sahab-utils 1.7.4 → 1.7.6

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.
@@ -1,15 +1,6 @@
1
1
  import { CarSize, Discount, Gift, GiftConfig, GiftItem, Order } from '../constants/types';
2
- /**
3
- * Gifting — the shared rules.
4
- *
5
- * Pure by design: no Firestore, no clock, no id generation. The cloud function owns charging,
6
- * issuing and redeeming; the apps own showing it; all three agree because they call these.
7
- *
8
- * A gift is not a new kind of discount. Once paid for it becomes an ordinary `Discount` — 100%
9
- * off, capped at what the giver paid, scoped by `servicesIds` / `addonsIds` — so the whole
10
- * pricing pipeline handles it unchanged. `grossPromoDiscount` already sums only the scoped lines
11
- * and clamps to `maxAmount`, which is what makes a multi-item gift work with no changes at all.
12
- */
2
+ /** Gifting — the shared rules. Pure (no Firestore/clock/ids); the cloud function owns charging and
3
+ * redeeming. A gift becomes an ordinary 100%-off `Discount` once paid, so the pricing pipeline needs no changes. */
13
4
  /** Default when the back office has not set one. */
14
5
  export declare const DEFAULT_GIFT_EXPIRY_MONTHS = 12;
15
6
  /** A gift shorter than this is barely usable; longer is an open-ended liability. */
@@ -21,77 +12,41 @@ export declare function giftTotal(items?: GiftItem[]): number;
21
12
  export declare const GIFT_MESSAGE_MAX_CHARS = 70;
22
13
  /** Used when the back office has not set `GiftConfig.giftMessageSurcharge`. */
23
14
  export declare const GIFT_MESSAGE_SURCHARGE_DEFAULT = 3;
24
- /**
25
- * What a personal message, if any, adds to the gift's price.
26
- *
27
- * Zero for a blank or whitespace-only message — writing nothing costs nothing. Otherwise the
28
- * back-office amount if one is configured, else the default, same shape as `giftExpiryMonths`.
29
- */
15
+ /** What a personal message, if any, adds to the price. Zero for blank/whitespace-only; otherwise the
16
+ * back-office amount if configured, else the default — same shape as `giftExpiryMonths`. */
30
17
  export declare function giftMessageSurcharge(message?: string, config?: GiftConfig): number;
31
18
  /** The gifted service ids. */
32
19
  export declare function giftServiceIds(gift?: Pick<Gift, 'items'>): string[];
33
20
  /** The gifted addon ids. */
34
21
  export declare function giftAddonIds(gift?: Pick<Gift, 'items'>): string[];
35
- /**
36
- * Expiry months, clamped.
37
- *
38
- * A misconfigured `0` would expire every gift at the instant it was sold, so the bound is
39
- * enforced here rather than trusted from the config document.
40
- */
22
+ /** Expiry months, clamped — a misconfigured `0` would expire every gift instantly, so the bound
23
+ * is enforced here rather than trusted from the config document. */
41
24
  export declare function giftExpiryMonths(config?: GiftConfig): number;
42
- /**
43
- * When a gift issued now should expire.
44
- *
45
- * Month arithmetic rather than a fixed day count, so "12 months" lands on the same date. Clamped
46
- * to the last day of the target month, which is what stops 31 January + 1 month landing in March.
47
- */
25
+ /** When a gift issued now should expire. Month arithmetic (not a fixed day count) so "12 months"
26
+ * lands on the same date, clamped to the target month's last day so 31 Jan + 1 month doesn't land in March. */
48
27
  export declare function giftExpiryDate(issuedAt: Date, config?: GiftConfig): Date;
49
- /**
50
- * Whether a service can be gifted at all.
51
- *
52
- * Multi-visit packages are excluded: the promo binds to the first visit only and the remaining
53
- * visits are created as separate orders, an interaction that has not been verified end to end.
54
- * A gift carries money already collected, so it is not the place to find out.
55
- */
28
+ /** Whether a service can be gifted. Multi-visit packages are excluded — the promo binds only the
29
+ * first visit and the rest become separate orders, an interaction never verified end to end, and a gift already holds real money. */
56
30
  export declare function isGiftableService(service?: {
57
31
  numberOfVisits?: number;
58
32
  }): boolean;
59
33
  /** Whether a gift is in a state where it can still be spent. */
60
34
  export declare function isGiftSpendable(gift: Gift | undefined, at: Date, expiresAt?: Date): boolean;
61
- /**
62
- * The voucher for a paid-for gift, ready to write to `promoCodes`.
63
- *
64
- * 100% off, capped at what the giver actually paid, and **Sahab-funded without exception**.
65
- * Funding is the one field that must never be wrong: a provider-funded gift voucher makes the
66
- * carwash perform the wash for nothing while Sahab keeps the giver's money. It is hardcoded here
67
- * rather than passed in so there is no call site that can get it wrong.
68
- */
35
+ /** The voucher for a paid gift — 100% off, capped at what the giver paid, **always Sahab-funded**.
36
+ * Hardcoded here (not passed in) so no call site can misfund it and leave the carwash unpaid. */
69
37
  export declare function giftDiscount(gift: Gift, dates: {
70
38
  validFrom: unknown;
71
39
  validTo: unknown;
72
40
  }): Discount;
