sinfactura-types 1.10.310 → 1.10.311

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
+ ];
@@ -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
+ ];
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sinfactura-types",
3
- "version": "1.10.310",
3
+ "version": "1.10.311",
4
4
  "description": "Shared wire and entity contracts for the SINFACTURA platform.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",