sinfactura-types 1.10.310 → 1.10.312

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cart.d.ts CHANGED
@@ -67,6 +67,18 @@ declare global {
67
67
  * redemption. See `CartCoupon`.
68
68
  */
69
69
  coupon?: CartCoupon;
70
+ /**
71
+ * The promotion this cart carries, if any — at most one. Applied by
72
+ * `applyPromotion`, cleared by `removePromotion`, and, like the coupon,
73
+ * holding it is not a redemption. See {@link CartPromotion}.
74
+ */
75
+ promotion?: CartPromotion;
76
+ /**
77
+ * What `promotion` came to on this cart's current lines, re-derived on
78
+ * every write. Absent when no promotion is applied. See
79
+ * {@link CartPromotionEffect}.
80
+ */
81
+ promotionEffect?: CartPromotionEffect;
70
82
  /**
71
83
  * The CART-LEVEL cut this coupon produced, re-derived on every write from
72
84
  * `coupon`'s frozen grant against the current subtotal. Absent when no
@@ -410,6 +422,89 @@ declare global {
410
422
  */
411
423
  maxDiscountAmount?: number;
412
424
  }
425
+ /**
426
+ * The promotion a cart currently carries — the granted TERMS, frozen at
427
+ * apply time, exactly as {@link CartCoupon} freezes a coupon's.
428
+ *
429
+ * ⚠️ The terms are frozen, not a reference to the `Promotion` row, and
430
+ * for the same reason the coupon's are: a promotion the merchant edits or
431
+ * ends after a shopper applied it keeps working for that cart until checkout
432
+ * re-validates. Re-reading the row on every cart write would reprice a
433
+ * shopper mid-session from a change they never saw.
434
+ *
435
+ * ⚠️ A cart carries at most ONE of these, the same single slot the
436
+ * coupon has. Stacking two promotions of the same type on one cart is a
437
+ * question nobody has answered — what a second buy-2-get-1 does to units
438
+ * the first already rewarded has no obvious right answer — so applying one
439
+ * over another REPLACES it rather than quietly compounding a cut. When that
440
+ * question is answered, this becomes a list and the replace becomes an
441
+ * append; until then a single slot is the shape that cannot pay out twice
442
+ * for the same unit.
443
+ *
444
+ * ⚠️ `'coupon'` is absent from the union on purpose. A coupon promotion's
445
+ * money lives in the `Coupon` row, reaches the cart through `applyCoupon`,
446
+ * and sits in `Cart.coupon` — a cart that could hold it here as well would
447
+ * have two places to disagree about one cut.
448
+ */
449
+ type CartPromotion = {
450
+ promotionId: string;
451
+ /** The promotion's name at apply time, so a receipt can say what was given. */
452
+ name: string;
453
+ /** When the shopper applied it. */
454
+ appliedAt: number;
455
+ } & ({
456
+ type: 'buyXGetY';
457
+ buyXGetY: BuyXGetYTerms;
458
+ } | {
459
+ type: 'freeShipping';
460
+ freeShipping: FreeShippingTerms;
461
+ } | {
462
+ type: 'bundle';
463
+ bundle: BundleTerms;
464
+ });
465
+ /**
466
+ * What the frozen {@link CartPromotion} actually came to on THIS cart —
467
+ * re-derived on every write, never stored as the answer.
468
+ *
469
+ * ⚠️ Server-owned, like {@link CartDiscount}'s `amount`. The client may
470
+ * read it and must never send it: it is a function of the cart's current
471
+ * lines and their current prices, so a client-supplied value is a claim about
472
+ * money the server is about to recompute anyway.
473
+ */
474
+ interface CartPromotionEffect {
475
+ promotionId: string;
476
+ type: 'buyXGetY' | 'freeShipping' | 'bundle';
477
+ /**
478
+ * The DERIVED cut, in currency units, already included in
479
+ * `CartTotals.discount`.
480
+ *
481
+ * ⚠️ ALWAYS `0` for `'freeShipping'`, and that is the invariant, not
482
+ * an accident of the current numbers. Free shipping is not a discount —
483
+ * it zeroes `CartTotals.shipping`, which is a different term of the
484
+ * `grandTotal` formula. A free-shipping promotion that ever contributed
485
+ * here would be cutting the merchandise price for a delivery the merchant
486
+ * agreed to absorb, and the comprobante would discriminate a discount the
487
+ * customer was never given on the goods.
488
+ */
489
+ amount: number;
490
+ /**
491
+ * How many times the promotion applied. `1` for a promotion that has no
492
+ * repeat notion (`'freeShipping'`), and `0` when the cart carries the
493
+ * promotion but no longer qualifies — a shopper who applied a buy-2-get-1
494
+ * and then removed a line keeps the frozen grant and earns nothing by it.
495
+ */
496
+ applications: number;
497
+ /**
498
+ * Whether this promotion waives the cart's shipping.
499
+ *
500
+ * ⚠️ `CartTotals.shipping` is structurally `0` today — nothing in the
501
+ * api computes a shipping cost yet, by design, because it is chosen at
502
+ * checkout. So the waiver currently changes no money, and this flag is the
503
+ * only place the GRANT survives to be read by whoever lands the shipping
504
+ * quote. Do not read its absence as "shipping was charged".
505
+ */
506
+ shippingWaived?: boolean;
507
+ }
413
508
  /**
414
509
  * A redeemable coupon. `PK: COUPON#{storeId}`, `SK: <normalized code>`.
415
510
  *
@@ -622,7 +717,7 @@ declare global {
622
717
  * at the DynamoDB marshaller instead of at validation.
623
718
  * - `merge.items` is `min(1).max(50)`.
624
719
  */
