sahab-utils 1.7.3 → 1.7.5
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/constants/types.d.ts +85 -129
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/ts/calcs.d.ts +3 -0
- package/dist/ts/calcs.js +23 -29
- package/dist/ts/cashback.d.ts +4 -11
- package/dist/ts/cashback.js +4 -11
- package/dist/ts/gifts.d.ts +18 -67
- package/dist/ts/gifts.js +20 -70
- package/dist/ts/loyalty.d.ts +18 -72
- package/dist/ts/loyalty.js +22 -86
- package/dist/ts/utils.d.ts +4 -0
- package/dist/ts/utils.js +27 -1
- package/dist/ts/wheel.d.ts +27 -0
- package/dist/ts/wheel.js +92 -0
- package/package.json +2 -2
|
@@ -129,8 +129,7 @@ export type Order = {
|
|
|
129
129
|
cashbackOffer?: CashbackOffer;
|
|
130
130
|
cashbackAmount?: number;
|
|
131
131
|
/** Set by calculateCashbackOnOrderDone once the credit lands — its own idempotency guard,
|
|
132
|
-
* independent of movementsCreated, so a retried COMPLETED trigger cannot double-credit
|
|
133
|
-
* if calculateBalanceOnOrderDone's own guard fails to fire for an unrelated reason. */
|
|
132
|
+
* independent of movementsCreated, so a retried COMPLETED trigger cannot double-credit. */
|
|
134
133
|
cashbackCreditedAt?: TimestampType;
|
|
135
134
|
isDummyOrder?: boolean;
|
|
136
135
|
ratingNote?: string;
|
|
@@ -146,12 +145,8 @@ export type Order = {
|
|
|
146
145
|
providerLogoUrl?: string;
|
|
147
146
|
paymentGateway?: 'PAYLINK' | 'CHECKOUT' | 'TAP';
|
|
148
147
|
visitDates?: number[];
|
|
149
|
-
/**
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
* Already declared in cloudFun's local `Order`; added here because the customer app reads
|
|
153
|
-
* them too, and was doing so against a type that did not admit they exist.
|
|
154
|
-
*/
|
|
148
|
+
/** Cloud Tasks ids for cancelling a scheduled job — mirrors cloudFun's local `Order`; added here
|
|
149
|
+
* because the customer app reads them too, against a type that previously didn't admit they exist. */
|
|
155
150
|
arrivalExpirationTaskId?: string;
|
|
156
151
|
customerNotificationTaskId?: string;
|
|
157
152
|
/** Set on an order created by re-ordering, pointing at the one it was copied from. */
|
|
@@ -250,23 +245,14 @@ export type Complaint = {
|
|
|
250
245
|
type: ComplaintType;
|
|
251
246
|
resolvedBy?: string;
|
|
252
247
|
resolveDate?: string;
|
|
253
|
-
/**
|
|
254
|
-
*
|
|
255
|
-
*
|
|
256
|
-
* These are written by the customer app and read by the back office, but were missing from
|
|
257
|
-
* this type — so the app cast its way past the mismatch and the dashboard could not see the
|
|
258
|
-
* fields at all. Optional because older complaints predate them.
|
|
259
|
-
*/
|
|
248
|
+
/** Who raised it and against whom — written by the app, read by the back office. Optional because
|
|
249
|
+
* older complaints predate these fields (missing here before, so the app cast past the type mismatch). */
|
|
260
250
|
complaintClientId?: string;
|
|
261
251
|
complaintClientName?: string;
|
|
262
252
|
complaintProviderId?: string;
|
|
263
253
|
complaintProviderName?: string;
|
|
264
|
-
/**
|
|
265
|
-
*
|
|
266
|
-
*
|
|
267
|
-
* Named "number" but holds the order's document id — the back office looks the order up with
|
|
268
|
-
* it directly. Kept as-is because existing complaints are stored this way.
|
|
269
|
-
*/
|
|
254
|
+
/** The order the complaint is about, when it is about one. Named "number" but holds the order's
|
|
255
|
+
* document id — kept as-is because existing complaints are already stored this way. */
|
|
270
256
|
orderNumber?: string;
|
|
271
257
|
/** Hidden from the customer's list without deleting the record. */
|
|
272
258
|
isDismissed?: boolean;
|
|
@@ -543,13 +529,8 @@ export type TapApplicationError = {
|
|
|
543
529
|
at: number;
|
|
544
530
|
endpoint: 'files' | 'lead' | 'account' | 'update_lead';
|
|
545
531
|
};
|
|
546
|
-
/**
|
|
547
|
-
*
|
|
548
|
-
*
|
|
549
|
-
* One document per onboarding attempt. On rejection a new application may be created;
|
|
550
|
-
* history is preserved. Invariant: at most one non-terminal application per provider.
|
|
551
|
-
* Terminal states: `payout_enabled`, `rejected`.
|
|
552
|
-
*/
|
|
532
|
+
/** Stored at `service_providers/{providerId}/tapApplications/{applicationId}` — one document per
|
|
533
|
+
* onboarding attempt (history preserved on rejection). Invariant: at most one non-terminal application per provider. */
|
|
553
534
|
export type TapApplication = {
|
|
554
535
|
id: string;
|
|
555
536
|
providerId: string;
|
|
@@ -634,55 +615,71 @@ export type Discount = {
|
|
|
634
615
|
providerPercentage?: number;
|
|
635
616
|
/** Present only on "buy N, get one free" offers. Absent means an ordinary discount. */
|
|
636
617
|
loyalty?: LoyaltyOffer;
|
|
637
|
-
/**
|
|
638
|
-
*
|
|
639
|
-
*
|
|
640
|
-
* Sahab-only by nature: it is a platform acquisition offer, so `sahabPercentage` should be
|
|
641
|
-
* 100. Eligibility is decided by the server — the client cannot be trusted to report its own
|
|
642
|
-
* order history — and re-checked when the order is created.
|
|
643
|
-
*
|
|
644
|
-
* "Has not ordered before" means no order that is still live or already completed. An order
|
|
645
|
-
* the customer cancelled does not burn the offer, but one that is in flight does, which is
|
|
646
|
-
* what stops the same first-order discount being claimed on two carts at once.
|
|
647
|
-
*/
|
|
618
|
+
/** Restricts to customers who have never ordered before (cancelled orders don't count, in-flight
|
|
619
|
+
* ones do). Server-decided and re-checked at order creation — the client cannot be trusted to report this. */
|
|
648
620
|
firstOrderOnly?: boolean;
|
|
649
|
-
/**
|
|
650
|
-
*
|
|
651
|
-
*
|
|
652
|
-
* Distinct from `providerUid`, which marks an offer a *provider* created and funds. This is
|
|
653
|
-
* a Sahab offer that Sahab funds, restricted to a chosen set of carwashes.
|
|
654
|
-
*/
|
|
621
|
+
/** Providers this offer is valid at (empty/absent = every provider). Distinct from `providerUid`,
|
|
622
|
+
* which marks an offer a *provider* created and funds — this is a Sahab offer restricted to a chosen set of carwashes. */
|
|
655
623
|
providerUids?: string[];
|
|
656
|
-
/**
|
|
657
|
-
*
|
|
658
|
-
*
|
|
659
|
-
* The back-pointer is what lets the order pipeline recognise a gift redemption without
|
|
660
|
-
* loading anything — used to keep the loyalty stamp away from the recipient, who did not pay
|
|
661
|
-
* for the wash.
|
|
662
|
-
*/
|
|
624
|
+
/** Present only on the voucher issued for a gift; absent means an ordinary discount. The back-pointer
|
|
625
|
+
* lets the order pipeline recognise a gift redemption without a load, to keep the loyalty stamp off the recipient. */
|
|
663
626
|
gift?: {
|
|
664
627
|
giftId: string;
|
|
665
628
|
};
|
|
666
|
-
/**
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
*
|
|
673
|
-
* is what lets a genuinely Sahab-wide, no-single-provider offer (or a promo code with no
|
|
674
|
-
* gift/provider scope at all) still declare one explicitly.
|
|
675
|
-
*/
|
|
629
|
+
/** Present only on a prize issued by a wheel-of-fortune spin; absent means an ordinary discount. */
|
|
630
|
+
wheel?: {
|
|
631
|
+
campaignId: string;
|
|
632
|
+
segmentId: string;
|
|
633
|
+
};
|
|
634
|
+
/** Which environment this offer belongs to. Absent means prod, mirroring `Client`/`Order`/`Provider` `isTest`.
|
|
635
|
+
* Only needed when the offer doesn't already resolve to one environment on its own (see `loyaltyOfferMatchesTestMode`). */
|
|
676
636
|
isTest?: boolean;
|
|
677
637
|
};
|
|
678
|
-
/**
|
|
679
|
-
*
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
638
|
+
/** One prize slice on a wheel-of-fortune wheel. `weight` is relative, not a percentage — a segment
|
|
639
|
+
* with weight 2 is twice as likely as one with weight 1, whatever the other weights sum to. */
|
|
640
|
+
export type WheelSegment = {
|
|
641
|
+
segmentId: string;
|
|
642
|
+
label: {
|
|
643
|
+
ar: string;
|
|
644
|
+
en: string;
|
|
645
|
+
};
|
|
646
|
+
amount: number;
|
|
647
|
+
isPercentage?: boolean;
|
|
648
|
+
maxAmount?: number;
|
|
649
|
+
weight: number;
|
|
650
|
+
};
|
|
651
|
+
/** A wheel-of-fortune campaign, configured by Sahab staff. `cadence: 'once'` grants exactly one
|
|
652
|
+
* spin per customer for the campaign's whole `validFrom`–`validTo` window; `'daily'`/`'weekly'`
|
|
653
|
+
* grant one spin per customer per UTC day/ISO week the campaign is live. */
|
|
654
|
+
export type WheelCampaign = {
|
|
655
|
+
campaignId: string;
|
|
656
|
+
name?: string;
|
|
657
|
+
segments: WheelSegment[];
|
|
658
|
+
cadence: 'once' | 'daily' | 'weekly';
|
|
659
|
+
validFrom?: TimestampType;
|
|
660
|
+
validTo?: TimestampType;
|
|
661
|
+
active?: boolean;
|
|
662
|
+
/** Carwashes a won prize is valid at (empty/absent = every provider), mirroring `Discount.providerUids`. */
|
|
663
|
+
providerUids?: string[];
|
|
664
|
+
sahabPercentage?: number;
|
|
665
|
+
providerPercentage?: number;
|
|
666
|
+
createdAt?: TimestampType;
|
|
667
|
+
createrEmail?: string;
|
|
668
|
+
isTest?: boolean;
|
|
669
|
+
};
|
|
670
|
+
/** One customer's claim on a campaign cycle — flat doc id `${clientId}__${campaignId}__${cycleKey}`,
|
|
671
|
+
* so "has this customer already spun this cycle" is a direct read, not a query. */
|
|
672
|
+
export type WheelSpin = {
|
|
673
|
+
spinId: string;
|
|
674
|
+
clientId: string;
|
|
675
|
+
campaignId: string;
|
|
676
|
+
cycleKey: string;
|
|
677
|
+
segmentId: string;
|
|
678
|
+
promoCodeId: string;
|
|
679
|
+
spunAt: TimestampType;
|
|
680
|
+
};
|
|
681
|
+
/** A cashback campaign — percentage or flat credit to balance after a completed purchase. Kept
|
|
682
|
+
* separate from `Discount`, which prices at checkout; cashback never touches the pricing pipeline. */
|
|
686
683
|
export type CashbackOffer = {
|
|
687
684
|
amount?: number;
|
|
688
685
|
isPercentage?: boolean;
|
|
@@ -695,41 +692,20 @@ export type CashbackOffer = {
|
|
|
695
692
|
createrEmail?: string;
|
|
696
693
|
isTest?: boolean;
|
|
697
694
|
};
|
|
698
|
-
/**
|
|
699
|
-
*
|
|
700
|
-
* offer.
|
|
701
|
-
*
|
|
702
|
-
* The reward is redeemed as an ordinary fixed-amount discount: at redemption the earned amount
|
|
703
|
-
* is written onto `order.promoCode.amount`, so it flows through `getSahabDiscountsAmount` /
|
|
704
|
-
* `getProviderDiscountsAmount` and the `sahabPercentage` / `providerPercentage` split exactly
|
|
705
|
-
* like any other promo. Nothing in the pricing pipeline needs to know loyalty exists.
|
|
706
|
-
*/
|
|
695
|
+
/** "Buy N washes, get one free" config, carried on the `Discount` representing the offer.
|
|
696
|
+
* Redeemed as an ordinary fixed discount, so it flows through the normal split unchanged. */
|
|
707
697
|
export type LoyaltyOffer = {
|
|
708
698
|
/** Qualifying orders needed to earn one free wash. */
|
|
709
699
|
buyQuantity: number;
|
|
710
|
-
/**
|
|
711
|
-
*
|
|
712
|
-
*
|
|
713
|
-
* Without it a customer can collect stamps on the cheapest possible wash and redeem against
|
|
714
|
-
* an expensive one.
|
|
715
|
-
*/
|
|
700
|
+
/** Minimum the customer must actually pay for an order to count as a stamp — without it they could
|
|
701
|
+
* stamp on the cheapest wash and redeem against an expensive one. */
|
|
716
702
|
minOrderAmount?: number;
|
|
717
|
-
/**
|
|
718
|
-
*
|
|
719
|
-
*
|
|
720
|
-
* Only meaningful for Sahab-created offers, which may span some or all providers. A
|
|
721
|
-
* provider-created offer is always scoped to its own `providerUid`, whatever this says.
|
|
722
|
-
* Absent or empty on a Sahab offer means every provider counts.
|
|
723
|
-
*/
|
|
703
|
+
/** Providers whose orders count toward a Sahab-created offer (empty/absent = every provider).
|
|
704
|
+
* A provider-created offer ignores this and is always scoped to its own `providerUid`. */
|
|
724
705
|
providerUids?: string[];
|
|
725
706
|
};
|
|
726
|
-
/**
|
|
727
|
-
*
|
|
728
|
-
*
|
|
729
|
-
* Server-owned: only cloud functions may write it, or a customer could award themselves washes.
|
|
730
|
-
* Rewards do not expire — the campaign's own `validFrom`/`validTo` bounds when stamps are
|
|
731
|
-
* earned, not how long an earned reward lasts.
|
|
732
|
-
*/
|
|
707
|
+
/** A customer's progress toward one loyalty offer. Server-owned — only cloud functions may write
|
|
708
|
+
* it, or a customer could award themselves washes. Earned rewards do not expire. */
|
|
733
709
|
export type LoyaltyProgress = {
|
|
734
710
|
clientId: string;
|
|
735
711
|
/** The offer this progress belongs to. */
|
|
@@ -742,25 +718,13 @@ export type LoyaltyProgress = {
|
|
|
742
718
|
earnedRewards: number;
|
|
743
719
|
/** Orders already counted, so a status flapping COMPLETED->RATED cannot double-stamp. */
|
|
744
720
|
countedOrderIds: string[];
|
|
745
|
-
/**
|
|
746
|
-
*
|
|
747
|
-
*
|
|
748
|
-
* Kept separate from `countedOrderIds` because a gift has no order id at the moment it is
|
|
749
|
-
* paid for, and mixing the two id spaces would make a collision silently skip a stamp.
|
|
750
|
-
*/
|
|
721
|
+
/** Gifts counted for stamps earned by *buying* one, kept separate from `countedOrderIds` because
|
|
722
|
+
* a gift has no order id at purchase — mixing the two id spaces would silently skip a stamp. */
|
|
751
723
|
countedGiftIds?: string[];
|
|
752
724
|
updatedAt?: TimestampType;
|
|
753
725
|
};
|
|
754
|
-
/**
|
|
755
|
-
*
|
|
756
|
-
*
|
|
757
|
-
* The voucher the recipient actually spends is an ordinary `Discount` carrying `gift.giftId`.
|
|
758
|
-
* This document owns everything the voucher deliberately does not — payment, delivery and
|
|
759
|
-
* lifecycle — so the voucher stays a pure pricing object that the calcs can read without
|
|
760
|
-
* knowing gifting exists.
|
|
761
|
-
*
|
|
762
|
-
* Server-owned. A client that could write this could issue itself free washes.
|
|
763
|
-
*/
|
|
726
|
+
/** One paid-for gift. The voucher the recipient spends is an ordinary `Discount` carrying
|
|
727
|
+
* `gift.giftId` — this document owns payment, delivery and lifecycle so the voucher stays pure. Server-owned. */
|
|
764
728
|
export type Gift = {
|
|
765
729
|
giftId: string;
|
|
766
730
|
status: GiftStatus;
|
|
@@ -769,10 +733,8 @@ export type Gift = {
|
|
|
769
733
|
giverName?: string;
|
|
770
734
|
/** E.164, normalised at write. The recipient may not have an account yet. */
|
|
771
735
|
recipientPhone: string;
|
|
772
|
-
/**
|
|
773
|
-
*
|
|
774
|
-
* stable uid for a phone number on demand, so the voucher can be scoped to them immediately.
|
|
775
|
-
*/
|
|
736
|
+
/** Resolved at issuance, before the recipient has opened the app — Firebase Auth mints a stable
|
|
737
|
+
* uid for a phone number on demand, so the voucher can be scoped to them immediately. */
|
|
776
738
|
recipientUid?: string;
|
|
777
739
|
/** Whether they were already a customer. Analytics, and useful to support. */
|
|
778
740
|
recipientHadAccount?: boolean;
|
|
@@ -801,11 +763,8 @@ export type Gift = {
|
|
|
801
763
|
expiresAt?: TimestampType;
|
|
802
764
|
notifiedAt?: TimestampType;
|
|
803
765
|
notifyChannel?: GiftNotifyChannel;
|
|
804
|
-
/**
|
|
805
|
-
*
|
|
806
|
-
* creation and never recomputed, same reasoning as `expiresAt`: a client's test/prod status
|
|
807
|
-
* changing later must not retroactively move a gift someone already paid for.
|
|
808
|
-
*/
|
|
766
|
+
/** Whether the giver was a test client at purchase — mirrors `Client.isTest`. Stamped once and
|
|
767
|
+
* never recomputed, so a client's test/prod status changing later can't retroactively move a paid gift. */
|
|
809
768
|
isTest?: boolean;
|
|
810
769
|
};
|
|
811
770
|
/** One line of a gifted basket, priced when the gift was bought. */
|
|
@@ -839,11 +798,8 @@ export declare enum GiftNotifyChannel {
|
|
|
839
798
|
}
|
|
840
799
|
/** Back-office settings for gifting. */
|
|
841
800
|
export type GiftConfig = {
|
|
842
|
-
/**
|
|
843
|
-
*
|
|
844
|
-
*
|
|
845
|
-
* Bounded on read: a 0 would expire every gift the moment it was sold.
|
|
846
|
-
*/
|
|
801
|
+
/** How long an issued gift stays spendable, applied at issuance only. Bounded on read — a 0
|
|
802
|
+
* would expire every gift the moment it was sold. */
|
|
847
803
|
giftExpiryMonths?: number;
|
|
848
804
|
/** Charged when the giver adds a personal message to the SMS. Defaults to `GIFT_MESSAGE_SURCHARGE_DEFAULT` when unset. */
|
|
849
805
|
giftMessageSurcharge?: number;
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -19,3 +19,5 @@ __exportStar(require("./ts/calcs"), exports);
|
|
|
19
19
|
__exportStar(require("./ts/cashback"), exports);
|
|
20
20
|
__exportStar(require("./ts/gifts"), exports);
|
|
21
21
|
__exportStar(require("./ts/loyalty"), exports);
|
|
22
|
+
__exportStar(require("./ts/utils"), exports);
|
|
23
|
+
__exportStar(require("./ts/wheel"), exports);
|
package/dist/ts/calcs.d.ts
CHANGED
|
@@ -2,6 +2,9 @@ import { Order } from '../constants/types';
|
|
|
2
2
|
export declare const SAHAB_PERCENTAGE = 0;
|
|
3
3
|
export declare const ONLINE_SAHAB_PERCENTAGE = 0.03;
|
|
4
4
|
export declare const CLIENT_SAHAB_PERCENTAGE = 0.03;
|
|
5
|
+
export declare const PROVIDER_ORDERS_PERCENTAGE_DEFAULT = 0.1;
|
|
6
|
+
export declare const PROVIDER_ORDERS_PERCENTAGE_WITH_SAHAB_LOGO = 0.07;
|
|
7
|
+
export declare function providerOrdersPercentage(hasSahabLogoOnCar: boolean | undefined, storedValue: unknown): number;
|
|
5
8
|
export declare function getServicesTotal(order?: Order): number;
|
|
6
9
|
export declare function getSahabDiscountsAmount(order: Order): number;
|
|
7
10
|
export declare function getProviderDiscountsAmount(order: Order): number;
|
package/dist/ts/calcs.js
CHANGED
|
@@ -3,7 +3,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.CLIENT_SAHAB_PERCENTAGE = exports.ONLINE_SAHAB_PERCENTAGE = exports.SAHAB_PERCENTAGE = void 0;
|
|
6
|
+
exports.PROVIDER_ORDERS_PERCENTAGE_WITH_SAHAB_LOGO = exports.PROVIDER_ORDERS_PERCENTAGE_DEFAULT = exports.CLIENT_SAHAB_PERCENTAGE = exports.ONLINE_SAHAB_PERCENTAGE = exports.SAHAB_PERCENTAGE = void 0;
|
|
7
|
+
exports.providerOrdersPercentage = providerOrdersPercentage;
|
|
7
8
|
exports.getServicesTotal = getServicesTotal;
|
|
8
9
|
exports.getSahabDiscountsAmount = getSahabDiscountsAmount;
|
|
9
10
|
exports.getProviderDiscountsAmount = getProviderDiscountsAmount;
|
|
@@ -22,10 +23,21 @@ const utils_1 = require("./utils");
|
|
|
22
23
|
exports.SAHAB_PERCENTAGE = 0;
|
|
23
24
|
exports.ONLINE_SAHAB_PERCENTAGE = 0.03;
|
|
24
25
|
exports.CLIENT_SAHAB_PERCENTAGE = 0.03;
|
|
25
|
-
//
|
|
26
|
-
// `
|
|
27
|
-
//
|
|
28
|
-
|
|
26
|
+
// `Provider.ordersPercentage` — the provider's own cut of an order, factored into
|
|
27
|
+
// `sahabProviderPercentage` server-side. Only two values are ever valid: the default, or the
|
|
28
|
+
// reduced rate for a provider that's put the Sahab logo on their car.
|
|
29
|
+
exports.PROVIDER_ORDERS_PERCENTAGE_DEFAULT = 0.1;
|
|
30
|
+
exports.PROVIDER_ORDERS_PERCENTAGE_WITH_SAHAB_LOGO = 0.07;
|
|
31
|
+
// Never trusts a stored `ordersPercentage` verbatim — it may have arrived without going through
|
|
32
|
+
// this constraint (a stray Firestore edit, a migration). Clamps to whichever of the two valid
|
|
33
|
+
// values actually applies instead.
|
|
34
|
+
function providerOrdersPercentage(hasSahabLogoOnCar, storedValue) {
|
|
35
|
+
if (!hasSahabLogoOnCar)
|
|
36
|
+
return exports.PROVIDER_ORDERS_PERCENTAGE_DEFAULT;
|
|
37
|
+
return storedValue === exports.PROVIDER_ORDERS_PERCENTAGE_WITH_SAHAB_LOGO ? exports.PROVIDER_ORDERS_PERCENTAGE_WITH_SAHAB_LOGO : exports.PROVIDER_ORDERS_PERCENTAGE_DEFAULT;
|
|
38
|
+
}
|
|
39
|
+
// `type` is missing on legacy orders, so fall back to the id field only one shape carries —
|
|
40
|
+
// without it, legacy line items match neither guard and promos silently zero out.
|
|
29
41
|
function isProvidedService(service) {
|
|
30
42
|
if (service === null || service === void 0 ? void 0 : service.type)
|
|
31
43
|
return service.type === 'service';
|
|
@@ -36,7 +48,6 @@ function isProvidedAddon(service) {
|
|
|
36
48
|
return service.type === 'addon';
|
|
37
49
|
return (service === null || service === void 0 ? void 0 : service.addonId) !== undefined;
|
|
38
50
|
}
|
|
39
|
-
// Helper function to check if service/addon is applicable for promo codes
|
|
40
51
|
function isServiceApplicableForPromo(service, servicesIds, addonsIds) {
|
|
41
52
|
if (isProvidedService(service)) {
|
|
42
53
|
return (servicesIds === null || servicesIds === void 0 ? void 0 : servicesIds.includes(service.serviceId || '')) || false;
|
|
@@ -46,7 +57,6 @@ function isServiceApplicableForPromo(service, servicesIds, addonsIds) {
|
|
|
46
57
|
}
|
|
47
58
|
return false;
|
|
48
59
|
}
|
|
49
|
-
// Helper function to check if service/addon is applicable for discounts
|
|
50
60
|
function isServiceApplicableForDiscount(service, discount) {
|
|
51
61
|
var _a, _b, _c, _d;
|
|
52
62
|
const scopedToServices = !!((_a = discount === null || discount === void 0 ? void 0 : discount.servicesIds) === null || _a === void 0 ? void 0 : _a.length);
|
|
@@ -63,14 +73,8 @@ function isServiceApplicableForDiscount(service, discount) {
|
|
|
63
73
|
}
|
|
64
74
|
return false;
|
|
65
75
|
}
|
|
66
|
-
/**
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
* Previously the Sahab and Provider sides each decided independently, so a partially
|
|
70
|
-
* configured discount could be funded at 150% (both a percentage and `createdByProvider`)
|
|
71
|
-
* or at 60% (one percentage set, the remainder funded by nobody). Deriving both shares in
|
|
72
|
-
* one place makes over/under-funding structurally impossible.
|
|
73
|
-
*/
|
|
76
|
+
/** Funding split for a discount, as fractions that always sum to 1. Previously each side computed
|
|
77
|
+
* its own share independently and could total 150% or 60%; deriving both here makes that impossible. */
|
|
74
78
|
function responsibilitySplit(discount) {
|
|
75
79
|
var _a, _b;
|
|
76
80
|
const s = (0, utils_1.preventNaN)((_a = discount === null || discount === void 0 ? void 0 : discount.sahabPercentage) !== null && _a !== void 0 ? _a : 0);
|
|
@@ -190,15 +194,8 @@ function cashSurcharge(order) {
|
|
|
190
194
|
var _a;
|
|
191
195
|
return (order === null || order === void 0 ? void 0 : order.paymentType) === types_1.OrderPaymentType.CASH ? (0, utils_1.preventNaN)((_a = order === null || order === void 0 ? void 0 : order.sahabExtraPaymentAmount) !== null && _a !== void 0 ? _a : 0) : 0;
|
|
192
196
|
}
|
|
193
|
-
/**
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
* `paymentAmount` for a cash order is the full amount the customer hands over, which
|
|
197
|
-
* includes Sahab's cash surcharge. That surcharge is Sahab's revenue, not the provider's,
|
|
198
|
-
* so it must come off before comparing against `minimumProviderAmount` — otherwise the
|
|
199
|
-
* surcharge is counted twice in `getSahabFees` and deducted from the provider twice in
|
|
200
|
-
* `getProviderProfits`.
|
|
201
|
-
*/
|
|
197
|
+
/** Cash `paymentAmount` includes Sahab's surcharge, which is Sahab's revenue not the provider's —
|
|
198
|
+
* it must come off before comparing to `minimumProviderAmount`, or it's double-counted in getSahabFees/getProviderProfits. */
|
|
202
199
|
function cashSettlementBase(order) {
|
|
203
200
|
var _a;
|
|
204
201
|
return Math.max(0, (0, utils_1.preventNaN)((_a = order === null || order === void 0 ? void 0 : order.paymentAmount) !== null && _a !== void 0 ? _a : 0) - cashSurcharge(order));
|
|
@@ -238,14 +235,12 @@ function getSahabFees(order) {
|
|
|
238
235
|
const paymentAmount = (order === null || order === void 0 ? void 0 : order.paymentType) === types_1.OrderPaymentType.CASH ? cashSettlementBase(order) : getCustomerTotalToPay(order);
|
|
239
236
|
const originalPrice = orderTotal / (1 + (0, utils_1.preventNaN)((_a = order === null || order === void 0 ? void 0 : order.sahabClientPercentage) !== null && _a !== void 0 ? _a : 0));
|
|
240
237
|
const minimumProviderAmount = originalPrice - originalPrice * ((_b = order === null || order === void 0 ? void 0 : order.sahabProviderPercentage) !== null && _b !== void 0 ? _b : 0);
|
|
241
|
-
// When a Sahab-funded discount pushes the customer below the provider's entitlement,
|
|
242
|
-
//
|
|
243
|
-
// margin on every discounted order, so the negative figure is returned as-is.
|
|
238
|
+
// When a Sahab-funded discount pushes the customer below the provider's entitlement, Sahab is
|
|
239
|
+
// genuinely subsidising the order — reporting a floor of 0 here overstated margin, so the negative figure stands.
|
|
244
240
|
return paymentAmount - minimumProviderAmount + cashSurcharge(order);
|
|
245
241
|
}
|
|
246
242
|
function calcOrdersData(orders) {
|
|
247
243
|
var _a, _b, _c, _d, _e, _f, _g, _h;
|
|
248
|
-
//Filter Orders
|
|
249
244
|
const cashOrders = orders.filter((o) => o.paymentType === types_1.OrderPaymentType.CASH);
|
|
250
245
|
const onlineOrders = orders.filter((o) => o.paymentType !== types_1.OrderPaymentType.CASH);
|
|
251
246
|
const sahabOnlineRevenue = (0, sum_1.default)(onlineOrders.map((o) => getSahabFees(o)));
|
|
@@ -254,7 +249,6 @@ function calcOrdersData(orders) {
|
|
|
254
249
|
const sahabCashRevenue = (0, sum_1.default)(cashOrders.map((o) => getSahabFees(o)));
|
|
255
250
|
const totalCashTransfer = (0, sum_1.default)(cashOrders.map((o) => getProviderTransferAmount(o)));
|
|
256
251
|
const providerCashRevenue = (0, sum_1.default)(cashOrders.map((o) => getProviderProfits(o)));
|
|
257
|
-
//Calculate Percentages
|
|
258
252
|
const totalTransfer = totalOnlineTransfer + totalCashTransfer;
|
|
259
253
|
const totalSahabRevenue = sahabOnlineRevenue + sahabCashRevenue;
|
|
260
254
|
const totalProviderRevenue = providerOnlineRevenue + providerCashRevenue;
|
package/dist/ts/cashback.d.ts
CHANGED
|
@@ -1,15 +1,8 @@
|
|
|
1
1
|
import { CashbackOffer, Order } from '../constants/types';
|
|
2
|
-
/**
|
|
3
|
-
*
|
|
4
|
-
* carwash. Shared by every offer type with a `providerUids`-style restriction — do not let a
|
|
5
|
-
* second copy of this exist; see the CLAUDE.md warning about the gift-flow eligibility
|
|
6
|
-
* duplication this exact pattern caused before.
|
|
7
|
-
*/
|
|
2
|
+
/** Whether a provider-scoped offer covers a provider (empty/absent scope = every provider). Shared
|
|
3
|
+
* by every offer type with this restriction — don't duplicate it; see CLAUDE.md's gift-flow eligibility warning. */
|
|
8
4
|
export declare function offerCoversProvider(scope: string[] | undefined, providerId?: string): boolean;
|
|
9
5
|
export declare function cashbackOfferCoversProvider(offer: CashbackOffer | undefined, providerId?: string): boolean;
|
|
10
|
-
/**
|
|
11
|
-
*
|
|
12
|
-
* already nets out other discounts and any balance the customer used — never pay cashback on
|
|
13
|
-
* money the customer didn't spend.
|
|
14
|
-
*/
|
|
6
|
+
/** The cashback amount this order earns, or 0. Percentage is of `getCustomerTotalToPay`, which
|
|
7
|
+
* already nets out discounts and used balance — never pay cashback on money the customer didn't spend. */
|
|
15
8
|
export declare function cashbackAmountFor(offer: CashbackOffer | undefined, order: Order): number;
|
package/dist/ts/cashback.js
CHANGED
|
@@ -5,12 +5,8 @@ exports.cashbackOfferCoversProvider = cashbackOfferCoversProvider;
|
|
|
5
5
|
exports.cashbackAmountFor = cashbackAmountFor;
|
|
6
6
|
const calcs_1 = require("./calcs");
|
|
7
7
|
const utils_1 = require("./utils");
|
|
8
|
-
/**
|
|
9
|
-
*
|
|
10
|
-
* carwash. Shared by every offer type with a `providerUids`-style restriction — do not let a
|
|
11
|
-
* second copy of this exist; see the CLAUDE.md warning about the gift-flow eligibility
|
|
12
|
-
* duplication this exact pattern caused before.
|
|
13
|
-
*/
|
|
8
|
+
/** Whether a provider-scoped offer covers a provider (empty/absent scope = every provider). Shared
|
|
9
|
+
* by every offer type with this restriction — don't duplicate it; see CLAUDE.md's gift-flow eligibility warning. */
|
|
14
10
|
function offerCoversProvider(scope, providerId) {
|
|
15
11
|
if (!scope || !scope.length)
|
|
16
12
|
return true;
|
|
@@ -19,11 +15,8 @@ function offerCoversProvider(scope, providerId) {
|
|
|
19
15
|
function cashbackOfferCoversProvider(offer, providerId) {
|
|
20
16
|
return offerCoversProvider(offer === null || offer === void 0 ? void 0 : offer.providerUids, providerId);
|
|
21
17
|
}
|
|
22
|
-
/**
|
|
23
|
-
*
|
|
24
|
-
* already nets out other discounts and any balance the customer used — never pay cashback on
|
|
25
|
-
* money the customer didn't spend.
|
|
26
|
-
*/
|
|
18
|
+
/** The cashback amount this order earns, or 0. Percentage is of `getCustomerTotalToPay`, which
|
|
19
|
+
* already nets out discounts and used balance — never pay cashback on money the customer didn't spend. */
|
|
27
20
|
function cashbackAmountFor(offer, order) {
|
|
28
21
|
if (!offer)
|
|
29
22
|
return 0;
|
package/dist/ts/gifts.d.ts
CHANGED
|
@@ -1,15 +1,6 @@
|
|
|
1
1
|
import { CarSize, Discount, Gift, GiftConfig, GiftItem, Order } from '../constants/types';
|
|
2
|
-
/**
|
|
3
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
126
|
-
//
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
}
|
package/dist/ts/loyalty.d.ts
CHANGED
|
@@ -1,92 +1,38 @@
|
|
|
1
1
|
import { Discount, Gift, LoyaltyProgress, Order } from '../constants/types';
|
|
2
|
-
/**
|
|
3
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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;
|
package/dist/ts/loyalty.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
107
|
-
//
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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)
|
package/dist/ts/utils.d.ts
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
1
|
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
|
+
export declare const compareVersions: (version1: string, version2: string) => number;
|
|
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. */
|
|
7
|
+
export declare function shouldForceUpdate(minVer?: string, currentVersion?: string): boolean;
|
package/dist/ts/utils.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.isEmpty = exports.toFixed = void 0;
|
|
3
|
+
exports.compareVersions = exports.isEmpty = exports.toFixed = void 0;
|
|
4
4
|
exports.preventNaN = preventNaN;
|
|
5
|
+
exports.shouldForceUpdate = shouldForceUpdate;
|
|
5
6
|
function preventNaN(x) {
|
|
6
7
|
if (x === undefined || x === null || isNaN(x)) {
|
|
7
8
|
return 0;
|
|
@@ -22,3 +23,28 @@ const isEmpty = (text) => {
|
|
|
22
23
|
return false;
|
|
23
24
|
};
|
|
24
25
|
exports.isEmpty = isEmpty;
|
|
26
|
+
const compareVersions = (version1, version2) => {
|
|
27
|
+
const v1Parts = version1.split('.').map(Number);
|
|
28
|
+
const v2Parts = version2.split('.').map(Number);
|
|
29
|
+
const maxLength = Math.max(v1Parts.length, v2Parts.length);
|
|
30
|
+
for (let i = 0; i < maxLength; i++) {
|
|
31
|
+
const v1Part = v1Parts[i] || 0;
|
|
32
|
+
const v2Part = v2Parts[i] || 0;
|
|
33
|
+
if (v1Part > v2Part)
|
|
34
|
+
return 1;
|
|
35
|
+
if (v1Part < v2Part)
|
|
36
|
+
return -1;
|
|
37
|
+
}
|
|
38
|
+
return 0;
|
|
39
|
+
};
|
|
40
|
+
exports.compareVersions = compareVersions;
|
|
41
|
+
const VERSION_RE = /^\d+(\.\d+)*$/;
|
|
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. */
|
|
44
|
+
function shouldForceUpdate(minVer, currentVersion) {
|
|
45
|
+
if (!minVer || !currentVersion)
|
|
46
|
+
return false;
|
|
47
|
+
if (!VERSION_RE.test(minVer) || !VERSION_RE.test(currentVersion))
|
|
48
|
+
return false;
|
|
49
|
+
return (0, exports.compareVersions)(minVer, currentVersion) > 0;
|
|
50
|
+
}
|
|
@@ -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;
|
package/dist/ts/wheel.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.WHEEL_PRIZE_EXPIRY_DAYS = void 0;
|
|
4
|
+
exports.wheelPrizeExpiryDate = wheelPrizeExpiryDate;
|
|
5
|
+
exports.cycleKeyFor = cycleKeyFor;
|
|
6
|
+
exports.isCampaignLive = isCampaignLive;
|
|
7
|
+
exports.pickWeightedSegment = pickWeightedSegment;
|
|
8
|
+
exports.wheelPrizeDiscount = wheelPrizeDiscount;
|
|
9
|
+
/** Wheel-of-fortune — the shared rules. Pure (no Firestore/clock/ids); the cloud function owns
|
|
10
|
+
* reading the campaign, generating the random draw, and writing the spin + prize atomically. */
|
|
11
|
+
/** A prize is redeemable for this long from the moment it's won, independent of the campaign's own
|
|
12
|
+
* window — a customer who spins on the campaign's last day still gets a fair chance to use it. */
|
|
13
|
+
exports.WHEEL_PRIZE_EXPIRY_DAYS = 14;
|
|
14
|
+
function wheelPrizeExpiryDate(issuedAt) {
|
|
15
|
+
const out = new Date(issuedAt.getTime());
|
|
16
|
+
out.setDate(out.getDate() + exports.WHEEL_PRIZE_EXPIRY_DAYS);
|
|
17
|
+
return out;
|
|
18
|
+
}
|
|
19
|
+
/** Which cycle `at` falls into for a campaign's cadence — the same key twice within one grant
|
|
20
|
+
* period, a different key across it. `'once'` has a single, constant cycle for the campaign's
|
|
21
|
+
* whole life; the doc-id built from this is what makes "already spun this cycle" a direct read. */
|
|
22
|
+
function cycleKeyFor(campaign, at) {
|
|
23
|
+
if (campaign.cadence === 'daily') {
|
|
24
|
+
return at.toISOString().slice(0, 10);
|
|
25
|
+
}
|
|
26
|
+
if (campaign.cadence === 'weekly') {
|
|
27
|
+
return isoWeekKey(at);
|
|
28
|
+
}
|
|
29
|
+
return 'once';
|
|
30
|
+
}
|
|
31
|
+
/** ISO-8601 week key (`YYYY-Www`), Thursday-anchored so the week a year "belongs to" matches the
|
|
32
|
+
* ISO standard rather than the calendar year of Jan 1st. */
|
|
33
|
+
function isoWeekKey(at) {
|
|
34
|
+
const date = new Date(Date.UTC(at.getUTCFullYear(), at.getUTCMonth(), at.getUTCDate()));
|
|
35
|
+
const dayNum = date.getUTCDay() || 7;
|
|
36
|
+
date.setUTCDate(date.getUTCDate() + 4 - dayNum);
|
|
37
|
+
const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
|
|
38
|
+
const week = Math.ceil(((date.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
|
|
39
|
+
return `${date.getUTCFullYear()}-W${String(week).padStart(2, '0')}`;
|
|
40
|
+
}
|
|
41
|
+
/** Whether a campaign is currently offering spins. `validFrom`/`validTo` are passed pre-converted
|
|
42
|
+
* (see this package's `TimestampType` gotcha — converting Firestore timestamps is the caller's job). */
|
|
43
|
+
function isCampaignLive(campaign, at, window) {
|
|
44
|
+
if (campaign.active === false)
|
|
45
|
+
return false;
|
|
46
|
+
if (window.validFrom && at < window.validFrom)
|
|
47
|
+
return false;
|
|
48
|
+
if (window.validTo && at > window.validTo)
|
|
49
|
+
return false;
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
/** Weighted random pick, given the caller's own `[0,1)` draw so the choice itself stays pure and
|
|
53
|
+
* testable — `Math.random()` is the cloud function's job, never this module's. A segment with
|
|
54
|
+
* `weight` ≤ 0 can never be picked. */
|
|
55
|
+
function pickWeightedSegment(segments, randomUnit) {
|
|
56
|
+
const live = segments.filter((s) => { var _a; return ((_a = s.weight) !== null && _a !== void 0 ? _a : 0) > 0; });
|
|
57
|
+
if (!live.length)
|
|
58
|
+
throw new Error('pickWeightedSegment: no segment has a positive weight');
|
|
59
|
+
const totalWeight = live.reduce((sum, s) => sum + s.weight, 0);
|
|
60
|
+
const target = Math.max(0, Math.min(randomUnit, 0.9999999999)) * totalWeight;
|
|
61
|
+
let cumulative = 0;
|
|
62
|
+
for (const segment of live) {
|
|
63
|
+
cumulative += segment.weight;
|
|
64
|
+
if (target < cumulative)
|
|
65
|
+
return segment;
|
|
66
|
+
}
|
|
67
|
+
return live[live.length - 1];
|
|
68
|
+
}
|
|
69
|
+
/** The prize for a winning spin — an ordinary auto-applied `Discount`, mirroring `giftDiscount`.
|
|
70
|
+
* Hardcoded shape (not passed in) so no call site can misfund it or leave it typeable-in. */
|
|
71
|
+
function wheelPrizeDiscount(campaign, segment, winnerUid, promoCodeId, dates) {
|
|
72
|
+
var _a, _b;
|
|
73
|
+
return {
|
|
74
|
+
promoCodeId,
|
|
75
|
+
amount: segment.amount,
|
|
76
|
+
isPercentage: segment.isPercentage,
|
|
77
|
+
maxAmount: segment.maxAmount,
|
|
78
|
+
clientIds: [winnerUid],
|
|
79
|
+
providerUids: campaign.providerUids,
|
|
80
|
+
allowedOnce: true,
|
|
81
|
+
usedBy: [],
|
|
82
|
+
// Never typed in as a code — the app finds it via `wheel.campaignId`, not a promoCode string.
|
|
83
|
+
isAutoApplied: true,
|
|
84
|
+
createdByProvider: false,
|
|
85
|
+
sahabPercentage: (_a = campaign.sahabPercentage) !== null && _a !== void 0 ? _a : 100,
|
|
86
|
+
providerPercentage: (_b = campaign.providerPercentage) !== null && _b !== void 0 ? _b : 0,
|
|
87
|
+
validFrom: dates.validFrom,
|
|
88
|
+
validTo: dates.validTo,
|
|
89
|
+
wheel: { campaignId: campaign.campaignId, segmentId: segment.segmentId },
|
|
90
|
+
isTest: campaign.isTest,
|
|
91
|
+
};
|
|
92
|
+
}
|
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sahab-utils",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.5",
|
|
4
4
|
"description": "This package contains types and utils functions",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
7
7
|
"scripts": {
|
|
8
8
|
"build": "tsc",
|
|
9
9
|
"deploy": "npm run build && npm publish",
|
|
10
|
-
"test": "npm run build && node tests/loyalty.test.js && node tests/gifts.test.js && node tests/cashback.test.js"
|
|
10
|
+
"test": "npm run build && node tests/loyalty.test.js && node tests/gifts.test.js && node tests/cashback.test.js && node tests/utils.test.js && node tests/wheel.test.js && node tests/calcs.test.js"
|
|
11
11
|
},
|
|
12
12
|
"repository": {
|
|
13
13
|
"type": "git",
|