declare global { /** Write shape for product create/update — carries the transient picture-removal control. */ type ProductUpsertInput = Partial & { /** Request-only: pictures to delete; the BE removes them from storage and from `pictures[]`. */ removePictures?: { url: string; }[]; }; /** * The surfaces a product can be sold on. Deliberately a CAPABILITY list on * `Product.sellableOn` rather than an enum, so a product can be sellable on * any subset — `['service','counter']` (a part you fit AND sell over the * counter) is the normal case in a repair shop, and an enum forbids it. * * - `storefront` — the public e-commerce checkout. * - `counter` — the operator/POS checkout. * - `service` — parts and labour consumed by a service-order delivery. * - `marketplace` — external channels (MercadoLibre today), which have never * had a server-side sellability predicate at all. * * ⚠️ ADDING a member here is fail-closed by construction: a row carrying an * explicit list does not gain the new channel, and every api call site that * passes a `SaleChannel` literal must be revisited for the compiler to stay * green. That is the point — the build breaks at the un-migrated site instead * of one of them silently defaulting. */ type SaleChannel = 'storefront' | 'counter' | 'service' | 'marketplace'; interface Product { storeId: string; productId: string; createdAt: number; updatedAt: number; disabled: boolean; /** * Storefront VISIBILITY. Independent of `disabled`: `disabled` means * soft-deleted, gone everywhere (operator pickers included); this means * "real and stocked, just not offered on the storefront" — a repair shop's * spare parts, ingredients, internal-use items. Defaults to visible * (`undefined`/`false`); operator-side READS (pickers, stock reports, * service parts, search) are UNAFFECTED and keep seeing it regardless * of this flag. * * ⚠️ It is NOT read-only, and that is the half the name hides: it also * REFUSES THE SALE with a 409 `PRODUCT_NOT_AVAILABLE` — but only as the * DERIVATION SOURCE now, never directly. The api's shared eligibility * pass is `findIneligibleProductIds` (`stacks/helpers/saleEligibility.ts`), * it resolves `sellableOn` rather than reading this flag, and it binds * four order paths, each passing its own channel literal: the operator/POS * checkout and `POST /orders mode=edit` (`'counter'`), the storefront * checkout (`'storefront'`), and service-order delivery (`'service'`). * Cite the SYMBOL, not a line — the pass moved once already and every line * number this docblock used to carry sent a reader to nothing. * * The consequence for a LEGACY row is unchanged and deliberately so: with * no `sellableOn`, hidden still derives to "sellable nowhere", so such a * product remains unsellable at the counter too. That was rarely what the * operator meant, and it is the whole reason `sellableOn` exists — but the * fix is to WRITE the capability list, never to reinterpret this flag. * * `sellableOn` SUPERSEDES this flag for SELLABILITY: read it, not this, to * answer "may this be sold here". `hiddenFromStorefront` keeps owning * VISIBILITY — the public catalogue, the direct-link 404 and the shopper * broadcast suppression — and stays the derivation source for a row that * carries no explicit `sellableOn`. */ hiddenFromStorefront?: boolean; /** * Per-channel sellability — the capability list that supersedes * `hiddenFromStorefront` for "may this be sold here". Says nothing about * VISIBILITY, which stays that flag's job. * * Three cases, all three load-bearing and distinct: * * 1. **Absent** ⇒ DERIVED from `hiddenFromStorefront`: `[]` when hidden, * every channel otherwise. This is the forward-only rule — every row * written before this field existed keeps its exact current behaviour, * and nothing is backfilled. The derivation belongs to ONE exported * resolver in the api; a call site that reimplements * `sellableOn?.includes(…)` inline reopens the divergence this field was * shaped to close. * 2. **`[]`** ⇒ sellable NOWHERE. A legal explicit value, and NOT the same * thing as absent: absent means "pre-model row, ask the old flag", * `[]` means "the merchant said no everywhere". A reader that collapses * the two with `?.length ? … : ALL` makes an explicit refusal sell * everywhere. * 3. A channel **added to the union later** is NOT retroactively granted to * a row that already carries an explicit list — absence from the list is * "the merchant has not said yes", so a new channel starts closed. This * is the property that makes the shape fail closed, and it is why a list * beat an enum: an enum re-interprets every stored member each time a * channel is added. * * The channel is a CALL-SITE constant, never a request parameter — a * client-supplied channel would be a straight authorization hole, since any * caller could claim `'counter'`. * * Orthogonal to `isService`, which is a stock-and-fiscal property (no stock * decrement, ARCA `Concepto`, service period). A repair part is an ORDINARY * product — `isService: false`, real `stock`, real `cost` — holding * `sellableOn: ['service']`. "Part" is a capability, not a third kind. * * ⚠️ Clearing this on the api needs `removeAttributes`, not * `fields: { sellableOn: undefined }`, which `dynamoUpdate` silently drops. * Writers should always write the full list. */ sellableOn?: SaleChannel[]; /** * Lowercase '#'-joined index the api maintains on every write. * * UNLIKE the same-named field on the other entities (where it is internal * and stripped at the wire boundary), this one is genuinely part of the * product read contract: `GET /products` returns it and the app filters * its product pickers on it client-side, so stripping it empties every * local product search. Optional because legacy rows predate it. */ search?: string; sku: string; name: string; description?: string; pictures?: { url: string; base64?: string; primary?: boolean; }[]; /** @deprecated Request-only control, never persisted or returned — use `ProductUpsertInput.removePictures`. */ removePictures?: { url: string; }[]; stock: number; minStock?: number; limit?: number; incomes?: { stockId: string; orderId?: string; returnId?: string; supplierName?: string; quantity: number; cost: number; }[]; sales?: { stockId: string; orderId: string; fullName: string; quantity: number; price: number; }[]; totalIncome?: number; totalSales?: number; zone?: string; currency: string; currencyValue?: number; currencyValueAt?: number; ivaType: number; categoryId: string; brandId: string; inOffer?: boolean; isNew: boolean; isService: boolean; cost: number; prices?: PriceSlot[]; channels?: Record; barcodes?: ProductBarcode[]; barcodePrimary?: string; variantGroupId?: string; variantAttributes?: { id: string; value: string; }[]; model?: string; seoTitle?: string; seoDescription?: string; attributes?: { name: string; value: string; evidence?: string; }[]; } interface ProductBarcode { value: string; type: 'EAN13' | 'EAN8' | 'UPC' | 'GTIN14' | 'CODE128' | 'internal'; isPrimary?: boolean; packSize?: number; source?: 'manual' | 'import' | 'generated'; } type ProductChannelStatus = 'linked' | 'pending' | 'paused' | 'rejected' | 'unlinked'; interface ProductChannelMapping { externalId?: string; userProductId?: string; familyId?: string; variationId?: string; status: ProductChannelStatus; linkedAt?: number; lastSyncedAt?: number; basis?: MlMatchBasis | 'manual'; syncErrors?: string[]; regime?: 'classic' | 'coexistence' | 'multi-origin'; stockMirrorOnly?: boolean; permalink?: string; listingPrice?: number; listingStock?: number; mlStatus?: string; pricePaused?: boolean; priceListId?: number; } } export {};