625
- type CartActionRequest = CartActionAddLine | CartActionChangeQuantity | CartActionRemoveLine | CartActionClear | CartActionMerge | CartActionSaveLine | CartActionRestoreLine | CartActionRemoveSavedLine | CartActionApplyCoupon | CartActionRemoveCoupon | CartActionSetLineDiscount;
720
+ type CartActionRequest = CartActionAddLine | CartActionChangeQuantity | CartActionRemoveLine | CartActionClear | CartActionMerge | CartActionSaveLine | CartActionRestoreLine | CartActionRemoveSavedLine | CartActionApplyCoupon | CartActionRemoveCoupon | CartActionApplyPromotion | CartActionRemovePromotion | CartActionSetLineDiscount;
626
721
  /**
627
722
  * Fields common to every action.
628
723
  *
@@ -897,6 +992,35 @@ declare global {
897
992
  interface CartActionRemoveCoupon extends CartActionBase {
898
993
  mode: 'removeCoupon';
899
994
  }
995
+ /**
996
+ * Applies a promotion to the cart by id.
997
+ *
998
+ * ⚠️ A cart holds AT MOST ONE promotion. Applying a second REPLACES the
999
+ * first rather than stacking — the same rule, and the same reason, as
1000
+ * {@link CartActionApplyCoupon}: nothing in the request says what a second
1001
+ * buy-2-get-1 does to units the first already rewarded.
1002
+ *
1003
+ * ⚠️ The action names an ID and nothing else. Every term comes off the
1004
+ * `Promotion` row, so a client cannot name its own cut.
1005
+ *
1006
+ * ⚠️ A promotion and a coupon can BOTH be on one cart. They are separate
1007
+ * slots and both cuts land in `CartTotals.discount`, which is clamped at the
1008
+ * subtotal — so the two together can take the cart to zero but never below.
1009
+ */
1010
+ interface CartActionApplyPromotion extends CartActionBase {
1011
+ mode: 'applyPromotion';
1012
+ promotionId: string;
1013
+ }
1014
+ /**
1015
+ * Clears the cart's promotion and its derived effect.
1016
+ *
1017
+ * A cart carrying no promotion is a no-op `200`, matching every other removal
1018
+ * verb here. Nothing is released, because a promotion has no redemption
1019
+ * counter to consume in the first place.
1020
+ */
1021
+ interface CartActionRemovePromotion extends CartActionBase {
1022
+ mode: 'removePromotion';
1023
+ }
900
1024
  /**
901
1025
  * Sets or clears ONE line's discount — the operator's per-line cut.
902
1026
  *
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.AUTOMATION_RULE_STATUSES = exports.AUTOMATION_SEND_STATUSES = exports.ATTRIBUTION_WINDOW_HOURS = exports.RFM_SEGMENT_LABELS = exports.AUTOMATION_TRIGGERS = void 0;
3
+ exports.CART_APPLICABLE_PROMOTION_TYPES = exports.PROMOTION_TYPES = exports.AUTOMATION_RULE_STATUSES = exports.AUTOMATION_SEND_STATUSES = exports.ATTRIBUTION_WINDOW_HOURS = exports.RFM_SEGMENT_LABELS = exports.AUTOMATION_TRIGGERS = void 0;
4
4
  /**
5
5
  * The {@link AutomationTrigger} vocabulary as a RUNTIME value, so the api's Zod
6
6
  * enum, the configuration screen's picker and the published union all derive
@@ -70,3 +70,31 @@ exports.AUTOMATION_RULE_STATUSES = [
70
70
  'active',
71
71
  'archived',
72
72
  ];
73
+ /**
74
+ * {@link PromotionType} as a runtime value — see {@link AUTOMATION_TRIGGERS}.
75
+ *
76
+ * ⚠️ `'coupon'` is in the tuple even though a row may express it by carrying
77
+ * no `type` at all. The tuple is the vocabulary a writer may DECLARE; the absent
78
+ * spelling is a reader's concern, and conflating the two is how a CRUD schema
79
+ * comes to reject the one value every legacy row means.
80
+ */
81
+ exports.PROMOTION_TYPES = [
82
+ 'coupon',
83
+ 'buyXGetY',
84
+ 'freeShipping',
85
+ 'bundle',
86
+ ];
87
+ /**
88
+ * The promotion types a CART can freeze onto itself — every type but `'coupon'`.
89
+ *
90
+ * ⚠️ `'coupon'` is excluded on purpose, and not for tidiness. A coupon
91
+ * promotion's money lives in the `Coupon` row its `couponCode` names, and that
92
+ * row already reaches the cart through `applyCoupon` and sits in `Cart.coupon`.
93
+ * Letting it also occupy the promotion slot would give one cart two places to
94
+ * disagree about the same cut.
95
+ */
96
+ exports.CART_APPLICABLE_PROMOTION_TYPES = [
97
+ 'buyXGetY',
98
+ 'freeShipping',
99
+ 'bundle',
100
+ ];
@@ -7,9 +7,14 @@ exports.NotificationTypeEnum = void 0;
7
7
  // Stripe hook, propagate-fx). Exported as a real enum so `api`
8
8
  // (stacks/helpers/notificationType.ts) and `app`
9
9
  // (src/domain/notificationType.ts) can drop their hand-mirrored copies
10
- // in follow-ups. DOLARBNA / ERROR / AFIP_CERT_EXPIRY have no User-row
11
- // read path — enum members only (AFIP_CERT_EXPIRY = the cert-expiry
12
- // alert type).
10
+ // in follow-ups. DOLARBNA / ERROR / ML_FACTURADOR_COLLISION have no
11
+ // User-row read path — enum members only. AFIP_CERT_EXPIRY (the
12
+ // cert-expiry alert type) was in that list and no longer belongs to it:
13
+ // `createStore` seeds it on the founding user and `afipCertMonitor`
14
+ // reads `getNotificationSubscribers` for it, so it is an ordinary opt-in
15
+ // key. Which members are opt-in is not cosmetic — it decides whether a
16
+ // producer may read subscribers at all, and a member seeded nowhere that
17
+ // does so fans out to an empty list on every store.
13
18
  var NotificationTypeEnum;