73
- /**
74
- * Whether an order contains **at least one** of the gifted items.
75
- *
76
- * One, not all. The value is bounded by `maxAmount`, so a recipient who takes only part of the
77
- * basket simply realises less, and anything they add prices normally — failing the order instead
78
- * would be the worse outcome.
79
- *
80
- * The check still has to exist: an order with *none* of the gifted items computes a zero discount
81
- * yet would otherwise still mark the gift redeemed, destroying it for nothing.
82
- */
41
+ /** Whether an order contains at least one gifted item (not all — `maxAmount` bounds the value, so
42
+ * taking part of the basket just realises less). Needed so a matchless order doesn't wrongly mark the gift redeemed. */
83
43
  export declare function orderContainsGiftedItem(order: Order | undefined, voucher: Discount | undefined): boolean;
84
44
  /** The gift id an order is spending, if it is spending one. */
85
45
  export declare function orderGiftId(order?: Order): string | undefined;
86
46
  /** Whether this order is redeeming a gift. */
87
47
  export declare function isGiftRedemption(order?: Order): boolean;
88
- /**
89
- * Whether a gift may be spent on a given order.
90
- *
91
- * Every condition the server must hold at redemption, in one place, so the transactional path and
92
- * anything that previews it cannot drift apart. Deliberately does **not** check `usedBy`: that is
93
- * read-then-write and races. Single use is enforced by the gift's own status inside a transaction.
94
- */
48
+ /** Whether a gift may be spent on this order — every redemption condition in one place, so the
49
+ * transactional path can't drift from a preview. Deliberately skips `usedBy` (races); status inside the transaction enforces single use. */
95
50
  export declare function giftRedeemableOnOrder(args: {
96
51
  gift?: Gift;
97
52
  voucher?: Discount;
@@ -99,10 +54,6 @@ export declare function giftRedeemableOnOrder(args: {
99
54
  at: Date;
100
55
  expiresAt?: Date;
101
56
  }): boolean;
102
- /**
103
- * Car sizes a gift can be priced for.
104
- *
105
- * The giver picks one; the recipient's car may be a different size. Prices differ by size but the
106
- * cap is a fixed amount, so the value transfers intact and they top up or under-spend.
107
- */
57
+ /** Car sizes a gift can be priced for. The giver picks one; the recipient's car may differ — prices
58
+ * vary by size but the cap is fixed, so value transfers intact and they top up or under-spend. */
108
59
  export declare function giftCarSizes(): CarSize[];
package/dist/ts/gifts.js CHANGED
@@ -17,17 +17,8 @@ exports.giftRedeemableOnOrder = giftRedeemableOnOrder;
17
17
  exports.giftCarSizes = giftCarSizes;
18
18
  const types_1 = require("../constants/types");
19
19
  const utils_1 = require("./utils");
20
- /**
21
- * Gifting — the shared rules.
22
- *
23
- * Pure by design: no Firestore, no clock, no id generation. The cloud function owns charging,
24
- * issuing and redeeming; the apps own showing it; all three agree because they call these.
25
- *
26
- * A gift is not a new kind of discount. Once paid for it becomes an ordinary `Discount` — 100%
27
- * off, capped at what the giver paid, scoped by `servicesIds` / `addonsIds` — so the whole
28
- * pricing pipeline handles it unchanged. `grossPromoDiscount` already sums only the scoped lines
29
- * and clamps to `maxAmount`, which is what makes a multi-item gift work with no changes at all.
30
- */
20
+ /** Gifting — the shared rules. Pure (no Firestore/clock/ids); the cloud function owns charging and
21
+ * redeeming. A gift becomes an ordinary 100%-off `Discount` once paid, so the pricing pipeline needs no changes. */
31
22
  /** Default when the back office has not set one. */
32
23
  exports.DEFAULT_GIFT_EXPIRY_MONTHS = 12;
33
24
  /** A gift shorter than this is barely usable; longer is an open-ended liability. */
@@ -41,12 +32,8 @@ function giftTotal(items) {
41
32
  exports.GIFT_MESSAGE_MAX_CHARS = 70;
42
33
  /** Used when the back office has not set `GiftConfig.giftMessageSurcharge`. */
43
34
  exports.GIFT_MESSAGE_SURCHARGE_DEFAULT = 3;
44
- /**
45
- * What a personal message, if any, adds to the gift's price.
46
- *
47
- * Zero for a blank or whitespace-only message — writing nothing costs nothing. Otherwise the
48
- * back-office amount if one is configured, else the default, same shape as `giftExpiryMonths`.
49
- */
35
+ /** What a personal message, if any, adds to the price. Zero for blank/whitespace-only; otherwise the
36
+ * back-office amount if configured, else the default — same shape as `giftExpiryMonths`. */
50
37
  function giftMessageSurcharge(message, config) {
51
38
  if (!(message !== null && message !== void 0 ? message : '').trim().length)
52
39
  return 0;
@@ -63,24 +50,16 @@ function giftAddonIds(gift) {
63
50
  var _a;
64
51
  return ((_a = gift === null || gift === void 0 ? void 0 : gift.items) !== null && _a !== void 0 ? _a : []).filter((i) => (i === null || i === void 0 ? void 0 : i.kind) === 'addon' && !!i.id).map((i) => i.id);
65
52
  }
66
- /**
67
- * Expiry months, clamped.
68
- *
69
- * A misconfigured `0` would expire every gift at the instant it was sold, so the bound is
70
- * enforced here rather than trusted from the config document.
71
- */
53
+ /** Expiry months, clamped — a misconfigured `0` would expire every gift instantly, so the bound
54
+ * is enforced here rather than trusted from the config document. */
72
55
  function giftExpiryMonths(config) {
73
56
  const raw = config === null || config === void 0 ? void 0 : config.giftExpiryMonths;
74
57
  if (raw === undefined || raw === null || !isFinite(raw))
75
58
  return exports.DEFAULT_GIFT_EXPIRY_MONTHS;
76
59
  return Math.min(exports.MAX_GIFT_EXPIRY_MONTHS, Math.max(exports.MIN_GIFT_EXPIRY_MONTHS, Math.floor(raw)));
77
60
  }
78
- /**
79
- * When a gift issued now should expire.
80
- *
81
- * Month arithmetic rather than a fixed day count, so "12 months" lands on the same date. Clamped
82
- * to the last day of the target month, which is what stops 31 January + 1 month landing in March.
83
- */
61
+ /** When a gift issued now should expire. Month arithmetic (not a fixed day count) so "12 months"
62
+ * lands on the same date, clamped to the target month's last day so 31 Jan + 1 month doesn't land in March. */
84
63
  function giftExpiryDate(issuedAt, config) {
85
64
  const months = giftExpiryMonths(config);
86
65
  const out = new Date(issuedAt.getTime());
@@ -91,13 +70,8 @@ function giftExpiryDate(issuedAt, config) {
91
70
  out.setDate(Math.min(day, lastDayOfTargetMonth));
92
71
  return out;
93
72
  }
94
- /**
95
- * Whether a service can be gifted at all.
96
- *
97
- * Multi-visit packages are excluded: the promo binds to the first visit only and the remaining
98
- * visits are created as separate orders, an interaction that has not been verified end to end.
99
- * A gift carries money already collected, so it is not the place to find out.
100
- */
73
+ /** Whether a service can be gifted. Multi-visit packages are excluded — the promo binds only the
74
+ * first visit and the rest become separate orders, an interaction never verified end to end, and a gift already holds real money. */
101
75
  function isGiftableService(service) {
102
76
  var _a;
103
77
  return (0, utils_1.preventNaN)((_a = service === null || service === void 0 ? void 0 : service.numberOfVisits) !== null && _a !== void 0 ? _a : 1) <= 1;
@@ -110,21 +84,14 @@ function isGiftSpendable(gift, at, expiresAt) {
110
84
  return false;
111
85
  return true;
112
86
  }
113
- /**
114
- * The voucher for a paid-for gift, ready to write to `promoCodes`.
115
- *
116
- * 100% off, capped at what the giver actually paid, and **Sahab-funded without exception**.
117
- * Funding is the one field that must never be wrong: a provider-funded gift voucher makes the
118
- * carwash perform the wash for nothing while Sahab keeps the giver's money. It is hardcoded here
119
- * rather than passed in so there is no call site that can get it wrong.
120
- */
87
+ /** The voucher for a paid gift — 100% off, capped at what the giver paid, **always Sahab-funded**.
88
+ * Hardcoded here (not passed in) so no call site can misfund it and leave the carwash unpaid. */
121
89
  function giftDiscount(gift, dates) {
122
90
  return {
123
91
  isPercentage: true,
124
92
  amount: 100,
125
- // Bounds the discount to what the basket is actually worth, whatever the recipient
126
- // orders — never to `gift.amount`, which can include a message surcharge that bought
127
- // no redeemable service and must not inflate how much the voucher can discount.
93
+ // Bounds to what the basket is worth, never to `gift.amount` — that can include a message
94
+ // surcharge that bought no redeemable service and must not inflate the discount.
128
95
  maxAmount: giftTotal(gift.items),
129
96
  clientIds: gift.recipientUid ? [gift.recipientUid] : [],
130
97
  providerUids: gift.providerUid ? [gift.providerUid] : [],
@@ -153,16 +120,8 @@ function allLines(order) {
153
120
  });
154
121
  return lines;
155
122
  }
156
- /**
157
- * Whether an order contains **at least one** of the gifted items.
158
- *
159
- * One, not all. The value is bounded by `maxAmount`, so a recipient who takes only part of the
160
- * basket simply realises less, and anything they add prices normally — failing the order instead
161
- * would be the worse outcome.
162
- *
163
- * The check still has to exist: an order with *none* of the gifted items computes a zero discount
164
- * yet would otherwise still mark the gift redeemed, destroying it for nothing.
165
- */
123
+ /** Whether an order contains at least one gifted item (not all — `maxAmount` bounds the value, so
124
+ * taking part of the basket just realises less). Needed so a matchless order doesn't wrongly mark the gift redeemed. */
166
125
  function orderContainsGiftedItem(order, voucher) {
167
126
  var _a, _b;
168
127
  const serviceIds = (_a = voucher === null || voucher === void 0 ? void 0 : voucher.servicesIds) !== null && _a !== void 0 ? _a : [];
@@ -188,13 +147,8 @@ function orderGiftId(order) {
188
147
  function isGiftRedemption(order) {
189
148
  return !!orderGiftId(order);
190
149
  }
191
- /**
192
- * Whether a gift may be spent on a given order.
193
- *
194
- * Every condition the server must hold at redemption, in one place, so the transactional path and
195
- * anything that previews it cannot drift apart. Deliberately does **not** check `usedBy`: that is
196
- * read-then-write and races. Single use is enforced by the gift's own status inside a transaction.
197
- */
150
+ /** Whether a gift may be spent on this order — every redemption condition in one place, so the
151
+ * transactional path can't drift from a preview. Deliberately skips `usedBy` (races); status inside the transaction enforces single use. */
198
152
  function giftRedeemableOnOrder(args) {
199
153
  var _a, _b, _c;
200
154
  const { gift, voucher, order, at, expiresAt } = args;
@@ -210,12 +164,8 @@ function giftRedeemableOnOrder(args) {
210
164
  return false;
211
165
  return orderContainsGiftedItem(order, voucher);
212
166
  }
213
- /**
214
- * Car sizes a gift can be priced for.
215
- *
216
- * The giver picks one; the recipient's car may be a different size. Prices differ by size but the
217
- * cap is a fixed amount, so the value transfers intact and they top up or under-spend.
218
- */
167
+ /** Car sizes a gift can be priced for. The giver picks one; the recipient's car may differ — prices
168
+ * vary by size but the cap is fixed, so value transfers intact and they top up or under-spend. */
219
169
  function giftCarSizes() {
220
170
  return [types_1.CarSize.X_SMALL, types_1.CarSize.SMALL, types_1.CarSize.MEDIUM, types_1.CarSize.LARGE, types_1.CarSize.X_LARGE];
221
171
  }
@@ -1,92 +1,38 @@
1
1
  import { Discount, Gift, LoyaltyProgress, Order } from '../constants/types';
2
- /**
3
- * Which providers' orders count toward an offer.
4
- *
5
- * `null` means every provider. A provider-created offer is always pinned to its own
6
- * `providerUid` regardless of what `loyalty.providerUids` says, so a provider cannot write an
7
- * offer that spends other providers' money.
8
- */
2
+ /** Which providers' orders count (`null` = every provider). A provider-created offer is always
3
+ * pinned to its own `providerUid` regardless of `loyalty.providerUids`, so it can't spend other providers' money. */
9
4
  export declare function loyaltyProviderScope(offer?: Discount): string[] | null;
10
- /**
11
- * Whether an offer is a provider's own programme.
12
- *
13
- * `createdByProvider` is the discriminator, not the shape of the scope: a Sahab offer restricted
14
- * to a handful of carwashes still belongs to Sahab, and a provider's offer is always pinned to
15
- * itself. Reading `loyalty.providerUids` to tell them apart is what made every provider
16
- * programme label itself "at any carwash".
17
- */
5
+ /** Whether an offer is a provider's own programme. `createdByProvider` is the discriminator, not
6
+ * the scope's shape — reading `providerUids` to tell them apart mislabeled every provider programme as "at any carwash". */
18
7
  export declare function isProviderLoyaltyOffer(offer?: Discount): boolean;
19
8
  /** Whether an offer is one Sahab runs and funds. */
20
9
  export declare function isSahabLoyaltyOffer(offer?: Discount): boolean;
21
10
  /** Whether an offer's provider scope admits a given provider. */
22
11
  export declare function loyaltyCoversProvider(offer: Discount | undefined, providerUid?: string): boolean;
23
- /**
24
- * Whether a loyalty offer belongs to the given test/prod environment.
25
- *
26
- * An offer's environment is decided in priority order:
27
- * 1. `offer.isTest`, when the offer declares one explicitly.
28
- * 2. `providerIsTest`, for an offer scoped to exactly one provider — resolving that (a
29
- * Firestore read on the provider) is the caller's job, since this stays framework-agnostic.
30
- * Pass it only when `loyaltyProviderScope(offer)` resolves to a single provider.
31
- * 3. Prod, otherwise — a genuinely network-wide offer with no explicit flag is exactly the
32
- * shape every offer had before this field existed, so it stays visible to prod and hidden
33
- * from test rather than leaking into both.
34
- */
12
+ /** Environment priority: explicit `offer.isTest`, else `providerIsTest` (only valid when the offer
13
+ * scopes to one provider), else prod — matching every pre-existing offer's implicit shape. */
35
14
  export declare function loyaltyOfferMatchesTestMode(offer: Discount | undefined, isTest: boolean, providerIsTest?: boolean): boolean;
36
- /**
37
- * Whether a finished order earns a stamp.
38
- *
39
- * The minimum is compared against what the customer actually paid, not the list price, so an
40
- * order discounted below the threshold does not qualify.
41
- */
15
+ /** Whether a finished order earns a stamp. The minimum is compared against what the customer
16
+ * actually paid, not the list price, so an order discounted below it doesn't qualify. */
42
17
  export declare function orderEarnsLoyaltyStamp(order: Order | undefined, offer: Discount | undefined): boolean;
43
- /**
44
- * Whether *buying* a gift earns the giver a stamp.
45
- *
46
- * Awarded at purchase rather than at redemption: the giver paid then, and the reward should not
47
- * hang on whether the recipient ever gets round to spending it — or on a provider the giver did
48
- * not choose.
49
- *
50
- * The minimum is a fair test here in a way it is not for the recipient, because the giver really
51
- * did pay this much.
52
- */
18
+ /** Whether buying a gift earns the giver a stamp. Awarded at purchase, not redemption — the reward
19
+ * shouldn't hinge on whether the recipient ever spends it. The minimum is fair here since the giver really paid it. */
53
20
  export declare function giftEarnsLoyaltyStamp(gift: Gift | undefined, offer: Discount | undefined): boolean;
54
21
  /** Empty progress for a client against an offer. */
55
22
  export declare function emptyLoyaltyProgress(clientId: string, offer: Discount): LoyaltyProgress;
56
- /**
57
- * Progress after a settled order, as a new object.
58
- *
59
- * Returns the input unchanged when the order does not qualify or has already been counted —
60
- * COMPLETED and RATED are both settled states, and an order passes through both, so without the
61
- * `countedOrderIds` guard a single wash would stamp the card twice.
62
- */
23
+ /** Progress after a settled order, as a new object. Returns input unchanged if unqualified or
24
+ * already counted — COMPLETED and RATED are both settled and an order passes through both, so the guard stops a double stamp. */
63
25
  export declare function applyLoyaltyStamp(progress: LoyaltyProgress, order: Order, offer: Discount): LoyaltyProgress;
64
- /**
65
- * Progress after a gift purchase, as a new object.
66
- *
67
- * Same shape as `applyLoyaltyStamp`, keyed on `giftId` because a purchase has no order id at the
68
- * moment it is paid for. Returns the input unchanged when the gift does not qualify or has
69
- * already been counted, so a webhook redelivery cannot double-stamp the giver.
70
- */
26
+ /** Progress after a gift purchase, as a new object. Same shape as `applyLoyaltyStamp`, keyed on
27
+ * `giftId` (no order id exists yet) — unchanged if unqualified or already counted, so a webhook redelivery can't double-stamp. */
71
28
  export declare function applyLoyaltyStampForGift(progress: LoyaltyProgress, gift: Gift, offer: Discount): LoyaltyProgress;
72
29
  /** How many more qualifying orders until the next free wash. */
73
30
  export declare function stampsRemaining(progress: LoyaltyProgress | undefined, offer: Discount | undefined): number;
74
31
  /** Whether the customer has a free wash waiting. */
75
32
  export declare function hasLoyaltyReward(progress?: LoyaltyProgress): boolean;
76
- /**
77
- * What the free wash is worth on this order.
78
- *
79
- * The **cheapest** line is discounted, which is the usual buy-X-get-Y convention and the
80
- * conservative reading for whoever funds it. `maxAmount` caps it — that is the "max 50 SR" in
81
- * "buy 4 washes, get one free (max 50 SR)".
82
- *
83
- * Returns 0 when there is nothing to discount, so a caller can treat 0 as "not redeemable here".
84
- */
33
+ /** What the free wash is worth. The cheapest line is discounted (the usual buy-X-get-Y convention,
34
+ * conservative for whoever funds it); `maxAmount` caps it. Returns 0 when nothing is discountable. */
85
35
  export declare function loyaltyRewardValue(order: Order | undefined, offer: Discount | undefined): number;
86
- /**
87
- * The offer, ready to attach to `order.promoCode` when a reward is redeemed.
88
- *
89
- * `amount` is resolved here and `isPercentage` forced off, so from this point the reward is an
90
- * ordinary fixed discount and every existing calc treats it as one.
91
- */
36
+ /** The offer, ready to attach to `order.promoCode` on redemption. `amount` is resolved and
37
+ * `isPercentage` forced off, so from here it's an ordinary fixed discount every calc treats the same. */
92
38
  export declare function redeemLoyaltyReward(order: Order, offer: Discount): Discount | undefined;
@@ -18,25 +18,12 @@ const types_1 = require("../constants/types");
18
18
  const calcs_1 = require("./calcs");
19
19
  const gifts_1 = require("./gifts");
20
20
  const utils_1 = require("./utils");
21
- /**
22
- * "Buy N washes, get one free" — the shared rules.
23
- *
24
- * Deliberately pure: no Firestore, no clock. The cloud function owns reading and writing
25
- * progress, the apps own showing it, and all three agree on the rules because they call these.
26
- *
27
- * The reward is not a new kind of discount. When it is redeemed the earned value is written onto
28
- * `order.promoCode.amount` as an ordinary fixed amount, so the existing pricing pipeline —
29
- * including the `sahabPercentage` / `providerPercentage` split — handles it unchanged.
30
- */
21
+ /** "Buy N washes, get one free" — the shared rules. Pure (no Firestore/clock); the cloud function
22
+ * owns state. Redeemed as an ordinary fixed `order.promoCode.amount`, so the pricing pipeline handles it unchanged. */
31
23
  /** An order counts once it is finished and actually paid for. */
32
24
  const SETTLED_STATUSES = [types_1.OrderStatus.COMPLETED, types_1.OrderStatus.RATED];
33
- /**
34
- * Which providers' orders count toward an offer.
35
- *
36
- * `null` means every provider. A provider-created offer is always pinned to its own
37
- * `providerUid` regardless of what `loyalty.providerUids` says, so a provider cannot write an
38
- * offer that spends other providers' money.
39
- */
25
+ /** Which providers' orders count (`null` = every provider). A provider-created offer is always
26
+ * pinned to its own `providerUid` regardless of `loyalty.providerUids`, so it can't spend other providers' money. */
40
27
  function loyaltyProviderScope(offer) {
41
28
  if (!(offer === null || offer === void 0 ? void 0 : offer.loyalty))
42
29
  return null;
@@ -46,14 +33,8 @@ function loyaltyProviderScope(offer) {
46
33
  const scope = offer.loyalty.providerUids;
47
34
  return scope && scope.length ? scope : null;
48
35
  }
49
- /**
50
- * Whether an offer is a provider's own programme.
51
- *
52
- * `createdByProvider` is the discriminator, not the shape of the scope: a Sahab offer restricted
53
- * to a handful of carwashes still belongs to Sahab, and a provider's offer is always pinned to
54
- * itself. Reading `loyalty.providerUids` to tell them apart is what made every provider
55
- * programme label itself "at any carwash".
56
- */
36
+ /** Whether an offer is a provider's own programme. `createdByProvider` is the discriminator, not
37
+ * the scope's shape — reading `providerUids` to tell them apart mislabeled every provider programme as "at any carwash". */
57
38
  function isProviderLoyaltyOffer(offer) {
58
39
  return !!(offer === null || offer === void 0 ? void 0 : offer.loyalty) && offer.createdByProvider === true;
59
40
  }
@@ -68,18 +49,8 @@ function loyaltyCoversProvider(offer, providerUid) {
68
49
  return true;
69
50
  return !!providerUid && scope.includes(providerUid);
70
51
  }
71
- /**
72
- * Whether a loyalty offer belongs to the given test/prod environment.
73
- *
74
- * An offer's environment is decided in priority order:
75
- * 1. `offer.isTest`, when the offer declares one explicitly.
76
- * 2. `providerIsTest`, for an offer scoped to exactly one provider — resolving that (a
77
- * Firestore read on the provider) is the caller's job, since this stays framework-agnostic.
78
- * Pass it only when `loyaltyProviderScope(offer)` resolves to a single provider.
79
- * 3. Prod, otherwise — a genuinely network-wide offer with no explicit flag is exactly the
80
- * shape every offer had before this field existed, so it stays visible to prod and hidden
81
- * from test rather than leaking into both.
82
- */
52
+ /** Environment priority: explicit `offer.isTest`, else `providerIsTest` (only valid when the offer
53
+ * scopes to one provider), else prod — matching every pre-existing offer's implicit shape. */
83
54
  function loyaltyOfferMatchesTestMode(offer, isTest, providerIsTest) {
84
55
  if (typeof (offer === null || offer === void 0 ? void 0 : offer.isTest) === 'boolean')
85
56
  return offer.isTest === isTest;
@@ -87,12 +58,8 @@ function loyaltyOfferMatchesTestMode(offer, isTest, providerIsTest) {
87
58
  return providerIsTest === isTest;
88
59
  return !isTest;
89
60
  }
90
- /**
91
- * Whether a finished order earns a stamp.
92
- *
93
- * The minimum is compared against what the customer actually paid, not the list price, so an
94
- * order discounted below the threshold does not qualify.
95
- */
61
+ /** Whether a finished order earns a stamp. The minimum is compared against what the customer
62
+ * actually paid, not the list price, so an order discounted below it doesn't qualify. */
96
63
  function orderEarnsLoyaltyStamp(order, offer) {
97
64
  var _a, _b;
98
65
  if (!order || !(offer === null || offer === void 0 ? void 0 : offer.loyalty))
@@ -103,10 +70,8 @@ function orderEarnsLoyaltyStamp(order, offer) {
103
70
  return false;
104
71
  if (!loyaltyCoversProvider(offer, order.providerId))
105
72
  return false;
106
- // A gift redemption stamps the person who *paid* for it, which is the giver, at purchase —
107
- // see `giftEarnsLoyaltyStamp`. Without this the recipient collects a stamp for a wash they
108
- // did not pay for, and the `minOrderAmount` gate below does not catch it: their total is 0,
109
- // so any programme with no minimum would have stamped them.
73
+ // A gift redemption stamps the giver (who paid), not the recipient — see `giftEarnsLoyaltyStamp`.
74
+ // Without this, the recipient's total is 0, so any programme with no minimum would wrongly stamp them.
110
75
  if ((0, gifts_1.isGiftRedemption)(order))
111
76
  return false;
112
77
  const min = (0, utils_1.preventNaN)((_b = offer.loyalty.minOrderAmount) !== null && _b !== void 0 ? _b : 0);
@@ -114,16 +79,8 @@ function orderEarnsLoyaltyStamp(order, offer) {
114
79
  return false;
115
80
  return true;
116
81
  }
117
- /**
118
- * Whether *buying* a gift earns the giver a stamp.
119
- *
120
- * Awarded at purchase rather than at redemption: the giver paid then, and the reward should not
121
- * hang on whether the recipient ever gets round to spending it — or on a provider the giver did
122
- * not choose.
123
- *
124
- * The minimum is a fair test here in a way it is not for the recipient, because the giver really
125
- * did pay this much.
126
- */
82
+ /** Whether buying a gift earns the giver a stamp. Awarded at purchase, not redemption — the reward
83
+ * shouldn't hinge on whether the recipient ever spends it. The minimum is fair here since the giver really paid it. */
127
84
  function giftEarnsLoyaltyStamp(gift, offer) {
128
85
  var _a;
129
86
  if (!gift || !(offer === null || offer === void 0 ? void 0 : offer.loyalty))
@@ -145,13 +102,8 @@ function emptyLoyaltyProgress(clientId, offer) {
145
102
  const scope = loyaltyProviderScope(offer);
146
103
  return Object.assign(Object.assign({ clientId, promoCodeId: (_a = offer.promoCodeId) !== null && _a !== void 0 ? _a : '' }, (scope && scope.length === 1 ? { providerUid: scope[0] } : {})), { qualifyingCount: 0, earnedRewards: 0, countedOrderIds: [] });
147
104
  }
148
- /**
149
- * Progress after a settled order, as a new object.
150
- *
151
- * Returns the input unchanged when the order does not qualify or has already been counted —
152
- * COMPLETED and RATED are both settled states, and an order passes through both, so without the
153
- * `countedOrderIds` guard a single wash would stamp the card twice.
154
- */
105
+ /** Progress after a settled order, as a new object. Returns input unchanged if unqualified or
106
+ * already counted — COMPLETED and RATED are both settled and an order passes through both, so the guard stops a double stamp. */
155
107
  function applyLoyaltyStamp(progress, order, offer) {
156
108
  var _a, _b, _c, _d, _e;
157
109
  if (!orderEarnsLoyaltyStamp(order, offer))
@@ -164,13 +116,8 @@ function applyLoyaltyStamp(progress, order, offer) {
164
116
  const earned = nextCount >= target;
165
117
  return Object.assign(Object.assign({}, progress), { qualifyingCount: earned ? 0 : nextCount, earnedRewards: (0, utils_1.preventNaN)(progress.earnedRewards) + (earned ? 1 : 0), countedOrderIds: [...((_e = progress.countedOrderIds) !== null && _e !== void 0 ? _e : []), orderId] });
166
118
  }
167
- /**
168
- * Progress after a gift purchase, as a new object.
169
- *
170
- * Same shape as `applyLoyaltyStamp`, keyed on `giftId` because a purchase has no order id at the
171
- * moment it is paid for. Returns the input unchanged when the gift does not qualify or has
172
- * already been counted, so a webhook redelivery cannot double-stamp the giver.
173
- */
119
+ /** Progress after a gift purchase, as a new object. Same shape as `applyLoyaltyStamp`, keyed on
120
+ * `giftId` (no order id exists yet) — unchanged if unqualified or already counted, so a webhook redelivery can't double-stamp. */
174
121
  function applyLoyaltyStampForGift(progress, gift, offer) {
175
122
  var _a, _b, _c, _d, _e;
176
123
  if (!giftEarnsLoyaltyStamp(gift, offer))
@@ -203,15 +150,8 @@ function allLines(order) {
203
150
  });
204
151
  return lines;
205
152
  }
206
- /**
207
- * What the free wash is worth on this order.
208
- *
209
- * The **cheapest** line is discounted, which is the usual buy-X-get-Y convention and the
210
- * conservative reading for whoever funds it. `maxAmount` caps it — that is the "max 50 SR" in
211
- * "buy 4 washes, get one free (max 50 SR)".
212
- *
213
- * Returns 0 when there is nothing to discount, so a caller can treat 0 as "not redeemable here".
214
- */
153
+ /** What the free wash is worth. The cheapest line is discounted (the usual buy-X-get-Y convention,
154
+ * conservative for whoever funds it); `maxAmount` caps it. Returns 0 when nothing is discountable. */
215
155
  function loyaltyRewardValue(order, offer) {
216
156
  if (!order || !(offer === null || offer === void 0 ? void 0 : offer.loyalty))
217
157
  return 0;
@@ -228,12 +168,8 @@ function loyaltyRewardValue(order, offer) {
228
168
  value = Math.min(value, cap);
229
169
  return value;
230
170
  }
231
- /**
232
- * The offer, ready to attach to `order.promoCode` when a reward is redeemed.
233
- *
234
- * `amount` is resolved here and `isPercentage` forced off, so from this point the reward is an
235
- * ordinary fixed discount and every existing calc treats it as one.
236
- */
171
+ /** The offer, ready to attach to `order.promoCode` on redemption. `amount` is resolved and
172
+ * `isPercentage` forced off, so from here it's an ordinary fixed discount every calc treats the same. */
237
173
  function redeemLoyaltyReward(order, offer) {
238
174
  const amount = loyaltyRewardValue(order, offer);
239
175
  if (amount <= 0)
@@ -2,9 +2,6 @@ export declare function preventNaN(x?: number | string): number;
2
2
  export declare const toFixed: (num: any) => string;
3
3
  export declare const isEmpty: (text: any) => boolean;
4
4
  export declare const compareVersions: (version1: string, version2: string) => number;
5
- /**
6
- * Fails open (no block) on a missing or malformed version — the admin UI that writes `minVer`
7
- * validates the same shape before saving, so this is a second, independent guard against a hand
8
- * edit (or a future write path) briefly bricking every install with a typo'd value.
9
- */
5
+ /** Fails open on a missing/malformed version — a second, independent guard (the admin UI already
6
+ * validates before saving) against a hand-edit or future write path bricking every install with a typo. */
10
7
  export declare function shouldForceUpdate(minVer?: string, currentVersion?: string): boolean;
package/dist/ts/utils.js CHANGED
@@ -39,11 +39,8 @@ const compareVersions = (version1, version2) => {
39
39
  };
40
40
  exports.compareVersions = compareVersions;
41
41
  const VERSION_RE = /^\d+(\.\d+)*$/;
42
- /**
43
- * Fails open (no block) on a missing or malformed version — the admin UI that writes `minVer`
44
- * validates the same shape before saving, so this is a second, independent guard against a hand
45
- * edit (or a future write path) briefly bricking every install with a typo'd value.
46
- */
42
+ /** Fails open on a missing/malformed version — a second, independent guard (the admin UI already
43
+ * validates before saving) against a hand-edit or future write path bricking every install with a typo. */
47
44
  function shouldForceUpdate(minVer, currentVersion) {
48
45
  if (!minVer || !currentVersion)
49
46
  return false;
@@ -0,0 +1,27 @@
1
+ import { Discount, WheelCampaign, WheelSegment } from '../constants/types';
2
+ /** Wheel-of-fortune — the shared rules. Pure (no Firestore/clock/ids); the cloud function owns
3
+ * reading the campaign, generating the random draw, and writing the spin + prize atomically. */
4
+ /** A prize is redeemable for this long from the moment it's won, independent of the campaign's own
5
+ * window — a customer who spins on the campaign's last day still gets a fair chance to use it. */
6
+ export declare const WHEEL_PRIZE_EXPIRY_DAYS = 14;
7
+ export declare function wheelPrizeExpiryDate(issuedAt: Date): Date;
8
+ /** Which cycle `at` falls into for a campaign's cadence — the same key twice within one grant
9
+ * period, a different key across it. `'once'` has a single, constant cycle for the campaign's
10
+ * whole life; the doc-id built from this is what makes "already spun this cycle" a direct read. */
11
+ export declare function cycleKeyFor(campaign: Pick<WheelCampaign, 'cadence'>, at: Date): string;
12
+ /** Whether a campaign is currently offering spins. `validFrom`/`validTo` are passed pre-converted
13
+ * (see this package's `TimestampType` gotcha — converting Firestore timestamps is the caller's job). */
14
+ export declare function isCampaignLive(campaign: Pick<WheelCampaign, 'active'>, at: Date, window: {
15
+ validFrom?: Date;
16
+ validTo?: Date;
17
+ }): boolean;
18
+ /** Weighted random pick, given the caller's own `[0,1)` draw so the choice itself stays pure and
19
+ * testable — `Math.random()` is the cloud function's job, never this module's. A segment with
20
+ * `weight` ≤ 0 can never be picked. */
21
+ export declare function pickWeightedSegment(segments: WheelSegment[], randomUnit: number): WheelSegment;
22
+ /** The prize for a winning spin — an ordinary auto-applied `Discount`, mirroring `giftDiscount`.
23
+ * Hardcoded shape (not passed in) so no call site can misfund it or leave it typeable-in. */
24
+ export declare function wheelPrizeDiscount(campaign: Pick<WheelCampaign, 'campaignId' | 'providerUids' | 'sahabPercentage' | 'providerPercentage' | 'isTest'>, segment: WheelSegment, winnerUid: string, promoCodeId: string, dates: {
25
+ validFrom: unknown;
26
+ validTo: unknown;
27
+ }): Discount;