sahab-utils 1.4.0 → 1.6.0

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.
@@ -0,0 +1,97 @@
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
+ */
13
+ /** Default when the back office has not set one. */
14
+ export declare const DEFAULT_GIFT_EXPIRY_MONTHS = 12;
15
+ /** A gift shorter than this is barely usable; longer is an open-ended liability. */
16
+ export declare const MIN_GIFT_EXPIRY_MONTHS = 1;
17
+ export declare const MAX_GIFT_EXPIRY_MONTHS = 36;
18
+ /** What the giver pays: every line in the basket. */
19
+ export declare function giftTotal(items?: GiftItem[]): number;
20
+ /** The gifted service ids. */
21
+ export declare function giftServiceIds(gift?: Pick<Gift, 'items'>): string[];
22
+ /** The gifted addon ids. */
23
+ export declare function giftAddonIds(gift?: Pick<Gift, 'items'>): string[];
24
+ /**
25
+ * Expiry months, clamped.
26
+ *
27
+ * A misconfigured `0` would expire every gift at the instant it was sold, so the bound is
28
+ * enforced here rather than trusted from the config document.
29
+ */
30
+ export declare function giftExpiryMonths(config?: GiftConfig): number;
31
+ /**
32
+ * When a gift issued now should expire.
33
+ *
34
+ * Month arithmetic rather than a fixed day count, so "12 months" lands on the same date. Clamped
35
+ * to the last day of the target month, which is what stops 31 January + 1 month landing in March.
36
+ */
37
+ export declare function giftExpiryDate(issuedAt: Date, config?: GiftConfig): Date;
38
+ /**
39
+ * Whether a service can be gifted at all.
40
+ *
41
+ * Multi-visit packages are excluded: the promo binds to the first visit only and the remaining
42
+ * visits are created as separate orders, an interaction that has not been verified end to end.
43
+ * A gift carries money already collected, so it is not the place to find out.
44
+ */
45
+ export declare function isGiftableService(service?: {
46
+ numberOfVisits?: number;
47
+ }): boolean;
48
+ /** Whether a gift is in a state where it can still be spent. */
49
+ export declare function isGiftSpendable(gift: Gift | undefined, at: Date, expiresAt?: Date): boolean;
50
+ /**
51
+ * The voucher for a paid-for gift, ready to write to `promoCodes`.
52
+ *
53
+ * 100% off, capped at what the giver actually paid, and **Sahab-funded without exception**.
54
+ * Funding is the one field that must never be wrong: a provider-funded gift voucher makes the
55
+ * carwash perform the wash for nothing while Sahab keeps the giver's money. It is hardcoded here
56
+ * rather than passed in so there is no call site that can get it wrong.
57
+ */
58
+ export declare function giftDiscount(gift: Gift, dates: {
59
+ validFrom: unknown;
60
+ validTo: unknown;
61
+ }): Discount;
62
+ /**
63
+ * Whether an order contains **at least one** of the gifted items.
64
+ *
65
+ * One, not all. The value is bounded by `maxAmount`, so a recipient who takes only part of the
66
+ * basket simply realises less, and anything they add prices normally — failing the order instead
67
+ * would be the worse outcome.
68
+ *
69
+ * The check still has to exist: an order with *none* of the gifted items computes a zero discount
70
+ * yet would otherwise still mark the gift redeemed, destroying it for nothing.
71
+ */
72
+ export declare function orderContainsGiftedItem(order: Order | undefined, voucher: Discount | undefined): boolean;
73
+ /** The gift id an order is spending, if it is spending one. */
74
+ export declare function orderGiftId(order?: Order): string | undefined;
75
+ /** Whether this order is redeeming a gift. */
76
+ export declare function isGiftRedemption(order?: Order): boolean;
77
+ /**
78
+ * Whether a gift may be spent on a given order.
79
+ *
80
+ * Every condition the server must hold at redemption, in one place, so the transactional path and
81
+ * anything that previews it cannot drift apart. Deliberately does **not** check `usedBy`: that is
82
+ * read-then-write and races. Single use is enforced by the gift's own status inside a transaction.
83
+ */
84
+ export declare function giftRedeemableOnOrder(args: {
85
+ gift?: Gift;
86
+ voucher?: Discount;
87
+ order?: Order;
88
+ at: Date;
89
+ expiresAt?: Date;
90
+ }): boolean;
91
+ /**
92
+ * Car sizes a gift can be priced for.
93
+ *
94
+ * The giver picks one; the recipient's car may be a different size. Prices differ by size but the
95
+ * cap is a fixed amount, so the value transfers intact and they top up or under-spend.
96
+ */
97
+ export declare function giftCarSizes(): CarSize[];
@@ -0,0 +1,201 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MAX_GIFT_EXPIRY_MONTHS = exports.MIN_GIFT_EXPIRY_MONTHS = exports.DEFAULT_GIFT_EXPIRY_MONTHS = void 0;
4
+ exports.giftTotal = giftTotal;
5
+ exports.giftServiceIds = giftServiceIds;
6
+ exports.giftAddonIds = giftAddonIds;
7
+ exports.giftExpiryMonths = giftExpiryMonths;
8
+ exports.giftExpiryDate = giftExpiryDate;
9
+ exports.isGiftableService = isGiftableService;
10
+ exports.isGiftSpendable = isGiftSpendable;
11
+ exports.giftDiscount = giftDiscount;
12
+ exports.orderContainsGiftedItem = orderContainsGiftedItem;
13
+ exports.orderGiftId = orderGiftId;
14
+ exports.isGiftRedemption = isGiftRedemption;
15
+ exports.giftRedeemableOnOrder = giftRedeemableOnOrder;
16
+ exports.giftCarSizes = giftCarSizes;
17
+ const types_1 = require("../constants/types");
18
+ const utils_1 = require("./utils");
19
+ /**
20
+ * Gifting — the shared rules.
21
+ *
22
+ * Pure by design: no Firestore, no clock, no id generation. The cloud function owns charging,
23
+ * issuing and redeeming; the apps own showing it; all three agree because they call these.
24
+ *
25
+ * A gift is not a new kind of discount. Once paid for it becomes an ordinary `Discount` — 100%
26
+ * off, capped at what the giver paid, scoped by `servicesIds` / `addonsIds` — so the whole
27
+ * pricing pipeline handles it unchanged. `grossPromoDiscount` already sums only the scoped lines
28
+ * and clamps to `maxAmount`, which is what makes a multi-item gift work with no changes at all.
29
+ */
30
+ /** Default when the back office has not set one. */
31
+ exports.DEFAULT_GIFT_EXPIRY_MONTHS = 12;
32
+ /** A gift shorter than this is barely usable; longer is an open-ended liability. */
33
+ exports.MIN_GIFT_EXPIRY_MONTHS = 1;
34
+ exports.MAX_GIFT_EXPIRY_MONTHS = 36;
35
+ /** What the giver pays: every line in the basket. */
36
+ function giftTotal(items) {
37
+ return (items !== null && items !== void 0 ? items : []).reduce((sum, item) => sum + (0, utils_1.preventNaN)(item === null || item === void 0 ? void 0 : item.price), 0);
38
+ }
39
+ /** The gifted service ids. */
40
+ function giftServiceIds(gift) {
41
+ var _a;
42
+ 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) === 'service' && !!i.id).map((i) => i.id);
43
+ }
44
+ /** The gifted addon ids. */
45
+ function giftAddonIds(gift) {
46
+ var _a;
47
+ 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);
48
+ }
49
+ /**
50
+ * Expiry months, clamped.
51
+ *
52
+ * A misconfigured `0` would expire every gift at the instant it was sold, so the bound is
53
+ * enforced here rather than trusted from the config document.
54
+ */
55
+ function giftExpiryMonths(config) {
56
+ const raw = config === null || config === void 0 ? void 0 : config.giftExpiryMonths;
57
+ if (raw === undefined || raw === null || !isFinite(raw))
58
+ return exports.DEFAULT_GIFT_EXPIRY_MONTHS;
59
+ return Math.min(exports.MAX_GIFT_EXPIRY_MONTHS, Math.max(exports.MIN_GIFT_EXPIRY_MONTHS, Math.floor(raw)));
60
+ }
61
+ /**
62
+ * When a gift issued now should expire.
63
+ *
64
+ * Month arithmetic rather than a fixed day count, so "12 months" lands on the same date. Clamped
65
+ * to the last day of the target month, which is what stops 31 January + 1 month landing in March.
66
+ */
67
+ function giftExpiryDate(issuedAt, config) {
68
+ const months = giftExpiryMonths(config);
69
+ const out = new Date(issuedAt.getTime());
70
+ const day = out.getDate();
71
+ out.setDate(1);
72
+ out.setMonth(out.getMonth() + months);
73
+ const lastDayOfTargetMonth = new Date(out.getFullYear(), out.getMonth() + 1, 0).getDate();
74
+ out.setDate(Math.min(day, lastDayOfTargetMonth));
75
+ return out;
76
+ }
77
+ /**
78
+ * Whether a service can be gifted at all.
79
+ *
80
+ * Multi-visit packages are excluded: the promo binds to the first visit only and the remaining
81
+ * visits are created as separate orders, an interaction that has not been verified end to end.
82
+ * A gift carries money already collected, so it is not the place to find out.
83
+ */
84
+ function isGiftableService(service) {
85
+ var _a;
86
+ return (0, utils_1.preventNaN)((_a = service === null || service === void 0 ? void 0 : service.numberOfVisits) !== null && _a !== void 0 ? _a : 1) <= 1;
87
+ }
88
+ /** Whether a gift is in a state where it can still be spent. */
89
+ function isGiftSpendable(gift, at, expiresAt) {
90
+ if (!gift || gift.status !== types_1.GiftStatus.ISSUED)
91
+ return false;
92
+ if (expiresAt && at.getTime() > expiresAt.getTime())
93
+ return false;
94
+ return true;
95
+ }
96
+ /**
97
+ * The voucher for a paid-for gift, ready to write to `promoCodes`.
98
+ *
99
+ * 100% off, capped at what the giver actually paid, and **Sahab-funded without exception**.
100
+ * Funding is the one field that must never be wrong: a provider-funded gift voucher makes the
101
+ * carwash perform the wash for nothing while Sahab keeps the giver's money. It is hardcoded here
102
+ * rather than passed in so there is no call site that can get it wrong.
103
+ */
104
+ function giftDiscount(gift, dates) {
105
+ return {
106
+ isPercentage: true,
107
+ amount: 100,
108
+ // Bounds the discount to the money actually collected, whatever the recipient orders.
109
+ maxAmount: (0, utils_1.preventNaN)(gift.amount) || giftTotal(gift.items),
110
+ clientIds: gift.recipientUid ? [gift.recipientUid] : [],
111
+ providerUids: gift.providerUid ? [gift.providerUid] : [],
112
+ servicesIds: giftServiceIds(gift),
113
+ addonsIds: giftAddonIds(gift),
114
+ allowedOnce: true,
115
+ usedBy: [],
116
+ // Never typed in as a code — the app attaches it once the server says it is held.
117
+ isAutoApplied: true,
118
+ createdByProvider: false,
119
+ sahabPercentage: 100,
120
+ providerPercentage: 0,
121
+ validFrom: dates.validFrom,
122
+ validTo: dates.validTo,
123
+ gift: { giftId: gift.giftId },
124
+ };
125
+ }
126
+ /** Every service and addon line on an order, flattened. */
127
+ function allLines(order) {
128
+ var _a;
129
+ const lines = [];
130
+ ((_a = order === null || order === void 0 ? void 0 : order.services) !== null && _a !== void 0 ? _a : []).forEach((carService) => {
131
+ var _a;
132
+ ((_a = carService === null || carService === void 0 ? void 0 : carService.services) !== null && _a !== void 0 ? _a : []).forEach((line) => lines.push(line));
133
+ });
134
+ return lines;
135
+ }
136
+ /**
137
+ * Whether an order contains **at least one** of the gifted items.
138
+ *
139
+ * One, not all. The value is bounded by `maxAmount`, so a recipient who takes only part of the
140
+ * basket simply realises less, and anything they add prices normally — failing the order instead
141
+ * would be the worse outcome.
142
+ *
143
+ * The check still has to exist: an order with *none* of the gifted items computes a zero discount
144
+ * yet would otherwise still mark the gift redeemed, destroying it for nothing.
145
+ */
146
+ function orderContainsGiftedItem(order, voucher) {
147
+ var _a, _b;
148
+ const serviceIds = (_a = voucher === null || voucher === void 0 ? void 0 : voucher.servicesIds) !== null && _a !== void 0 ? _a : [];
149
+ const addonIds = (_b = voucher === null || voucher === void 0 ? void 0 : voucher.addonsIds) !== null && _b !== void 0 ? _b : [];
150
+ if (!serviceIds.length && !addonIds.length)
151
+ return false;
152
+ return allLines(order).some((line) => {
153
+ var _a, _b;
154
+ if (!line)
155
+ return false;
156
+ if (line.type === 'service')
157
+ return serviceIds.includes((_a = line.serviceId) !== null && _a !== void 0 ? _a : '');
158
+ return addonIds.includes((_b = line.addonId) !== null && _b !== void 0 ? _b : '');
159
+ });
160
+ }
161
+ /** The gift id an order is spending, if it is spending one. */
162
+ function orderGiftId(order) {
163
+ var _a, _b;
164
+ const id = (_b = (_a = order === null || order === void 0 ? void 0 : order.promoCode) === null || _a === void 0 ? void 0 : _a.gift) === null || _b === void 0 ? void 0 : _b.giftId;
165
+ return id && id.length ? id : undefined;
166
+ }
167
+ /** Whether this order is redeeming a gift. */
168
+ function isGiftRedemption(order) {
169
+ return !!orderGiftId(order);
170
+ }
171
+ /**
172
+ * Whether a gift may be spent on a given order.
173
+ *
174
+ * Every condition the server must hold at redemption, in one place, so the transactional path and
175
+ * anything that previews it cannot drift apart. Deliberately does **not** check `usedBy`: that is
176
+ * read-then-write and races. Single use is enforced by the gift's own status inside a transaction.
177
+ */
178
+ function giftRedeemableOnOrder(args) {
179
+ var _a, _b, _c;
180
+ const { gift, voucher, order, at, expiresAt } = args;
181
+ if (!gift || !voucher || !order)
182
+ return false;
183
+ if (!isGiftSpendable(gift, at, expiresAt))
184
+ return false;
185
+ const clientId = ((_a = order.client) === null || _a === void 0 ? void 0 : _a.uid) || order.clientId;
186
+ if (!clientId || !((_b = voucher.clientIds) !== null && _b !== void 0 ? _b : []).includes(clientId))
187
+ return false;
188
+ const scope = (_c = voucher.providerUids) !== null && _c !== void 0 ? _c : [];
189
+ if (scope.length && !(order.providerId && scope.includes(order.providerId)))
190
+ return false;
191
+ return orderContainsGiftedItem(order, voucher);
192
+ }
193
+ /**
194
+ * Car sizes a gift can be priced for.
195
+ *
196
+ * The giver picks one; the recipient's car may be a different size. Prices differ by size but the
197
+ * cap is a fixed amount, so the value transfers intact and they top up or under-spend.
198
+ */
199
+ function giftCarSizes() {
200
+ return [types_1.CarSize.X_SMALL, types_1.CarSize.SMALL, types_1.CarSize.MEDIUM, types_1.CarSize.LARGE, types_1.CarSize.X_LARGE];
201
+ }
@@ -0,0 +1,68 @@
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
+ */
9
+ export declare function loyaltyProviderScope(offer?: Discount): string[] | null;
10
+ /** Whether an offer's provider scope admits a given provider. */
11
+ export declare function loyaltyCoversProvider(offer: Discount | undefined, providerUid?: string): boolean;
12
+ /**
13
+ * Whether a finished order earns a stamp.
14
+ *
15
+ * The minimum is compared against what the customer actually paid, not the list price, so an
16
+ * order discounted below the threshold does not qualify.
17
+ */
18
+ export declare function orderEarnsLoyaltyStamp(order: Order | undefined, offer: Discount | undefined): boolean;
19
+ /**
20
+ * Whether *buying* a gift earns the giver a stamp.
21
+ *
22
+ * Awarded at purchase rather than at redemption: the giver paid then, and the reward should not
23
+ * hang on whether the recipient ever gets round to spending it — or on a provider the giver did
24
+ * not choose.
25
+ *
26
+ * The minimum is a fair test here in a way it is not for the recipient, because the giver really
27
+ * did pay this much.
28
+ */
29
+ export declare function giftEarnsLoyaltyStamp(gift: Gift | undefined, offer: Discount | undefined): boolean;
30
+ /** Empty progress for a client against an offer. */
31
+ export declare function emptyLoyaltyProgress(clientId: string, offer: Discount): LoyaltyProgress;
32
+ /**
33
+ * Progress after a settled order, as a new object.
34
+ *
35
+ * Returns the input unchanged when the order does not qualify or has already been counted —
36
+ * COMPLETED and RATED are both settled states, and an order passes through both, so without the
37
+ * `countedOrderIds` guard a single wash would stamp the card twice.
38
+ */
39
+ export declare function applyLoyaltyStamp(progress: LoyaltyProgress, order: Order, offer: Discount): LoyaltyProgress;
40
+ /**
41
+ * Progress after a gift purchase, as a new object.
42
+ *
43
+ * Same shape as `applyLoyaltyStamp`, keyed on `giftId` because a purchase has no order id at the
44
+ * moment it is paid for. Returns the input unchanged when the gift does not qualify or has
45
+ * already been counted, so a webhook redelivery cannot double-stamp the giver.
46
+ */
47
+ export declare function applyLoyaltyStampForGift(progress: LoyaltyProgress, gift: Gift, offer: Discount): LoyaltyProgress;
48
+ /** How many more qualifying orders until the next free wash. */
49
+ export declare function stampsRemaining(progress: LoyaltyProgress | undefined, offer: Discount | undefined): number;
50
+ /** Whether the customer has a free wash waiting. */
51
+ export declare function hasLoyaltyReward(progress?: LoyaltyProgress): boolean;
52
+ /**
53
+ * What the free wash is worth on this order.
54
+ *
55
+ * The **cheapest** line is discounted, which is the usual buy-X-get-Y convention and the
56
+ * conservative reading for whoever funds it. `maxAmount` caps it — that is the "max 50 SR" in
57
+ * "buy 4 washes, get one free (max 50 SR)".
58
+ *
59
+ * Returns 0 when there is nothing to discount, so a caller can treat 0 as "not redeemable here".
60
+ */
61
+ export declare function loyaltyRewardValue(order: Order | undefined, offer: Discount | undefined): number;
62
+ /**
63
+ * The offer, ready to attach to `order.promoCode` when a reward is redeemed.
64
+ *
65
+ * `amount` is resolved here and `isPercentage` forced off, so from this point the reward is an
66
+ * ordinary fixed discount and every existing calc treats it as one.
67
+ */
68
+ export declare function redeemLoyaltyReward(order: Order, offer: Discount): Discount | undefined;
@@ -0,0 +1,205 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.loyaltyProviderScope = loyaltyProviderScope;
4
+ exports.loyaltyCoversProvider = loyaltyCoversProvider;
5
+ exports.orderEarnsLoyaltyStamp = orderEarnsLoyaltyStamp;
6
+ exports.giftEarnsLoyaltyStamp = giftEarnsLoyaltyStamp;
7
+ exports.emptyLoyaltyProgress = emptyLoyaltyProgress;
8
+ exports.applyLoyaltyStamp = applyLoyaltyStamp;
9
+ exports.applyLoyaltyStampForGift = applyLoyaltyStampForGift;
10
+ exports.stampsRemaining = stampsRemaining;
11
+ exports.hasLoyaltyReward = hasLoyaltyReward;
12
+ exports.loyaltyRewardValue = loyaltyRewardValue;
13
+ exports.redeemLoyaltyReward = redeemLoyaltyReward;
14
+ const types_1 = require("../constants/types");
15
+ const calcs_1 = require("./calcs");
16
+ const gifts_1 = require("./gifts");
17
+ const utils_1 = require("./utils");
18
+ /**
19
+ * "Buy N washes, get one free" — the shared rules.
20
+ *
21
+ * Deliberately pure: no Firestore, no clock. The cloud function owns reading and writing
22
+ * progress, the apps own showing it, and all three agree on the rules because they call these.
23
+ *
24
+ * The reward is not a new kind of discount. When it is redeemed the earned value is written onto
25
+ * `order.promoCode.amount` as an ordinary fixed amount, so the existing pricing pipeline —
26
+ * including the `sahabPercentage` / `providerPercentage` split — handles it unchanged.
27
+ */
28
+ /** An order counts once it is finished and actually paid for. */
29
+ const SETTLED_STATUSES = [types_1.OrderStatus.COMPLETED, types_1.OrderStatus.RATED];
30
+ /**
31
+ * Which providers' orders count toward an offer.
32
+ *
33
+ * `null` means every provider. A provider-created offer is always pinned to its own
34
+ * `providerUid` regardless of what `loyalty.providerUids` says, so a provider cannot write an
35
+ * offer that spends other providers' money.
36
+ */
37
+ function loyaltyProviderScope(offer) {
38
+ if (!(offer === null || offer === void 0 ? void 0 : offer.loyalty))
39
+ return null;
40
+ if (offer.createdByProvider) {
41
+ return offer.providerUid ? [offer.providerUid] : [];
42
+ }
43
+ const scope = offer.loyalty.providerUids;
44
+ return scope && scope.length ? scope : null;
45
+ }
46
+ /** Whether an offer's provider scope admits a given provider. */
47
+ function loyaltyCoversProvider(offer, providerUid) {
48
+ const scope = loyaltyProviderScope(offer);
49
+ if (scope === null)
50
+ return true;
51
+ return !!providerUid && scope.includes(providerUid);
52
+ }
53
+ /**
54
+ * Whether a finished order earns a stamp.
55
+ *
56
+ * The minimum is compared against what the customer actually paid, not the list price, so an
57
+ * order discounted below the threshold does not qualify.
58
+ */
59
+ function orderEarnsLoyaltyStamp(order, offer) {
60
+ var _a, _b;
61
+ if (!order || !(offer === null || offer === void 0 ? void 0 : offer.loyalty))
62
+ return false;
63
+ if (!SETTLED_STATUSES.includes((_a = order.orderStatus) !== null && _a !== void 0 ? _a : types_1.OrderStatus.PENDING))
64
+ return false;
65
+ if (!order.isPaid)
66
+ return false;
67
+ if (!loyaltyCoversProvider(offer, order.providerId))
68
+ return false;
69
+ // A gift redemption stamps the person who *paid* for it, which is the giver, at purchase —
70
+ // see `giftEarnsLoyaltyStamp`. Without this the recipient collects a stamp for a wash they
71
+ // did not pay for, and the `minOrderAmount` gate below does not catch it: their total is 0,
72
+ // so any programme with no minimum would have stamped them.
73
+ if ((0, gifts_1.isGiftRedemption)(order))
74
+ return false;
75
+ const min = (0, utils_1.preventNaN)((_b = offer.loyalty.minOrderAmount) !== null && _b !== void 0 ? _b : 0);
76
+ if (min > 0 && (0, calcs_1.getCustomerTotalToPay)(order) < min)
77
+ return false;
78
+ return true;
79
+ }
80
+ /**
81
+ * Whether *buying* a gift earns the giver a stamp.
82
+ *
83
+ * Awarded at purchase rather than at redemption: the giver paid then, and the reward should not
84
+ * hang on whether the recipient ever gets round to spending it — or on a provider the giver did
85
+ * not choose.
86
+ *
87
+ * The minimum is a fair test here in a way it is not for the recipient, because the giver really
88
+ * did pay this much.
89
+ */
90
+ function giftEarnsLoyaltyStamp(gift, offer) {
91
+ var _a;
92
+ if (!gift || !(offer === null || offer === void 0 ? void 0 : offer.loyalty))
93
+ return false;
94
+ if (gift.status !== types_1.GiftStatus.ISSUED)
95
+ return false;
96
+ if (!gift.giverUid)
97
+ return false;
98
+ if (!loyaltyCoversProvider(offer, gift.providerUid))
99
+ return false;
100
+ const min = (0, utils_1.preventNaN)((_a = offer.loyalty.minOrderAmount) !== null && _a !== void 0 ? _a : 0);
101
+ if (min > 0 && (0, utils_1.preventNaN)(gift.amount) < min)
102
+ return false;
103
+ return true;
104
+ }
105
+ /** Empty progress for a client against an offer. */
106
+ function emptyLoyaltyProgress(clientId, offer) {
107
+ var _a;
108
+ const scope = loyaltyProviderScope(offer);
109
+ 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: [] });
110
+ }
111
+ /**
112
+ * Progress after a settled order, as a new object.
113
+ *
114
+ * Returns the input unchanged when the order does not qualify or has already been counted —
115
+ * COMPLETED and RATED are both settled states, and an order passes through both, so without the
116
+ * `countedOrderIds` guard a single wash would stamp the card twice.
117
+ */
118
+ function applyLoyaltyStamp(progress, order, offer) {
119
+ var _a, _b, _c, _d, _e;
120
+ if (!orderEarnsLoyaltyStamp(order, offer))
121
+ return progress;
122
+ const orderId = (_a = order.orderId) !== null && _a !== void 0 ? _a : '';
123
+ if (!orderId || ((_b = progress.countedOrderIds) === null || _b === void 0 ? void 0 : _b.includes(orderId)))
124
+ return progress;
125
+ const target = Math.max(1, Math.floor((0, utils_1.preventNaN)((_d = (_c = offer.loyalty) === null || _c === void 0 ? void 0 : _c.buyQuantity) !== null && _d !== void 0 ? _d : 1)));
126
+ const nextCount = (0, utils_1.preventNaN)(progress.qualifyingCount) + 1;
127
+ const earned = nextCount >= target;
128
+ 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] });
129
+ }
130
+ /**
131
+ * Progress after a gift purchase, as a new object.
132
+ *
133
+ * Same shape as `applyLoyaltyStamp`, keyed on `giftId` because a purchase has no order id at the
134
+ * moment it is paid for. Returns the input unchanged when the gift does not qualify or has
135
+ * already been counted, so a webhook redelivery cannot double-stamp the giver.
136
+ */
137
+ function applyLoyaltyStampForGift(progress, gift, offer) {
138
+ var _a, _b, _c, _d, _e;
139
+ if (!giftEarnsLoyaltyStamp(gift, offer))
140
+ return progress;
141
+ const giftId = (_a = gift.giftId) !== null && _a !== void 0 ? _a : '';
142
+ if (!giftId || ((_b = progress.countedGiftIds) === null || _b === void 0 ? void 0 : _b.includes(giftId)))
143
+ return progress;
144
+ const target = Math.max(1, Math.floor((0, utils_1.preventNaN)((_d = (_c = offer.loyalty) === null || _c === void 0 ? void 0 : _c.buyQuantity) !== null && _d !== void 0 ? _d : 1)));
145
+ const nextCount = (0, utils_1.preventNaN)(progress.qualifyingCount) + 1;
146
+ const earned = nextCount >= target;
147
+ return Object.assign(Object.assign({}, progress), { qualifyingCount: earned ? 0 : nextCount, earnedRewards: (0, utils_1.preventNaN)(progress.earnedRewards) + (earned ? 1 : 0), countedGiftIds: [...((_e = progress.countedGiftIds) !== null && _e !== void 0 ? _e : []), giftId] });
148
+ }
149
+ /** How many more qualifying orders until the next free wash. */
150
+ function stampsRemaining(progress, offer) {
151
+ var _a, _b;
152
+ const target = Math.max(1, Math.floor((0, utils_1.preventNaN)((_b = (_a = offer === null || offer === void 0 ? void 0 : offer.loyalty) === null || _a === void 0 ? void 0 : _a.buyQuantity) !== null && _b !== void 0 ? _b : 1)));
153
+ return Math.max(0, target - (0, utils_1.preventNaN)(progress === null || progress === void 0 ? void 0 : progress.qualifyingCount));
154
+ }
155
+ /** Whether the customer has a free wash waiting. */
156
+ function hasLoyaltyReward(progress) {
157
+ return (0, utils_1.preventNaN)(progress === null || progress === void 0 ? void 0 : progress.earnedRewards) > 0;
158
+ }
159
+ /** Every service and addon line on an order, flattened. */
160
+ function allLines(order) {
161
+ var _a;
162
+ const lines = [];
163
+ ((_a = order === null || order === void 0 ? void 0 : order.services) !== null && _a !== void 0 ? _a : []).forEach((carService) => {
164
+ var _a;
165
+ ((_a = carService === null || carService === void 0 ? void 0 : carService.services) !== null && _a !== void 0 ? _a : []).forEach((line) => lines.push(line));
166
+ });
167
+ return lines;
168
+ }
169
+ /**
170
+ * What the free wash is worth on this order.
171
+ *
172
+ * The **cheapest** line is discounted, which is the usual buy-X-get-Y convention and the
173
+ * conservative reading for whoever funds it. `maxAmount` caps it — that is the "max 50 SR" in
174
+ * "buy 4 washes, get one free (max 50 SR)".
175
+ *
176
+ * Returns 0 when there is nothing to discount, so a caller can treat 0 as "not redeemable here".
177
+ */
178
+ function loyaltyRewardValue(order, offer) {
179
+ if (!order || !(offer === null || offer === void 0 ? void 0 : offer.loyalty))
180
+ return 0;
181
+ if (!loyaltyCoversProvider(offer, order.providerId))
182
+ return 0;
183
+ const prices = allLines(order)
184
+ .map((line) => { var _a; return (0, utils_1.preventNaN)((_a = line === null || line === void 0 ? void 0 : line.price) === null || _a === void 0 ? void 0 : _a.price); })
185
+ .filter((price) => price > 0);
186
+ if (!prices.length)
187
+ return 0;
188
+ let value = Math.min(...prices);
189
+ const cap = (0, utils_1.preventNaN)(offer.maxAmount);
190
+ if (cap > 0)
191
+ value = Math.min(value, cap);
192
+ return value;
193
+ }
194
+ /**
195
+ * The offer, ready to attach to `order.promoCode` when a reward is redeemed.
196
+ *
197
+ * `amount` is resolved here and `isPercentage` forced off, so from this point the reward is an
198
+ * ordinary fixed discount and every existing calc treats it as one.
199
+ */
200
+ function redeemLoyaltyReward(order, offer) {
201
+ const amount = loyaltyRewardValue(order, offer);
202
+ if (amount <= 0)
203
+ return undefined;
204
+ return Object.assign(Object.assign({}, offer), { amount, isPercentage: false });
205
+ }
@@ -1,3 +1,3 @@
1
- export declare function preventNaN(x?: number | string): number;
2
- export declare const toFixed: (num: any) => string;
3
- export declare const isEmpty: (text: any) => boolean;
1
+ export declare function preventNaN(x?: number | string): number;
2
+ export declare const toFixed: (num: any) => string;
3
+ export declare const isEmpty: (text: any) => boolean;
package/dist/ts/utils.js CHANGED
@@ -1,24 +1,24 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.isEmpty = exports.toFixed = void 0;
4
- exports.preventNaN = preventNaN;
5
- function preventNaN(x) {
6
- if (x === undefined || x === null || isNaN(x)) {
7
- return 0;
8
- }
9
- return Number(x);
10
- }
11
- const toFixed = (num) => {
12
- const decimal_places = 2;
13
- num = Number(num || 0);
14
- return (Math.round(num * 10 ** decimal_places) / 10 ** decimal_places).toFixed(decimal_places);
15
- };
16
- exports.toFixed = toFixed;
17
- const isEmpty = (text) => {
18
- const txt = typeof text === 'string' ? text.trim() : text;
19
- if ((txt !== 0 && !txt) || txt === '' || txt === null || txt === 'null' || txt === ' ' || txt === undefined || txt === 'undefined') {
20
- return true;
21
- }
22
- return false;
23
- };
24
- exports.isEmpty = isEmpty;
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isEmpty = exports.toFixed = void 0;
4
+ exports.preventNaN = preventNaN;
5
+ function preventNaN(x) {
6
+ if (x === undefined || x === null || isNaN(x)) {
7
+ return 0;
8
+ }
9
+ return Number(x);
10
+ }
11
+ const toFixed = (num) => {
12
+ const decimal_places = 2;
13
+ num = Number(num || 0);
14
+ return (Math.round(num * 10 ** decimal_places) / 10 ** decimal_places).toFixed(decimal_places);
15
+ };
16
+ exports.toFixed = toFixed;
17
+ const isEmpty = (text) => {
18
+ const txt = typeof text === 'string' ? text.trim() : text;
19
+ if ((txt !== 0 && !txt) || txt === '' || txt === null || txt === 'null' || txt === ' ' || txt === undefined || txt === 'undefined') {
20
+ return true;
21
+ }
22
+ return false;
23
+ };
24
+ exports.isEmpty = isEmpty;