14
19
  (function (NotificationTypeEnum) {
15
20
  NotificationTypeEnum["ORDER"] = "ORDER";
@@ -23,6 +28,27 @@ var NotificationTypeEnum;
23
28
  // ML order-ingestion fanout — User-row read path
24
29
  // added by the orders_v2 worker.
25
30
  NotificationTypeEnum["MERCADOLIBRE"] = "MERCADOLIBRE";
31
+ /**
32
+ * A fiscal-document upload that failed with a signal saying the pack
33
+ * already carries one — the tenant's own Mercado Libre Facturador is
34
+ * probably active and issuing invoices against the same orders we are.
35
+ *
36
+ * ⚠️ Additive, on the same grounds as `ABANDONED_CART`:
37
+ * `UserNotifications` is `Partial<Record<NotificationTypeEnum, boolean>>`,
38
+ * so every existing preferences row stays valid and an absent key already
39
+ * reads as "not opted in". No consumer migration is owed.
40
+ *
41
+ * ⚠️ **No User-row read path, deliberately — the `ERROR` / `DOLARBNA`
42
+ * category, not the opt-in one.** Two independent reasons, and the second
43
+ * is the one that bites: a collision means invoices may be issued twice
44
+ * against one order, which no tenant would sensibly hold an opt-out for;
45
+ * and a NEW key is seeded nowhere, so an opt-in read path would deliver it
46
+ * to nobody. `createStore`'s `FOUNDING_USER_NOTIFICATIONS` is a fixed
47
+ * seven-key literal written to the founding user alone, and this repo is
48
+ * forward-only — no backfill will ever add this key to an existing row.
49
+ * The producer therefore fans out to every ADMIN of the store.
50
+ */
51
+ NotificationTypeEnum["ML_FACTURADOR_COLLISION"] = "ML_FACTURADOR_COLLISION";
26
52
  // Stock alerts — fired when a sale crosses a product's stock
27
53
  // threshold. LOW_STOCK at stock <= `Product.minStock`; OUT_OF_STOCK at
28
54
  // stock <= 0. Both have User-row opt-in read paths.
@@ -70,6 +70,7 @@ exports.WEBHOOK_EVENT_TYPES = [
70
70
  'product.stock_low',
71
71
  'product.stock_out',
72
72
  'order.created',
73
+ 'order.status_changed',
73
74
  'invoice.generated',
74
75
  ];
75
76
  /** Terminal outcomes for a print job. Closed union — a job printed, or it failed. */
@@ -80,8 +81,16 @@ exports.PRINT_JOB_SETTLED_OUTCOMES = ['printed', 'failed'];
80
81
  * ⚠️ A CLOSED union, and rule 1 above applies to it with full force: a fifth
81
82
  * order-minting path cannot be added here without breaking every subscriber
82
83
  * that switches exhaustively — it mints `order.created.v2` instead. The four
83
- * members are the complete set of paths that write an ORDER row today, each
84
- * verified at its own post-commit fan-out, not a sample.
84
+ * members are the paths that EMIT which is no longer the same thing as the
85
+ * paths that WRITE an ORDER row.
86
+ *
87
+ * ⚠️ Converting an approved presupuesto (`convertPresupuestoToOrder`) mints an
88
+ * ordinary order through the same `createOrder` every member above uses, and
89
+ * emits nothing here — precisely BECAUSE adding a fifth member is the breaking
90
+ * change the paragraph above forbids. The gap is known and deliberate, not an
91
+ * oversight to be tidied: do not close it by quietly widening this tuple. The
92
+ * rest of that path's fan-out (stock webhooks, notifications, the ML push) does
93
+ * run; it is only this event that waits on the v2 decision.
85
94
  *
86
95
  * `service_delivery` is the repair-shop handover: a `ready` → `delivered`
87
96
  * transition mints an ordinary sale out of the counter lines the operator
@@ -221,25 +221,155 @@ declare global {
221
221
  updatedAt?: number;
222
222
  }
223
223
  /**
224
- * A marketing wrapper around a coupon that already exists.
224
+ * How a promotion cuts money the discriminant on {@link Promotion}.
225
225
  *
226
- * ⚠️ It carries NO discount mechanics of its own. `couponCode` is the only
227
- * link, and every money field type, value, minimum subtotal, cap, currency
228
- * stays owned by `Coupon`. A second place that can disagree with the
229
- * coupon on terms is a second answer to "what did the customer actually get".
226
+ * ⚠️ `'coupon'` is the ABSENT value, not merely the first one. Every row
227
+ * written before this union existed carries no `type` at all, and
228
+ * forward-only means none of them is ever rewritten so a reader that asks
229
+ * `promotion.type === 'coupon'` gets `false` on every one of them.
230
+ *
231
+ * ⚠️ The move, for EVERY consumer of this package, is `promotion.type ??
232
+ * 'coupon'` — or a `switch` whose `default` arm is the coupon arm. The api
233
+ * has a `promotionType()` (`stacks/services/promotions.ts`) that does exactly
234
+ * this, but it lives under `stacks/` and nothing importing these types can
235
+ * reach it: it is named here so a reader stops looking for it, not so one
236
+ * goes hunting.
230
237
  */
231
- interface Promotion {
238
+ type PromotionType = 'coupon' | 'buyXGetY' | 'freeShipping' | 'bundle';
239
+ /** What every promotion carries, whatever it does to the money. */
240
+ interface PromotionBase {
232
241
  storeId: string;
233
242
  promotionId: string;
234
243
  name: string;
235
- /** FK into the existing `Coupon` entity. */
236
- couponCode?: string;
237
244
  status: 'draft' | 'active' | 'ended';
238
245
  startsAt?: number;
239
246
  endsAt?: number;
240
247
  createdAt: number;
241
248
  updatedAt?: number;
242
249
  }
250
+ /**
251
+ * A marketing wrapper around a coupon that already exists — the original
252
+ * shape, and the one every unmigrated row still has.
253
+ *
254
+ * ⚠️ It carries NO discount mechanics of its own. `couponCode` is the only
255
+ * link, and every money field — type, value, minimum subtotal, cap, currency
256
+ * — stays owned by `Coupon`. A second place that can disagree with the
257
+ * coupon on terms is a second answer to "what did the customer actually get".
258
+ *
259
+ * ⚠️ This variant's discriminant is OPTIONAL, and it is the only one that
260
+ * is. That is what keeps every pre-union row readable with no migration, and
261
+ * it is why narrowing reaches this arm through a `switch`'s `default` rather
262
+ * than through `case 'coupon'`.
263
+ */
264
+ interface CouponPromotion extends PromotionBase {
265
+ type?: 'coupon';
266
+ /** FK into the existing `Coupon` entity. */
267
+ couponCode?: string;
268
+ }
269
+ /**
270
+ * "Buy X, get Y" — a quantity of one product arms a cut on another.
271
+ *
272
+ * ⚠️ The reward is a PERCENT, never money, for the same reason
273
+ * {@link CartDiscount} carries the grant and not the cut: it is re-derived on
274
+ * every cart write against the unit price the rewarded line actually has at
275
+ * that moment, which moves whenever a quantity break re-resolves `basePrice`.
276
+ * A frozen money amount would keep paying out the price the cart had when the
277
+ * shopper applied it.
278
+ */
279
+ interface BuyXGetYTerms {
280
+ /** The product whose quantity ARMS the promotion. */
281
+ buyProductId: string;
282
+ /** How many units of `buyProductId` one application consumes. At least 1. */
283
+ buyQuantity: number;
284
+ /** The product the reward is taken on. May be the same as `buyProductId`. */
285
+ getProductId: string;
286
+ /** How many units of `getProductId` one application rewards. At least 1. */
287
+ getQuantity: number;
288
+ /**
289
+ * The cut on the rewarded units, as a percent of their line's unit price.
290
+ * `100` is the plain "get one free" shape; `50` is "get one half price".
291
+ */
292
+ getDiscountPercent: number;
293
+ /**
294
+ * How many times one cart may apply this promotion. Absent means as often
295
+ * as the cart's own quantities allow.
296
+ */
297
+ maxApplications?: number;
298
+ }
299
+ /**
300
+ * What a bundle charges once its members are all present.
301
+ *
302
+ * ⚠️ A discriminated pair rather than two optional fields, because "both
303
+ * set" and "neither set" are both unanswerable at money time and neither
304
+ * should be representable. The CRUD schema does not have to refuse a shape
305
+ * the type cannot express.
306
+ */
307
+ type BundlePricing = {
308
+ mode: 'fixedPrice';
309
+ /** What the whole bundle costs, in the cart's currency. */
310
+ amount: number;
311
+ } | {
312
+ mode: 'percentOff';
313
+ /** The cut on the summed member lines, as a percent. */
314
+ percent: number;
315
+ };
316
+ /** A set of products that, bought together, price as one. */
317
+ interface BundleTerms {
318
+ /**
319
+ * Every product that must be present, at one unit each, for a single
320
+ * application. Order is not significant.
321
+ */
322
+ productIds: readonly string[];
323
+ pricing: BundlePricing;
324
+ /**
325
+ * How many times one cart may apply this bundle. Absent means as often as
326
+ * the cart's own quantities allow.
327
+ */
328
+ maxApplications?: number;
329
+ }
330
+ /**
331
+ * When shipping is waived.
332
+ *
333
+ * ⚠️ Eligibility ONLY — there is deliberately no money field here.
334
+ * Free shipping does not grant a discount, it zeroes a total the cart already
335
+ * carries, so a value on this object would be a second, disagreeing answer to
336
+ * what the shipping costs.
337
+ */
338
+ interface FreeShippingTerms {
339
+ /** The cart subtotal that qualifies. Absent means any subtotal does. */
340
+ minSubtotal?: number;
341
+ /**
342
+ * At least one of these products must be on the cart. Absent means any
343
+ * cart qualifies.
344
+ */
345
+ productIds?: readonly string[];
346
+ }
347
+ interface BuyXGetYPromotion extends PromotionBase {
348
+ type: 'buyXGetY';
349
+ buyXGetY: BuyXGetYTerms;
350
+ }
351
+ interface FreeShippingPromotion extends PromotionBase {
352
+ type: 'freeShipping';
353
+ freeShipping: FreeShippingTerms;
354
+ }
355
+ interface BundlePromotion extends PromotionBase {
356
+ type: 'bundle';
357
+ bundle: BundleTerms;
358
+ }
359
+ /**
360
+ * A promotion a store runs — one of four mechanics, discriminated on `type`.
361
+ *
362
+ * ⚠️ Each variant carries its terms under a key NAMED FOR ITS TYPE
363
+ * (`buyXGetY`, `freeShipping`, `bundle`) rather than a shared `terms` key.
364
+ * That is what lets the union narrow: a shared key would have to be the union
365
+ * of every terms shape, so a `bundle` row could typecheck while carrying
366
+ * buy-X-get-Y terms and nothing but a runtime check would object.
367
+ *
368
+ * ⚠️ The `'coupon'` variant is the ONLY one whose money lives elsewhere
369
+ * — in the `Coupon` row its `couponCode` names. The other three carry their
370
+ * own mechanics, and are the only three a cart can freeze onto itself.
371
+ */
372
+ type Promotion = CouponPromotion | BuyXGetYPromotion | FreeShippingPromotion | BundlePromotion;
243
373
  /**
244
374
  * What arms an {@link AutomationRule} — the SIGNAL a rule listens for.
245
375
  *
@@ -724,4 +854,23 @@ export declare const ATTRIBUTION_WINDOW_HOURS = 72;
724
854
  export declare const AUTOMATION_SEND_STATUSES: readonly ["queued", "sent", "failed", "skipped"];
725
855
  /** {@link AutomationRuleStatus} as a runtime value — see {@link AUTOMATION_TRIGGERS}. */
726
856
  export declare const AUTOMATION_RULE_STATUSES: readonly ["paused", "active", "archived"];
857
+ /**
858
+ * {@link PromotionType} as a runtime value — see {@link AUTOMATION_TRIGGERS}.
859
+ *
860
+ * ⚠️ `'coupon'` is in the tuple even though a row may express it by carrying
861
+ * no `type` at all. The tuple is the vocabulary a writer may DECLARE; the absent
862
+ * spelling is a reader's concern, and conflating the two is how a CRUD schema
863
+ * comes to reject the one value every legacy row means.
864
+ */
865
+ export declare const PROMOTION_TYPES: readonly ["coupon", "buyXGetY", "freeShipping", "bundle"];
866
+ /**
867
+ * The promotion types a CART can freeze onto itself — every type but `'coupon'`.
868
+ *
869
+ * ⚠️ `'coupon'` is excluded on purpose, and not for tidiness. A coupon
870
+ * promotion's money lives in the `Coupon` row its `couponCode` names, and that
871
+ * row already reaches the cart through `applyCoupon` and sits in `Cart.coupon`.
872
+ * Letting it also occupy the promotion slot would give one cart two places to
873
+ * disagree about the same cut.
874
+ */
875
+ export declare const CART_APPLICABLE_PROMOTION_TYPES: readonly ["buyXGetY", "freeShipping", "bundle"];
727
876
  export {};
package/dist/marketing.js CHANGED
@@ -67,3 +67,31 @@ export const AUTOMATION_RULE_STATUSES = [
67
67
  'active',
68
68
  'archived',
69
69
  ];
70
+ /**
71
+ * {@link PromotionType} as a runtime value — see {@link AUTOMATION_TRIGGERS}.
72
+ *
73
+ * ⚠️ `'coupon'` is in the tuple even though a row may express it by carrying
74
+ * no `type` at all. The tuple is the vocabulary a writer may DECLARE; the absent
75
+ * spelling is a reader's concern, and conflating the two is how a CRUD schema
76
+ * comes to reject the one value every legacy row means.
77
+ */
78
+ export const PROMOTION_TYPES = [
79
+ 'coupon',
80
+ 'buyXGetY',
81
+ 'freeShipping',
82
+ 'bundle',
83
+ ];
84
+ /**
85
+ * The promotion types a CART can freeze onto itself — every type but `'coupon'`.
86
+ *
87
+ * ⚠️ `'coupon'` is excluded on purpose, and not for tidiness. A coupon
88
+ * promotion's money lives in the `Coupon` row its `couponCode` names, and that
89
+ * row already reaches the cart through `applyCoupon` and sits in `Cart.coupon`.
90
+ * Letting it also occupy the promotion slot would give one cart two places to
91
+ * disagree about the same cut.
92
+ */
93
+ export const CART_APPLICABLE_PROMOTION_TYPES = [
94
+ 'buyXGetY',
95
+ 'freeShipping',
96
+ 'bundle',
97
+ ];
@@ -8,6 +8,27 @@ export declare enum NotificationTypeEnum {
8
8
  ERROR = "ERROR",
9
9
  AFIP_CERT_EXPIRY = "AFIP_CERT_EXPIRY",
10
10
  MERCADOLIBRE = "MERCADOLIBRE",
11
+ /**
12
+ * A fiscal-document upload that failed with a signal saying the pack
13
+ * already carries one — the tenant's own Mercado Libre Facturador is
14
+ * probably active and issuing invoices against the same orders we are.
15
+ *
16
+ * ⚠️ Additive, on the same grounds as `ABANDONED_CART`:
17
+ * `UserNotifications` is `Partial<Record<NotificationTypeEnum, boolean>>`,
18
+ * so every existing preferences row stays valid and an absent key already
19
+ * reads as "not opted in". No consumer migration is owed.
20
+ *
21
+ * ⚠️ **No User-row read path, deliberately — the `ERROR` / `DOLARBNA`
22
+ * category, not the opt-in one.** Two independent reasons, and the second
23
+ * is the one that bites: a collision means invoices may be issued twice
24
+ * against one order, which no tenant would sensibly hold an opt-out for;
25
+ * and a NEW key is seeded nowhere, so an opt-in read path would deliver it
26
+ * to nobody. `createStore`'s `FOUNDING_USER_NOTIFICATIONS` is a fixed
27
+ * seven-key literal written to the founding user alone, and this repo is
28
+ * forward-only — no backfill will ever add this key to an existing row.
29
+ * The producer therefore fans out to every ADMIN of the store.
30
+ */
31
+ ML_FACTURADOR_COLLISION = "ML_FACTURADOR_COLLISION",
11
32
  LOW_STOCK = "LOW_STOCK",
12
33
  OUT_OF_STOCK = "OUT_OF_STOCK",
13
34
  SUPPORT = "SUPPORT",
@@ -4,9 +4,14 @@
4
4
  // Stripe hook, propagate-fx). Exported as a real enum so `api`
5
5
  // (stacks/helpers/notificationType.ts) and `app`
6
6
  // (src/domain/notificationType.ts) can drop their hand-mirrored copies
7
- // in follow-ups. DOLARBNA / ERROR / AFIP_CERT_EXPIRY have no User-row
8
- // read path — enum members only (AFIP_CERT_EXPIRY = the cert-expiry
9
- // alert type).
7
+ // in follow-ups. DOLARBNA / ERROR / ML_FACTURADOR_COLLISION have no
8
+ // User-row read path — enum members only. AFIP_CERT_EXPIRY (the
9
+ // cert-expiry alert type) was in that list and no longer belongs to it:
10
+ // `createStore` seeds it on the founding user and `afipCertMonitor`
11
+ // reads `getNotificationSubscribers` for it, so it is an ordinary opt-in
12
+ // key. Which members are opt-in is not cosmetic — it decides whether a
13
+ // producer may read subscribers at all, and a member seeded nowhere that
14
+ // does so fans out to an empty list on every store.
10
15
  export var NotificationTypeEnum;
11
16
  (function (NotificationTypeEnum) {
12
17
  NotificationTypeEnum["ORDER"] = "ORDER";
@@ -20,6 +25,27 @@ export var NotificationTypeEnum;
20
25
  // ML order-ingestion fanout — User-row read path
21
26
  // added by the orders_v2 worker.
22
27
  NotificationTypeEnum["MERCADOLIBRE"] = "MERCADOLIBRE";
28
+ /**
29
+ * A fiscal-document upload that failed with a signal saying the pack
30
+ * already carries one — the tenant's own Mercado Libre Facturador is
31
+ * probably active and issuing invoices against the same orders we are.
32
+ *
33
+ * ⚠️ Additive, on the same grounds as `ABANDONED_CART`:
34
+ * `UserNotifications` is `Partial<Record<NotificationTypeEnum, boolean>>`,
35
+ * so every existing preferences row stays valid and an absent key already
36
+ * reads as "not opted in". No consumer migration is owed.
37
+ *
38
+ * ⚠️ **No User-row read path, deliberately — the `ERROR` / `DOLARBNA`
39
+ * category, not the opt-in one.** Two independent reasons, and the second
40
+ * is the one that bites: a collision means invoices may be issued twice
41
+ * against one order, which no tenant would sensibly hold an opt-out for;
42
+ * and a NEW key is seeded nowhere, so an opt-in read path would deliver it
43
+ * to nobody. `createStore`'s `FOUNDING_USER_NOTIFICATIONS` is a fixed
44
+ * seven-key literal written to the founding user alone, and this repo is
45
+ * forward-only — no backfill will ever add this key to an existing row.
46
+ * The producer therefore fans out to every ADMIN of the store.
47
+ */
48
+ NotificationTypeEnum["ML_FACTURADOR_COLLISION"] = "ML_FACTURADOR_COLLISION";
23
49
  // Stock alerts — fired when a sale crosses a product's stock
24
50
  // threshold. LOW_STOCK at stock <= `Product.minStock`; OUT_OF_STOCK at
25
51
  // stock <= 0. Both have User-row opt-in read paths.
package/dist/store.d.ts CHANGED
@@ -713,6 +713,33 @@ declare global {
713
713
  * `Mercadopago.expiresAt` is.
714
714
  */
715
715
  accessTokenExpiresAt?: number;
716
+ /**
717
+ * unix ms of the last SUCCESSFUL connect-time send verification
718
+ * (`POST /gmail/test-send`) — the tenant asked us to prove the send path
719
+ * works, and it did.
720
+ *
721
+ * ⚠️ **Written on SUCCESS ONLY, and there is deliberately no
722
+ * `lastFailedAt` sibling.** A failure is a transient condition the
723
+ * operator is already looking at; persisting it invites a screen that
724
+ * renders stale alarm about a problem reconnecting fixed five minutes
725
+ * later. This stays a FRESHNESS signal — `status` already carries status,
726
+ * and two representations of status is how they drift.
727
+ *
728
+ * ⚠️ **Absent means "never verified", NEVER "verification failed".** The
729
+ * platform is forward-only and never backfills, so every tenant who
730
+ * connected before this shipped reads as absent. A consumer rendering
731
+ * absence as an error state puts a red badge on all of them.
732
+ *
733
+ * ⚠️ **Staleness is the consumer's call.** There is no server-computed
734
+ * "verified recently" boolean and there must not be one — the threshold
735
+ * is a product decision that will change, and a boolean freezes it into
736
+ * the wire where every consumer inherits it.
737
+ *
738
+ * Distinct from `lastTokenRefreshAt`, which is token-level truth: a
739
+ * token can refresh perfectly while delivery is broken, and that state
740
+ * is exactly what the verification endpoint exists to detect.
741
+ */
742
+ gmailLastVerifiedAt?: number;
716
743
  }
717
744
  type FxAutoUpdateStrategy = 'overwrite' | 'overwrite-if-stale' | 'notify-only';
718
745
  interface FxAutoUpdateBinding {
@@ -494,10 +494,18 @@ declare global {
494
494
  interface BasketDiscountGrantedEvent extends UserActivityEventBase {
495
495
  event: 'Basket Discount Granted';
496
496
  /** Which verb — a withdrawal is as worth auditing as a grant. */
497
- verb: 'setLineDiscount' | 'applyCoupon' | 'removeCoupon';
497
+ verb: 'setLineDiscount' | 'applyCoupon' | 'removeCoupon' | 'applyPromotion' | 'removePromotion';
498
498
  /**
499
- * The grant's unit. Absent on `removeCoupon`, which withdraws rather than
500
- * grants and therefore has no terms of its own.
499
+ * The grant's unit. Absent on `removeCoupon` and `removePromotion`, which
500
+ * withdraw rather than grant and therefore have no terms of their own.
501
+ *
502
+ * ⚠️ Also absent on `applyPromotion`, which GRANTS — and that is the
503
+ * one case where absence does not mean withdrawal. A promotion's grant is a
504
+ * whole terms object (a trigger, a reward, a repeat cap); there is no
505
+ * `(type, value)` pair that states it without lying by omission, so `code`
506
+ * carries the promotion id and the row holds the terms. A review reading
507
+ * this trail for abuse gets `amount` — the money — either way, which is
508
+ * the figure the review is actually made of.
501
509
  */
502
510
  type?: 'percent' | 'amount';
503
511
  /** The GRANT, in the unit `type` names — NOT money. See `amount`. */
@@ -515,6 +523,16 @@ declare global {
515
523
  amount?: number;
516
524
  /** The coupon code, on the two coupon verbs. Absent for a line discount. */
517
525
  code?: string;
526
+ /**
527
+ * The promotion, on `applyPromotion`.
528
+ *
529
+ * ⚠️ Its OWN field rather than a promotion id shipped in `code`. They
530
+ * are different identifiers into different entities, and a trail that put
531
+ * both in one column would make "which coupon was granted most often"
532
+ * unanswerable without knowing each row's verb — which is exactly the kind
533
+ * of join a review does not do before drawing a conclusion.
534
+ */
535
+ promotion_id?: string;
518
536
  /** The line the cut was applied to, on `setLineDiscount`. */
519
537
  line_id?: string;
520
538
  /** ⚠️ Optional for the same reason as `BasketUpdatedEvent.customer_id`: a
package/dist/webhook.d.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  * immediately subscribable — and a store that subscribes to an event with no
11
11
  * emit site gets silence it cannot distinguish from "nothing happened".
12
12
  */
13
- export declare const WEBHOOK_EVENT_TYPES: readonly ["print.queued", "print.received", "print.printed", "print.failed", "print.agent.connected", "print.agent.disconnected", "print.printer.online", "print.printer.offline", "print.job.settled", "payment.received", "product.stock_low", "product.stock_out", "order.created", "invoice.generated"];
13
+ export declare const WEBHOOK_EVENT_TYPES: readonly ["print.queued", "print.received", "print.printed", "print.failed", "print.agent.connected", "print.agent.disconnected", "print.printer.online", "print.printer.offline", "print.job.settled", "payment.received", "product.stock_low", "product.stock_out", "order.created", "order.status_changed", "invoice.generated"];
14
14
  export type WebhookEventType = (typeof WEBHOOK_EVENT_TYPES)[number];
15
15
  /** Terminal outcomes for a print job. Closed union — a job printed, or it failed. */
16
16
  export declare const PRINT_JOB_SETTLED_OUTCOMES: readonly ["printed", "failed"];
@@ -194,8 +194,16 @@ export interface ProductStockOutPayload {
194
194
  * ⚠️ A CLOSED union, and rule 1 above applies to it with full force: a fifth
195
195
  * order-minting path cannot be added here without breaking every subscriber
196
196
  * that switches exhaustively — it mints `order.created.v2` instead. The four
197
- * members are the complete set of paths that write an ORDER row today, each
198
- * verified at its own post-commit fan-out, not a sample.
197
+ * members are the paths that EMIT which is no longer the same thing as the
198
+ * paths that WRITE an ORDER row.
199
+ *
200
+ * ⚠️ Converting an approved presupuesto (`convertPresupuestoToOrder`) mints an
201
+ * ordinary order through the same `createOrder` every member above uses, and
202
+ * emits nothing here — precisely BECAUSE adding a fifth member is the breaking
203
+ * change the paragraph above forbids. The gap is known and deliberate, not an
204
+ * oversight to be tidied: do not close it by quietly widening this tuple. The
205
+ * rest of that path's fan-out (stock webhooks, notifications, the ML push) does
206
+ * run; it is only this event that waits on the v2 decision.
199
207
  *
200
208
  * `service_delivery` is the repair-shop handover: a `ready` → `delivered`
201
209
  * transition mints an ordinary sale out of the counter lines the operator
@@ -277,6 +285,103 @@ export interface OrderCreatedPayload {
277
285
  */
278
286
  customerId?: string;
279
287
  }
288
+ /**
289
+ * Payload for `order.status_changed` — an order MOVED along one of its status
290
+ * axes, and the move is durable.
291
+ *
292
+ * ── WHY THERE IS AN `axis` FIELD AT ALL ──────────────────────────────────
293
+ *
294
+ * `Order` has **no single `status` field**. It carries `fulfilmentStatus`
295
+ * (`pending | ready | delivered | not_delivered`) and `financialStatus`
296
+ * (`pending | partial | paid`) as independent axes, plus a third cancellation
297
+ * axis (`cancelledAt`/`cancelledBy`/`cancellationSource`) that moves neither.
298
+ *
299
+ * ⚠️ **`'pending'` is a member of BOTH unions.** A payload carrying only
300
+ * `from`/`to` is therefore genuinely ambiguous — `to: 'pending'` alone cannot
301
+ * tell a subscriber whether an order became unfulfilled or became unpaid, and
302
+ * those are opposite operational signals. The axis is what disambiguates them,
303
+ * so it is REQUIRED and is never inferred from the status spelling.
304
+ *
305
+ * ── WHY `axis` HAS EXACTLY ONE MEMBER TODAY ──────────────────────────────
306
+ *
307
+ * Only the FULFILMENT axis is shipped. `'financial'` is deliberately NOT
308
+ * declared here, and this is the line most likely to be "helpfully" widened by
309
+ * someone who reads the three-axis description above and assumes the union was
310
+ * simply left unfinished.
311
+ *
312
+ * Rule 3 above is the reason: a declared value means "this can occur on a real
313
+ * event", never "we intend to emit this later". Declaring `'financial'` before
314
+ * a financial seam emits would publish a value no subscriber can ever observe
315
+ * — the union equivalent of publishing an event nothing emits, and it would
316
+ * read to an integrator as a branch worth writing and testing against.
317
+ *
318
+ * ⚠️ Adding `'financial'` LATER is a BREAKING change under rule 1 (it widens a
319
+ * union an integrator may exhaustively switch on) and it is breaking whether or
320
+ * not it was pre-announced here — pre-declaring buys nothing and costs honesty.
321
+ * The financial axis ships behind rule 2 instead: either an announced widen, or
322
+ * a new `order.status_changed.v2`. That decision belongs to whoever builds the
323
+ * financial seam, with the settlement and payment-linking writers in front of
324
+ * them; it is not owed in advance.
325
+ *
326
+ * ── WHICH MOVES REACH THE WIRE ───────────────────────────────────────────
327
+ *
328
+ * REAL moves only, from the two single-order fulfilment seams: the operator
329
+ * delivery/un-delivery toggle, and the MercadoLibre shipment sync. Two writers
330
+ * touch `fulfilmentStatus` and are deliberately SILENT, because neither is a
331
+ * transition:
332
+ *
333
+ * - the soft-delete hide path, which rewrites the field wholesale and
334
+ * deliberately writes no `statusHistory` entry either;
335
+ * - the order-edit heal branch, which writes a status where none was stored.
336
+ * A back-catalogue row acquiring the field it always implied is a data
337
+ * repair, and announcing it as a status change would tell an integrator an
338
+ * order moved on a day nobody touched it.
339
+ *
340
+ * A self-edge never emits. Both seams gate on the stored value differing from
341
+ * the computed one, so a redundant write — a repeated delivery toggle, a
342
+ * shipment webhook replayed at the same state — is silence, not a duplicate.
343
+ *
344
+ * ⚠️ **No `total` and no `currency`**, for the same denomination reason set out
345
+ * on {@link OrderCreatedPayload}: `Order.currency` is a catalogId, not ISO
346
+ * 4217. A fulfilment move does not change either figure in any case.
347
+ *
348
+ * ⚠️ **Enumerated, never spread from the row.** `getOrderById` does not delete
349
+ * `search`, which embeds the customer's `fullName` (Ley 25.326).
350
+ */
351
+ export interface OrderStatusChangedPayload {
352
+ orderId: string;
353
+ /**
354
+ * Which of the order's independent status axes moved.
355
+ *
356
+ * Single-member today. See the block above before widening it — the widen
357
+ * is the breaking change, not the pre-declaration.
358
+ */
359
+ axis: 'fulfilment';
360
+ /**
361
+ * The state moved FROM.
362
+ *
363
+ * ⚠️ **Genuinely optional, and it is not a "we might add it later".** Every
364
+ * order written before `fulfilmentStatus` existed carries no such field, and
365
+ * this platform is forward-only — nothing backfills. So the first real move
366
+ * on a back-catalogue order has no prior state to name, and a subscriber
367
+ * must handle its absence rather than treating it as a malformed event.
368
+ *
369
+ * Absent is never the same as equal to {@link to}: a self-edge does not
370
+ * emit at all.
371
+ */
372
+ from?: OrderFulfilmentStatus;
373
+ /** The state moved TO. Always the value the committed row now carries. */
374
+ to: OrderFulfilmentStatus;
375
+ /**
376
+ * Server-stamped Unix milliseconds, taken at the post-commit emit — the
377
+ * moment the api ANNOUNCED the move, on the same convention and for the same
378
+ * reason as {@link OrderCreatedPayload.occurredAt}.
379
+ *
380
+ * ⚠️ NOT the `statusHistory` entry's own `timestamp`. The two are within
381
+ * milliseconds of each other and they are not the same clock reading.
382
+ */
383
+ occurredAt: number;
384
+ }
280
385
  /**
281
386
  * Payload for `invoice.generated` — ARCA authorised a fiscal voucher and its
282
387
  * `INVOICE#{storeId}` row is durable.
package/dist/webhook.js CHANGED
@@ -67,6 +67,7 @@ export const WEBHOOK_EVENT_TYPES = [
67
67
  'product.stock_low',
68
68
  'product.stock_out',
69
69
  'order.created',
70
+ 'order.status_changed',
70
71
  'invoice.generated',
71
72
  ];
72
73
  /** Terminal outcomes for a print job. Closed union — a job printed, or it failed. */
@@ -77,8 +78,16 @@ export const PRINT_JOB_SETTLED_OUTCOMES = ['printed', 'failed'];
77
78
  * ⚠️ A CLOSED union, and rule 1 above applies to it with full force: a fifth
78
79
  * order-minting path cannot be added here without breaking every subscriber
79
80
  * that switches exhaustively — it mints `order.created.v2` instead. The four
80
- * members are the complete set of paths that write an ORDER row today, each
81
- * verified at its own post-commit fan-out, not a sample.
81
+ * members are the paths that EMIT which is no longer the same thing as the
82
+ * paths that WRITE an ORDER row.
83
+ *
84
+ * ⚠️ Converting an approved presupuesto (`convertPresupuestoToOrder`) mints an
85
+ * ordinary order through the same `createOrder` every member above uses, and
86
+ * emits nothing here — precisely BECAUSE adding a fifth member is the breaking
87
+ * change the paragraph above forbids. The gap is known and deliberate, not an
88
+ * oversight to be tidied: do not close it by quietly widening this tuple. The
89
+ * rest of that path's fan-out (stock webhooks, notifications, the ML push) does
90
+ * run; it is only this event that waits on the v2 decision.
82
91
  *
83
92
  * `service_delivery` is the repair-shop handover: a `ready` → `delivered`
84
93
  * transition mints an ordinary sale out of the counter lines the operator
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sinfactura-types",
3
- "version": "1.10.310",
3
+ "version": "1.10.312",
4
4
  "description": "Shared wire and entity contracts for the SINFACTURA platform.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",