@bash-app/bash-common 30.236.0 → 30.238.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.
- package/dist/__tests__/statusEnums.test.js +88 -7
- package/dist/__tests__/statusEnums.test.js.map +1 -1
- package/dist/definitions.d.ts +39 -8
- package/dist/definitions.d.ts.map +1 -1
- package/dist/definitions.js +31 -10
- package/dist/definitions.js.map +1 -1
- package/dist/extendedSchemas.d.ts +6 -0
- package/dist/extendedSchemas.d.ts.map +1 -1
- package/dist/extendedSchemas.js.map +1 -1
- package/dist/index.d.ts +13 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +16 -4
- package/dist/index.js.map +1 -1
- package/dist/mirroredPrismaEnums.d.ts +145 -0
- package/dist/mirroredPrismaEnums.d.ts.map +1 -0
- package/dist/mirroredPrismaEnums.js +136 -0
- package/dist/mirroredPrismaEnums.js.map +1 -0
- package/dist/utils/__tests__/nonprofitUtils.test.d.ts +2 -0
- package/dist/utils/__tests__/nonprofitUtils.test.d.ts.map +1 -0
- package/dist/utils/__tests__/nonprofitUtils.test.js +84 -0
- package/dist/utils/__tests__/nonprofitUtils.test.js.map +1 -0
- package/dist/utils/__tests__/paymentUtils.test.js +87 -0
- package/dist/utils/__tests__/paymentUtils.test.js.map +1 -1
- package/dist/utils/nonprofitUtils.d.ts +21 -0
- package/dist/utils/nonprofitUtils.d.ts.map +1 -0
- package/dist/utils/nonprofitUtils.js +29 -0
- package/dist/utils/nonprofitUtils.js.map +1 -0
- package/dist/utils/paymentUtils.d.ts +23 -0
- package/dist/utils/paymentUtils.d.ts.map +1 -1
- package/dist/utils/paymentUtils.js +24 -7
- package/dist/utils/paymentUtils.js.map +1 -1
- package/dist/utils/service/__tests__/billingCapUtils.test.d.ts +2 -0
- package/dist/utils/service/__tests__/billingCapUtils.test.d.ts.map +1 -0
- package/dist/utils/service/__tests__/billingCapUtils.test.js +120 -0
- package/dist/utils/service/__tests__/billingCapUtils.test.js.map +1 -0
- package/dist/utils/service/__tests__/cancellationPolicyRefundResolver.test.js +1 -1
- package/dist/utils/service/__tests__/cancellationPolicyRefundResolver.test.js.map +1 -1
- package/dist/utils/service/__tests__/eventPromoterPhase3.test.d.ts +2 -0
- package/dist/utils/service/__tests__/eventPromoterPhase3.test.d.ts.map +1 -0
- package/dist/utils/service/__tests__/eventPromoterPhase3.test.js +243 -0
- package/dist/utils/service/__tests__/eventPromoterPhase3.test.js.map +1 -0
- package/dist/utils/service/__tests__/eventPromoterUtils.test.d.ts +2 -0
- package/dist/utils/service/__tests__/eventPromoterUtils.test.d.ts.map +1 -0
- package/dist/utils/service/__tests__/eventPromoterUtils.test.js +333 -0
- package/dist/utils/service/__tests__/eventPromoterUtils.test.js.map +1 -0
- package/dist/utils/service/__tests__/offerAllowListUtils.test.d.ts +2 -0
- package/dist/utils/service/__tests__/offerAllowListUtils.test.d.ts.map +1 -0
- package/dist/utils/service/__tests__/offerAllowListUtils.test.js +52 -0
- package/dist/utils/service/__tests__/offerAllowListUtils.test.js.map +1 -0
- package/dist/utils/service/__tests__/offerComparisonUtils.test.d.ts +2 -0
- package/dist/utils/service/__tests__/offerComparisonUtils.test.d.ts.map +1 -0
- package/dist/utils/service/__tests__/offerComparisonUtils.test.js +111 -0
- package/dist/utils/service/__tests__/offerComparisonUtils.test.js.map +1 -0
- package/dist/utils/service/__tests__/serviceBookingExposureUtils.test.d.ts +2 -0
- package/dist/utils/service/__tests__/serviceBookingExposureUtils.test.d.ts.map +1 -0
- package/dist/utils/service/__tests__/serviceBookingExposureUtils.test.js +155 -0
- package/dist/utils/service/__tests__/serviceBookingExposureUtils.test.js.map +1 -0
- package/dist/utils/service/__tests__/unifiedPricingResolver.test.d.ts +2 -0
- package/dist/utils/service/__tests__/unifiedPricingResolver.test.d.ts.map +1 -0
- package/dist/utils/service/__tests__/unifiedPricingResolver.test.js +77 -0
- package/dist/utils/service/__tests__/unifiedPricingResolver.test.js.map +1 -0
- package/dist/utils/service/__tests__/venueFBSettlementUtils.test.d.ts +2 -0
- package/dist/utils/service/__tests__/venueFBSettlementUtils.test.d.ts.map +1 -0
- package/dist/utils/service/__tests__/venueFBSettlementUtils.test.js +157 -0
- package/dist/utils/service/__tests__/venueFBSettlementUtils.test.js.map +1 -0
- package/dist/utils/service/__tests__/venueSettlementUtils.test.d.ts +2 -0
- package/dist/utils/service/__tests__/venueSettlementUtils.test.d.ts.map +1 -0
- package/dist/utils/service/__tests__/venueSettlementUtils.test.js +175 -0
- package/dist/utils/service/__tests__/venueSettlementUtils.test.js.map +1 -0
- package/dist/utils/service/__tests__/volunteerCompensationUtils.test.d.ts +2 -0
- package/dist/utils/service/__tests__/volunteerCompensationUtils.test.d.ts.map +1 -0
- package/dist/utils/service/__tests__/volunteerCompensationUtils.test.js +118 -0
- package/dist/utils/service/__tests__/volunteerCompensationUtils.test.js.map +1 -0
- package/dist/utils/service/apiServiceBookingApiUtils.d.ts.map +1 -1
- package/dist/utils/service/apiServiceBookingApiUtils.js +4 -0
- package/dist/utils/service/apiServiceBookingApiUtils.js.map +1 -1
- package/dist/utils/service/billingCapUtils.d.ts +86 -0
- package/dist/utils/service/billingCapUtils.d.ts.map +1 -0
- package/dist/utils/service/billingCapUtils.js +96 -0
- package/dist/utils/service/billingCapUtils.js.map +1 -0
- package/dist/utils/service/cancellationPolicyRefundResolver.d.ts +1 -1
- package/dist/utils/service/cancellationPolicyRefundResolver.d.ts.map +1 -1
- package/dist/utils/service/cancellationPolicyRefundResolver.js +1 -1
- package/dist/utils/service/cancellationPolicyRefundResolver.js.map +1 -1
- package/dist/utils/service/eventPromoterUtils.d.ts +319 -0
- package/dist/utils/service/eventPromoterUtils.d.ts.map +1 -0
- package/dist/utils/service/eventPromoterUtils.js +372 -0
- package/dist/utils/service/eventPromoterUtils.js.map +1 -0
- package/dist/utils/service/offerAllowListUtils.d.ts +42 -0
- package/dist/utils/service/offerAllowListUtils.d.ts.map +1 -0
- package/dist/utils/service/offerAllowListUtils.js +49 -0
- package/dist/utils/service/offerAllowListUtils.js.map +1 -0
- package/dist/utils/service/offerComparisonUtils.d.ts +69 -0
- package/dist/utils/service/offerComparisonUtils.d.ts.map +1 -0
- package/dist/utils/service/offerComparisonUtils.js +110 -0
- package/dist/utils/service/offerComparisonUtils.js.map +1 -0
- package/dist/utils/service/serviceBookingExposureUtils.d.ts +111 -0
- package/dist/utils/service/serviceBookingExposureUtils.d.ts.map +1 -0
- package/dist/utils/service/serviceBookingExposureUtils.js +133 -0
- package/dist/utils/service/serviceBookingExposureUtils.js.map +1 -0
- package/dist/utils/service/serviceBookingTypes.d.ts +1 -1
- package/dist/utils/service/serviceBookingTypes.d.ts.map +1 -1
- package/dist/utils/service/serviceUtils.d.ts +2 -1
- package/dist/utils/service/serviceUtils.d.ts.map +1 -1
- package/dist/utils/service/serviceUtils.js +17 -16
- package/dist/utils/service/serviceUtils.js.map +1 -1
- package/dist/utils/service/unifiedPricingResolver.d.ts +67 -0
- package/dist/utils/service/unifiedPricingResolver.d.ts.map +1 -0
- package/dist/utils/service/unifiedPricingResolver.js +75 -0
- package/dist/utils/service/unifiedPricingResolver.js.map +1 -0
- package/dist/utils/service/venueFBSettlementUtils.d.ts +122 -0
- package/dist/utils/service/venueFBSettlementUtils.d.ts.map +1 -0
- package/dist/utils/service/venueFBSettlementUtils.js +106 -0
- package/dist/utils/service/venueFBSettlementUtils.js.map +1 -0
- package/dist/utils/service/venueSettlementUtils.d.ts +107 -0
- package/dist/utils/service/venueSettlementUtils.d.ts.map +1 -0
- package/dist/utils/service/venueSettlementUtils.js +117 -0
- package/dist/utils/service/venueSettlementUtils.js.map +1 -0
- package/dist/utils/service/volunteerCompensationUtils.d.ts +75 -0
- package/dist/utils/service/volunteerCompensationUtils.d.ts.map +1 -0
- package/dist/utils/service/volunteerCompensationUtils.js +88 -0
- package/dist/utils/service/volunteerCompensationUtils.js.map +1 -0
- package/dist/utils/venueLoyalty/__tests__/eligibilityUtils.test.d.ts +2 -0
- package/dist/utils/venueLoyalty/__tests__/eligibilityUtils.test.d.ts.map +1 -0
- package/dist/utils/venueLoyalty/__tests__/eligibilityUtils.test.js +67 -0
- package/dist/utils/venueLoyalty/__tests__/eligibilityUtils.test.js.map +1 -0
- package/dist/utils/venueLoyalty/__tests__/redemptionCodeUtils.test.d.ts +2 -0
- package/dist/utils/venueLoyalty/__tests__/redemptionCodeUtils.test.d.ts.map +1 -0
- package/dist/utils/venueLoyalty/__tests__/redemptionCodeUtils.test.js +71 -0
- package/dist/utils/venueLoyalty/__tests__/redemptionCodeUtils.test.js.map +1 -0
- package/dist/utils/venueLoyalty/eligibilityUtils.d.ts +33 -0
- package/dist/utils/venueLoyalty/eligibilityUtils.d.ts.map +1 -0
- package/dist/utils/venueLoyalty/eligibilityUtils.js +49 -0
- package/dist/utils/venueLoyalty/eligibilityUtils.js.map +1 -0
- package/dist/utils/venueLoyalty/redemptionCodeUtils.d.ts +35 -0
- package/dist/utils/venueLoyalty/redemptionCodeUtils.d.ts.map +1 -0
- package/dist/utils/venueLoyalty/redemptionCodeUtils.js +74 -0
- package/dist/utils/venueLoyalty/redemptionCodeUtils.js.map +1 -0
- package/dist/venueLoyaltyRedemption.d.ts +146 -1
- package/dist/venueLoyaltyRedemption.d.ts.map +1 -1
- package/dist/venueLoyaltyRedemption.js +18 -2
- package/dist/venueLoyaltyRedemption.js.map +1 -1
- package/package.json +1 -1
- package/prisma/schema.prisma +609 -214
- package/src/__tests__/statusEnums.test.ts +119 -11
- package/src/definitions.ts +57 -14
- package/src/extendedSchemas.ts +6 -0
- package/src/index.ts +16 -6
- package/src/mirroredPrismaEnums.ts +181 -0
- package/src/utils/__tests__/nonprofitUtils.test.ts +117 -0
- package/src/utils/__tests__/paymentUtils.test.ts +93 -0
- package/src/utils/nonprofitUtils.ts +44 -0
- package/src/utils/paymentUtils.ts +33 -7
- package/src/utils/service/__tests__/billingCapUtils.test.ts +175 -0
- package/src/utils/service/__tests__/cancellationPolicyRefundResolver.test.ts +1 -1
- package/src/utils/service/__tests__/eventPromoterPhase3.test.ts +296 -0
- package/src/utils/service/__tests__/eventPromoterUtils.test.ts +396 -0
- package/src/utils/service/__tests__/offerAllowListUtils.test.ts +76 -0
- package/src/utils/service/__tests__/offerComparisonUtils.test.ts +124 -0
- package/src/utils/service/__tests__/serviceBookingExposureUtils.test.ts +171 -0
- package/src/utils/service/__tests__/unifiedPricingResolver.test.ts +83 -0
- package/src/utils/service/__tests__/venueSettlementUtils.test.ts +219 -0
- package/src/utils/service/__tests__/volunteerCompensationUtils.test.ts +172 -0
- package/src/utils/service/apiServiceBookingApiUtils.ts +4 -0
- package/src/utils/service/billingCapUtils.ts +145 -0
- package/src/utils/service/cancellationPolicyRefundResolver.ts +1 -1
- package/src/utils/service/eventPromoterUtils.ts +644 -0
- package/src/utils/service/offerAllowListUtils.ts +81 -0
- package/src/utils/service/offerComparisonUtils.ts +197 -0
- package/src/utils/service/serviceBookingExposureUtils.ts +218 -0
- package/src/utils/service/serviceBookingTypes.ts +4 -0
- package/src/utils/service/serviceUtils.ts +18 -21
- package/src/utils/service/unifiedPricingResolver.ts +149 -0
- package/src/utils/service/venueSettlementUtils.ts +198 -0
- package/src/utils/service/volunteerCompensationUtils.ts +145 -0
- package/src/utils/venueLoyalty/__tests__/eligibilityUtils.test.ts +103 -0
- package/src/utils/venueLoyalty/__tests__/redemptionCodeUtils.test.ts +92 -0
- package/src/utils/venueLoyalty/eligibilityUtils.ts +84 -0
- package/src/utils/venueLoyalty/redemptionCodeUtils.ts +94 -0
- package/src/venueLoyaltyRedemption.ts +177 -2
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 6 of the unified-pricing roadmap — forward-looking dual-read.
|
|
3
|
+
*
|
|
4
|
+
* Reads the unified pricing block from whichever subtype owns it (EventService,
|
|
5
|
+
* Vendor, Exhibitor, Sponsor, Venue, EntertainmentService, VolunteerService),
|
|
6
|
+
* then falls back to the legacy `ServiceRate` row pinned on the service if the
|
|
7
|
+
* unified block is null. Plain object passing only — no Prisma client calls,
|
|
8
|
+
* no fetches, no I/O.
|
|
9
|
+
*
|
|
10
|
+
* Use this from the booking + checkout paths so existing data keeps working
|
|
11
|
+
* while new wizard saves only write the unified block. When zero reads hit
|
|
12
|
+
* the fallback path, callers can drop `ServiceRate` + `ServiceRatesAssociation`
|
|
13
|
+
* entirely (the actual table drop is a follow-up migration).
|
|
14
|
+
*/
|
|
15
|
+
import type { PricingModel } from "@prisma/client";
|
|
16
|
+
|
|
17
|
+
export interface UnifiedPricingBlock {
|
|
18
|
+
pricingModel: PricingModel | null;
|
|
19
|
+
baseRateCents: number | null;
|
|
20
|
+
baseRateBashPoints: number | null;
|
|
21
|
+
rateUnit: string | null;
|
|
22
|
+
minimumChargeCents: number | null;
|
|
23
|
+
minimumQuantity: number | null;
|
|
24
|
+
/** Json — keep `unknown` so callers narrow at use sites. */
|
|
25
|
+
tierPricing: unknown;
|
|
26
|
+
pricingNotes: string | null;
|
|
27
|
+
/** True iff this came from a unified block (vs the legacy ServiceRate). */
|
|
28
|
+
fromUnifiedBlock: boolean;
|
|
29
|
+
/** True iff `baseRateCents` was zero/null/missing in both paths. */
|
|
30
|
+
noRateConfigured: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface UnifiedPricingSourceCandidates {
|
|
34
|
+
/** Subtype rows on the parent Service. Pass whichever ones the include surfaced. */
|
|
35
|
+
eventService?: PartialPricingBlock | null;
|
|
36
|
+
vendor?: PartialPricingBlock | null;
|
|
37
|
+
exhibitor?: PartialPricingBlock | null;
|
|
38
|
+
sponsor?: PartialPricingBlock | null;
|
|
39
|
+
venue?: PartialPricingBlock | null;
|
|
40
|
+
entertainmentService?: PartialPricingBlock | null;
|
|
41
|
+
volunteerService?: PartialPricingBlock | null;
|
|
42
|
+
/** Legacy fallback — the active `ServiceRate` row. */
|
|
43
|
+
legacyRate?: LegacyServiceRateShape | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type PartialPricingBlock = {
|
|
47
|
+
pricingModel?: PricingModel | null;
|
|
48
|
+
baseRateCents?: number | null;
|
|
49
|
+
baseRateBashPoints?: number | null;
|
|
50
|
+
rateUnit?: string | null;
|
|
51
|
+
minimumChargeCents?: number | null;
|
|
52
|
+
minimumQuantity?: number | null;
|
|
53
|
+
tierPricing?: unknown;
|
|
54
|
+
pricingNotes?: string | null;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
type LegacyServiceRateShape = {
|
|
58
|
+
/** Legacy doesn't carry a pricing model — caller can default to FLAT_FEE. */
|
|
59
|
+
ratePerUnitCents?: number | null;
|
|
60
|
+
rateUnit?: string | null;
|
|
61
|
+
minimumQuantity?: number | null;
|
|
62
|
+
minimumChargeCents?: number | null;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const EMPTY: UnifiedPricingBlock = {
|
|
66
|
+
pricingModel: null,
|
|
67
|
+
baseRateCents: null,
|
|
68
|
+
baseRateBashPoints: null,
|
|
69
|
+
rateUnit: null,
|
|
70
|
+
minimumChargeCents: null,
|
|
71
|
+
minimumQuantity: null,
|
|
72
|
+
tierPricing: null,
|
|
73
|
+
pricingNotes: null,
|
|
74
|
+
fromUnifiedBlock: false,
|
|
75
|
+
noRateConfigured: true,
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
function hasUnifiedSignal(b: PartialPricingBlock | null | undefined): boolean {
|
|
79
|
+
if (!b) return false;
|
|
80
|
+
return (
|
|
81
|
+
b.pricingModel != null ||
|
|
82
|
+
b.baseRateCents != null ||
|
|
83
|
+
b.baseRateBashPoints != null ||
|
|
84
|
+
b.minimumChargeCents != null ||
|
|
85
|
+
b.minimumQuantity != null
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Walk the candidate subtypes in order, returning the first one that has
|
|
91
|
+
* unified pricing populated. Falls back to legacy `ServiceRate` if nothing
|
|
92
|
+
* has been migrated yet.
|
|
93
|
+
*/
|
|
94
|
+
export function resolveUnifiedPricingBlock(
|
|
95
|
+
c: UnifiedPricingSourceCandidates
|
|
96
|
+
): UnifiedPricingBlock {
|
|
97
|
+
const candidates: Array<PartialPricingBlock | null | undefined> = [
|
|
98
|
+
c.eventService,
|
|
99
|
+
c.vendor,
|
|
100
|
+
c.exhibitor,
|
|
101
|
+
c.sponsor,
|
|
102
|
+
c.venue,
|
|
103
|
+
c.entertainmentService,
|
|
104
|
+
c.volunteerService,
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
for (const cand of candidates) {
|
|
108
|
+
if (hasUnifiedSignal(cand)) {
|
|
109
|
+
const b = cand!;
|
|
110
|
+
const baseRateCents = b.baseRateCents ?? null;
|
|
111
|
+
return {
|
|
112
|
+
pricingModel: b.pricingModel ?? null,
|
|
113
|
+
baseRateCents,
|
|
114
|
+
baseRateBashPoints: b.baseRateBashPoints ?? null,
|
|
115
|
+
rateUnit: b.rateUnit ?? null,
|
|
116
|
+
minimumChargeCents: b.minimumChargeCents ?? null,
|
|
117
|
+
minimumQuantity: b.minimumQuantity ?? null,
|
|
118
|
+
tierPricing: b.tierPricing ?? null,
|
|
119
|
+
pricingNotes: b.pricingNotes ?? null,
|
|
120
|
+
fromUnifiedBlock: true,
|
|
121
|
+
noRateConfigured:
|
|
122
|
+
baseRateCents == null && (b.baseRateBashPoints ?? null) == null,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const legacy = c.legacyRate;
|
|
128
|
+
if (
|
|
129
|
+
legacy &&
|
|
130
|
+
(legacy.ratePerUnitCents != null ||
|
|
131
|
+
legacy.minimumQuantity != null ||
|
|
132
|
+
legacy.minimumChargeCents != null)
|
|
133
|
+
) {
|
|
134
|
+
return {
|
|
135
|
+
pricingModel: "FLAT_FEE" as PricingModel,
|
|
136
|
+
baseRateCents: legacy.ratePerUnitCents ?? null,
|
|
137
|
+
baseRateBashPoints: null,
|
|
138
|
+
rateUnit: legacy.rateUnit ?? null,
|
|
139
|
+
minimumChargeCents: legacy.minimumChargeCents ?? null,
|
|
140
|
+
minimumQuantity: legacy.minimumQuantity ?? null,
|
|
141
|
+
tierPricing: null,
|
|
142
|
+
pricingNotes: null,
|
|
143
|
+
fromUnifiedBlock: false,
|
|
144
|
+
noRateConfigured: legacy.ratePerUnitCents == null,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return EMPTY;
|
|
149
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 2 of the unified-pricing roadmap — pure math for Venue F&B minimums
|
|
3
|
+
* and revenue share. Pure functions only (no Prisma / no fetches) so the
|
|
4
|
+
* wizard, api validators, settlement worker, and offer-suggestion AI can all
|
|
5
|
+
* share one source of truth.
|
|
6
|
+
*
|
|
7
|
+
* See `docs/help-center/pricing-models/` and the plan at
|
|
8
|
+
* `unified-pricing-roadmap_21d456c6.plan.md` for the policy story.
|
|
9
|
+
*
|
|
10
|
+
* Amounts are always integer cents.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Days of week used in `Venue.fbMinimumSchedule`. Locale-agnostic short keys. */
|
|
14
|
+
export const FB_MINIMUM_SCHEDULE_DAYS = [
|
|
15
|
+
"Sun",
|
|
16
|
+
"Mon",
|
|
17
|
+
"Tue",
|
|
18
|
+
"Wed",
|
|
19
|
+
"Thu",
|
|
20
|
+
"Fri",
|
|
21
|
+
"Sat",
|
|
22
|
+
] as const;
|
|
23
|
+
export type FBMinimumScheduleDay = (typeof FB_MINIMUM_SCHEDULE_DAYS)[number];
|
|
24
|
+
|
|
25
|
+
/** Shape of the JSON stored at `Venue.fbMinimumSchedule`. */
|
|
26
|
+
export type FBMinimumSchedule = Partial<Record<FBMinimumScheduleDay, number>>;
|
|
27
|
+
|
|
28
|
+
/** Allowed values for `Venue.revenueShareBasis` (kept as plain strings since
|
|
29
|
+
* the column is `String?` rather than an enum). */
|
|
30
|
+
export const REVENUE_SHARE_BASES = [
|
|
31
|
+
"FB_SALES",
|
|
32
|
+
"TICKET_REVENUE",
|
|
33
|
+
"TOTAL_GROSS",
|
|
34
|
+
] as const;
|
|
35
|
+
export type RevenueShareBasis = (typeof REVENUE_SHARE_BASES)[number];
|
|
36
|
+
|
|
37
|
+
/** Internal helper — cents → non-negative rounded integer. */
|
|
38
|
+
function nonNegInt(value: number): number {
|
|
39
|
+
if (!Number.isFinite(value) || value <= 0) return 0;
|
|
40
|
+
return Math.round(value);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ---------- F&B minimum: resolution + shortfall math ----------
|
|
44
|
+
|
|
45
|
+
export interface ResolveFBMinimumInput {
|
|
46
|
+
/** Per-day-of-week schedule from `Venue.fbMinimumSchedule`. */
|
|
47
|
+
schedule: FBMinimumSchedule | null | undefined;
|
|
48
|
+
/** Optional date-windowed overrides from `VenueFBMinimumOverride`. */
|
|
49
|
+
overrides?: ReadonlyArray<{
|
|
50
|
+
startDate: Date;
|
|
51
|
+
endDate: Date;
|
|
52
|
+
minimumCents: number;
|
|
53
|
+
}>;
|
|
54
|
+
/** Bash start as it should be evaluated for day-of-week / override match. */
|
|
55
|
+
eventStartDateTime: Date;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Resolve the effective F&B minimum (in cents) for a given bash date.
|
|
60
|
+
*
|
|
61
|
+
* Override precedence: any matching window in `overrides` (inclusive endpoints)
|
|
62
|
+
* wins; otherwise the schedule entry for the day-of-week of `eventStartDateTime`
|
|
63
|
+
* is used. Returns `null` if neither produces a value.
|
|
64
|
+
*
|
|
65
|
+
* Callers that need timezone-aware day-of-week selection should pre-shift the
|
|
66
|
+
* Date before passing it in — this function uses `Date.getDay()` directly.
|
|
67
|
+
*/
|
|
68
|
+
export function resolveFBMinimumForDate(
|
|
69
|
+
input: ResolveFBMinimumInput
|
|
70
|
+
): number | null {
|
|
71
|
+
if (input.overrides && input.overrides.length > 0) {
|
|
72
|
+
const ms = input.eventStartDateTime.getTime();
|
|
73
|
+
for (const o of input.overrides) {
|
|
74
|
+
if (o.startDate.getTime() <= ms && ms <= o.endDate.getTime()) {
|
|
75
|
+
return Math.max(0, Math.floor(o.minimumCents));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const schedule = input.schedule ?? null;
|
|
80
|
+
if (!schedule) return null;
|
|
81
|
+
const dayIdx = input.eventStartDateTime.getDay();
|
|
82
|
+
const dayKey = FB_MINIMUM_SCHEDULE_DAYS[dayIdx];
|
|
83
|
+
const value = schedule[dayKey];
|
|
84
|
+
if (value == null || !Number.isFinite(value)) return null;
|
|
85
|
+
return Math.max(0, Math.floor(value));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface FBShortfallInput {
|
|
89
|
+
/** Effective minimum (resolved via `resolveFBMinimumForDate`). */
|
|
90
|
+
fbMinimumCents: number;
|
|
91
|
+
/** Final food+bev sticker price from the venue invoice. */
|
|
92
|
+
fbActualSpendCents: number;
|
|
93
|
+
/** Service-charge cents the venue charged on top of the F&B subtotal. */
|
|
94
|
+
serviceChargeCents?: number | null;
|
|
95
|
+
/** Tax cents the venue charged on top of subtotal+service. */
|
|
96
|
+
taxCents?: number | null;
|
|
97
|
+
/** Snapshot of `Venue.fbServiceChargeCountsTowardMinimum`. */
|
|
98
|
+
serviceChargeCountsTowardMinimum: boolean;
|
|
99
|
+
/** Snapshot of `Venue.fbMinimumIncludesTax`. */
|
|
100
|
+
includesTax: boolean;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface FBShortfallResult {
|
|
104
|
+
/** Spend credited toward the minimum after svc-charge/tax flags. */
|
|
105
|
+
coveredSpendCents: number;
|
|
106
|
+
/** max(0, minimum - covered). */
|
|
107
|
+
shortfallCents: number;
|
|
108
|
+
/** max(0, covered - minimum). Useful for carryover-credit issuance. */
|
|
109
|
+
excessCents: number;
|
|
110
|
+
/** True if `shortfallCents === 0 && excessCents === 0`. */
|
|
111
|
+
metExactly: boolean;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Compute how much of the bash's F&B invoice counts toward the venue's
|
|
116
|
+
* minimum and the resulting shortfall (or excess) at settlement.
|
|
117
|
+
*
|
|
118
|
+
* Rules:
|
|
119
|
+
* - Subtotal (`fbActualSpendCents`) ALWAYS counts toward the minimum.
|
|
120
|
+
* - Service charge counts iff `serviceChargeCountsTowardMinimum`.
|
|
121
|
+
* - Tax counts iff `includesTax`.
|
|
122
|
+
*
|
|
123
|
+
* All cents are integers and always non-negative.
|
|
124
|
+
*/
|
|
125
|
+
export function computeFBMinimumShortfallCents(
|
|
126
|
+
input: FBShortfallInput
|
|
127
|
+
): FBShortfallResult {
|
|
128
|
+
const subtotal = nonNegInt(input.fbActualSpendCents);
|
|
129
|
+
const svc = nonNegInt(input.serviceChargeCents ?? 0);
|
|
130
|
+
const tax = nonNegInt(input.taxCents ?? 0);
|
|
131
|
+
const coveredSpendCents =
|
|
132
|
+
subtotal +
|
|
133
|
+
(input.serviceChargeCountsTowardMinimum ? svc : 0) +
|
|
134
|
+
(input.includesTax ? tax : 0);
|
|
135
|
+
const minimum = nonNegInt(input.fbMinimumCents);
|
|
136
|
+
const shortfallCents = Math.max(0, minimum - coveredSpendCents);
|
|
137
|
+
const excessCents = Math.max(0, coveredSpendCents - minimum);
|
|
138
|
+
return {
|
|
139
|
+
coveredSpendCents,
|
|
140
|
+
shortfallCents,
|
|
141
|
+
excessCents,
|
|
142
|
+
metExactly: shortfallCents === 0 && excessCents === 0,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ---------- Revenue share ----------
|
|
147
|
+
|
|
148
|
+
export interface RevenueShareInput {
|
|
149
|
+
/** The basis amount in cents (ticket revenue, F&B sales, etc.). */
|
|
150
|
+
basisCents: number;
|
|
151
|
+
/** Host percentage (0..100). Venue cut = basisCents * (100 - hostPct) / 100. */
|
|
152
|
+
hostPct: number;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export interface RevenueShareResult {
|
|
156
|
+
/** Host's share in cents. */
|
|
157
|
+
hostCents: number;
|
|
158
|
+
/** Venue's share in cents. Equals basis - host. */
|
|
159
|
+
venueCents: number;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Compute the revenue-share split given a basis and host percentage.
|
|
164
|
+
* Returns integer cents. Out-of-range hostPct is clamped to [0, 100].
|
|
165
|
+
*/
|
|
166
|
+
export function computeRevenueShareCents(
|
|
167
|
+
input: RevenueShareInput
|
|
168
|
+
): RevenueShareResult {
|
|
169
|
+
const basis = nonNegInt(input.basisCents);
|
|
170
|
+
const hostPct = Math.max(0, Math.min(100, input.hostPct));
|
|
171
|
+
const hostCents = nonNegInt((basis * hostPct) / 100);
|
|
172
|
+
const venueCents = Math.max(0, basis - hostCents);
|
|
173
|
+
return { hostCents, venueCents };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---------- Cancellation copy ----------
|
|
177
|
+
|
|
178
|
+
/** Surfaced on offer sheets + booking detail when an F&B minimum is attached. */
|
|
179
|
+
export const FB_MINIMUM_CANCELLATION_COPY =
|
|
180
|
+
"If the bash is canceled within the venue's cancellation window, the host may still owe the F&B minimum shortfall. See the venue's cancellation policy for the exact timing.";
|
|
181
|
+
|
|
182
|
+
/** Surfaced when a venue booking has revenue share configured. */
|
|
183
|
+
export const REVENUE_SHARE_CANCELLATION_COPY =
|
|
184
|
+
"If the bash is canceled before tickets sell, no revenue-share payment is owed. See the venue's cancellation policy for late-cancel rules.";
|
|
185
|
+
|
|
186
|
+
/** Default lifetime for a `VenueFBCarryoverCredit`, in days (12 months). */
|
|
187
|
+
export const DEFAULT_FB_CARRYOVER_EXPIRY_DAYS = 365;
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Compute the default expiry timestamp for a newly-issued carryover credit.
|
|
191
|
+
*/
|
|
192
|
+
export function defaultFBCarryoverExpiresAt(
|
|
193
|
+
issuedAt: Date = new Date(),
|
|
194
|
+
days: number = DEFAULT_FB_CARRYOVER_EXPIRY_DAYS
|
|
195
|
+
): Date {
|
|
196
|
+
const ms = issuedAt.getTime() + Math.max(1, Math.floor(days)) * 86_400_000;
|
|
197
|
+
return new Date(ms);
|
|
198
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Volunteer compensation — closed-loop BashPoints stipends + on-site perks.
|
|
3
|
+
*
|
|
4
|
+
* Volunteers are NOT employees or contractors. This util intentionally avoids
|
|
5
|
+
* any USD rate / wage / per-attendee math, because crossing that line would
|
|
6
|
+
* trigger worker-classification, minimum-wage, and 1099 reporting concerns.
|
|
7
|
+
*
|
|
8
|
+
* BashPoints stipends are safe because BP is closed-loop platform credit:
|
|
9
|
+
* - never cashed out,
|
|
10
|
+
* - never used to settle third-party USD invoices,
|
|
11
|
+
* - only redeemable against Bash-issued goods (tickets, BashPass upgrades,
|
|
12
|
+
* premium features) — see the closed-loop BP plan.
|
|
13
|
+
*
|
|
14
|
+
* Pure functions only so the wizard, api validators, and help-center page
|
|
15
|
+
* share one source of truth.
|
|
16
|
+
*/
|
|
17
|
+
import { VolunteerCompensationType } from "../../mirroredPrismaEnums.js";
|
|
18
|
+
|
|
19
|
+
/** Suggested starter BP stipend per shift. Hosts can override in the wizard. */
|
|
20
|
+
export const DEFAULT_VOLUNTEER_STIPEND_BP_PER_SHIFT = 100;
|
|
21
|
+
|
|
22
|
+
/** Suggested starter BP stipend per hour. */
|
|
23
|
+
export const DEFAULT_VOLUNTEER_STIPEND_BP_PER_HOUR = 25;
|
|
24
|
+
|
|
25
|
+
/** Allowed values for `VolunteerService.rateUnit`. */
|
|
26
|
+
export const VOLUNTEER_RATE_UNITS = ["shift", "hour"] as const;
|
|
27
|
+
export type VolunteerRateUnit = (typeof VOLUNTEER_RATE_UNITS)[number];
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Returns the suggested starter BP stipend for a volunteer profile based on
|
|
31
|
+
* how the host wants to denominate it. Wizard pre-fills `bashPointsStipend`
|
|
32
|
+
* with this; host can override.
|
|
33
|
+
*/
|
|
34
|
+
export function defaultStipendBpFor(args: {
|
|
35
|
+
rateUnit: VolunteerRateUnit | string | null | undefined;
|
|
36
|
+
}): number {
|
|
37
|
+
if (args.rateUnit === "hour") {
|
|
38
|
+
return DEFAULT_VOLUNTEER_STIPEND_BP_PER_HOUR;
|
|
39
|
+
}
|
|
40
|
+
return DEFAULT_VOLUNTEER_STIPEND_BP_PER_SHIFT;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Returns true if the given compensation type pays any BashPoints. Used by
|
|
45
|
+
* the booking detail page + settlement worker to decide whether to surface
|
|
46
|
+
* BP-stipend fields.
|
|
47
|
+
*/
|
|
48
|
+
export function compensationTypePaysBashPoints(
|
|
49
|
+
t: VolunteerCompensationType | string | null | undefined
|
|
50
|
+
): boolean {
|
|
51
|
+
return t === "BashPointsStipend" || t === "BashPointsAndPerks";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Returns true if the given compensation type includes on-site perks.
|
|
56
|
+
*/
|
|
57
|
+
export function compensationTypeIncludesPerks(
|
|
58
|
+
t: VolunteerCompensationType | string | null | undefined
|
|
59
|
+
): boolean {
|
|
60
|
+
return t === "PerksOnly" || t === "BashPointsAndPerks";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Validates a saved volunteer compensation block. Returns a friendly error
|
|
65
|
+
* when the host's choices contradict (e.g. `PerksOnly` with a BP stipend
|
|
66
|
+
* filled in, or `BashPointsStipend` with no BP amount).
|
|
67
|
+
*/
|
|
68
|
+
export type VolunteerCompensationValidation =
|
|
69
|
+
| { ok: true }
|
|
70
|
+
| { ok: false; reason: string };
|
|
71
|
+
|
|
72
|
+
export function validateVolunteerCompensation(args: {
|
|
73
|
+
compensationType: VolunteerCompensationType | string;
|
|
74
|
+
bashPointsStipend?: number | null;
|
|
75
|
+
rateUnit?: string | null;
|
|
76
|
+
perksDescription?: string | null;
|
|
77
|
+
}): VolunteerCompensationValidation {
|
|
78
|
+
const paysBp = compensationTypePaysBashPoints(args.compensationType);
|
|
79
|
+
const hasPerks = compensationTypeIncludesPerks(args.compensationType);
|
|
80
|
+
|
|
81
|
+
if (paysBp) {
|
|
82
|
+
if (args.bashPointsStipend == null || args.bashPointsStipend <= 0) {
|
|
83
|
+
return {
|
|
84
|
+
ok: false,
|
|
85
|
+
reason: "BashPoints stipend amount required when compensation type pays BashPoints.",
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
if (!args.rateUnit) {
|
|
89
|
+
return {
|
|
90
|
+
ok: false,
|
|
91
|
+
reason: "Rate unit (shift or hour) required when paying a BashPoints stipend.",
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (!paysBp && args.bashPointsStipend != null && args.bashPointsStipend > 0) {
|
|
96
|
+
return {
|
|
97
|
+
ok: false,
|
|
98
|
+
reason: "BashPoints stipend set but compensation type doesn't pay BashPoints.",
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
if (hasPerks && !args.perksDescription) {
|
|
102
|
+
return {
|
|
103
|
+
ok: false,
|
|
104
|
+
reason: "Describe the perks volunteers will receive.",
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
return { ok: true };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Plain-English label for the booking detail page + booking thread system
|
|
112
|
+
* messages. Stable across renders.
|
|
113
|
+
*/
|
|
114
|
+
export function volunteerCompensationSummary(args: {
|
|
115
|
+
compensationType: VolunteerCompensationType | string;
|
|
116
|
+
bashPointsStipend?: number | null;
|
|
117
|
+
rateUnit?: string | null;
|
|
118
|
+
perksDescription?: string | null;
|
|
119
|
+
}): string {
|
|
120
|
+
switch (args.compensationType) {
|
|
121
|
+
case "None":
|
|
122
|
+
return "Pure volunteer — no stipend or perks beyond appreciation.";
|
|
123
|
+
case "BashPointsStipend":
|
|
124
|
+
return `${args.bashPointsStipend ?? 0} BashPoints per ${args.rateUnit ?? "shift"}.`;
|
|
125
|
+
case "PerksOnly":
|
|
126
|
+
return args.perksDescription
|
|
127
|
+
? `Perks only: ${args.perksDescription}`
|
|
128
|
+
: "Perks only.";
|
|
129
|
+
case "BashPointsAndPerks":
|
|
130
|
+
return `${args.bashPointsStipend ?? 0} BashPoints per ${
|
|
131
|
+
args.rateUnit ?? "shift"
|
|
132
|
+
} + perks${
|
|
133
|
+
args.perksDescription ? ` (${args.perksDescription})` : ""
|
|
134
|
+
}.`;
|
|
135
|
+
default:
|
|
136
|
+
return "Compensation unspecified.";
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Disclaimer copy for the volunteer wizard step + help-center page + booking
|
|
142
|
+
* detail. Kept here so wizard, api, and docs cite the exact same wording.
|
|
143
|
+
*/
|
|
144
|
+
export const VOLUNTEER_COMPENSATION_DISCLAIMER =
|
|
145
|
+
"Volunteers are not employees or contractors. BashPoints stipends are platform credit redeemable on Bash, not wages, and don't count toward employment or 1099 reporting.";
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure eligibility evaluator — same logic runs on API + bash-app, so this
|
|
3
|
+
* suite locks the truth table for both call sites.
|
|
4
|
+
*/
|
|
5
|
+
import {
|
|
6
|
+
evaluateEligibility,
|
|
7
|
+
evaluateOffersEligibility,
|
|
8
|
+
} from "../eligibilityUtils.js";
|
|
9
|
+
|
|
10
|
+
const baseMetrics = { ticketedEventsAttended: 2, bookingsAsHost: 0 };
|
|
11
|
+
|
|
12
|
+
describe("evaluateEligibility", () => {
|
|
13
|
+
test("offer with no gates and no cooldown is eligible", () => {
|
|
14
|
+
const result = evaluateEligibility(baseMetrics, {
|
|
15
|
+
id: "o1",
|
|
16
|
+
minTicketedEventsAttended: null,
|
|
17
|
+
minBookingsAsHost: null,
|
|
18
|
+
});
|
|
19
|
+
expect(result.eligible).toBe(true);
|
|
20
|
+
expect(result.missing.ticketedEventsShort).toBeNull();
|
|
21
|
+
expect(result.missing.bookingsAsHostShort).toBeNull();
|
|
22
|
+
expect(result.onCooldownUntil).toBeNull();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("guest meets ticketed gate exactly is eligible", () => {
|
|
26
|
+
const result = evaluateEligibility(
|
|
27
|
+
{ ticketedEventsAttended: 3, bookingsAsHost: 0 },
|
|
28
|
+
{ id: "o2", minTicketedEventsAttended: 3, minBookingsAsHost: null },
|
|
29
|
+
);
|
|
30
|
+
expect(result.eligible).toBe(true);
|
|
31
|
+
expect(result.missing.ticketedEventsShort).toBeNull();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("guest one short on ticketed gate surfaces shortfall + locked state", () => {
|
|
35
|
+
const result = evaluateEligibility(
|
|
36
|
+
{ ticketedEventsAttended: 2, bookingsAsHost: 0 },
|
|
37
|
+
{ id: "o3", minTicketedEventsAttended: 3, minBookingsAsHost: null },
|
|
38
|
+
);
|
|
39
|
+
expect(result.eligible).toBe(false);
|
|
40
|
+
expect(result.missing.ticketedEventsShort).toBe(1);
|
|
41
|
+
expect(result.missing.bookingsAsHostShort).toBeNull();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("guest short on both gates surfaces both shortfalls", () => {
|
|
45
|
+
const result = evaluateEligibility(
|
|
46
|
+
{ ticketedEventsAttended: 1, bookingsAsHost: 0 },
|
|
47
|
+
{ id: "o4", minTicketedEventsAttended: 4, minBookingsAsHost: 2 },
|
|
48
|
+
);
|
|
49
|
+
expect(result.eligible).toBe(false);
|
|
50
|
+
expect(result.missing.ticketedEventsShort).toBe(3);
|
|
51
|
+
expect(result.missing.bookingsAsHostShort).toBe(2);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("gates met but cooldown active surfaces onCooldownUntil + ineligible", () => {
|
|
55
|
+
const futureDate = new Date(Date.now() + 86400_000);
|
|
56
|
+
const result = evaluateEligibility(
|
|
57
|
+
{ ticketedEventsAttended: 5, bookingsAsHost: 5 },
|
|
58
|
+
{ id: "o5", minTicketedEventsAttended: 1, minBookingsAsHost: 1 },
|
|
59
|
+
futureDate,
|
|
60
|
+
);
|
|
61
|
+
expect(result.eligible).toBe(false);
|
|
62
|
+
expect(result.onCooldownUntil).toBe(futureDate.toISOString());
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("gates met and cooldown elapsed flips back to eligible", () => {
|
|
66
|
+
const pastDate = new Date(Date.now() - 86400_000);
|
|
67
|
+
const result = evaluateEligibility(
|
|
68
|
+
{ ticketedEventsAttended: 5, bookingsAsHost: 5 },
|
|
69
|
+
{ id: "o6", minTicketedEventsAttended: 1, minBookingsAsHost: 1 },
|
|
70
|
+
pastDate,
|
|
71
|
+
);
|
|
72
|
+
expect(result.eligible).toBe(true);
|
|
73
|
+
expect(result.onCooldownUntil).toBeNull();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("cooldown accepts ISO string input", () => {
|
|
77
|
+
const futureIso = new Date(Date.now() + 3600_000).toISOString();
|
|
78
|
+
const result = evaluateEligibility(
|
|
79
|
+
{ ticketedEventsAttended: 5, bookingsAsHost: 5 },
|
|
80
|
+
{ id: "o7", minTicketedEventsAttended: null, minBookingsAsHost: null },
|
|
81
|
+
futureIso,
|
|
82
|
+
);
|
|
83
|
+
expect(result.eligible).toBe(false);
|
|
84
|
+
expect(result.onCooldownUntil).toBe(futureIso);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("evaluateOffersEligibility", () => {
|
|
89
|
+
test("maps each offer with the right cooldown from the keyed map", () => {
|
|
90
|
+
const future = new Date(Date.now() + 86400_000).toISOString();
|
|
91
|
+
const result = evaluateOffersEligibility(
|
|
92
|
+
{ ticketedEventsAttended: 2, bookingsAsHost: 1 },
|
|
93
|
+
[
|
|
94
|
+
{ id: "a", minTicketedEventsAttended: null, minBookingsAsHost: null },
|
|
95
|
+
{ id: "b", minTicketedEventsAttended: 3, minBookingsAsHost: null },
|
|
96
|
+
{ id: "c", minTicketedEventsAttended: 1, minBookingsAsHost: 1 },
|
|
97
|
+
],
|
|
98
|
+
{ c: future },
|
|
99
|
+
);
|
|
100
|
+
expect(result.map((r) => r.eligible)).toEqual([true, false, false]);
|
|
101
|
+
expect(result[2].onCooldownUntil).toBe(future);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pin the redemption code alphabet, length, normalization, and uniqueness
|
|
3
|
+
* retry loop. Hosts type these codes from a phone — fat-finger characters
|
|
4
|
+
* (I, O, 0, 1) must never appear.
|
|
5
|
+
*/
|
|
6
|
+
import {
|
|
7
|
+
REDEMPTION_CODE_LENGTH,
|
|
8
|
+
__testInternals,
|
|
9
|
+
generateRedemptionCode,
|
|
10
|
+
generateUniqueRedemptionCode,
|
|
11
|
+
isValidRedemptionCodeShape,
|
|
12
|
+
normalizeRedemptionCode,
|
|
13
|
+
} from "../redemptionCodeUtils.js";
|
|
14
|
+
|
|
15
|
+
describe("redemption code shape", () => {
|
|
16
|
+
test("alphabet excludes ambiguous characters", () => {
|
|
17
|
+
const alphabet = __testInternals.REDEMPTION_CODE_ALPHABET;
|
|
18
|
+
expect(alphabet).not.toMatch(/[IO01]/);
|
|
19
|
+
expect(alphabet).toHaveLength(32);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("isValidRedemptionCodeShape accepts canonical codes", () => {
|
|
23
|
+
expect(isValidRedemptionCodeShape("ABCDEF23")).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("isValidRedemptionCodeShape rejects wrong length", () => {
|
|
27
|
+
expect(isValidRedemptionCodeShape("ABCDEF2")).toBe(false);
|
|
28
|
+
expect(isValidRedemptionCodeShape("ABCDEF234")).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("isValidRedemptionCodeShape rejects ambiguous chars", () => {
|
|
32
|
+
expect(isValidRedemptionCodeShape("ABCDEFI2")).toBe(false);
|
|
33
|
+
expect(isValidRedemptionCodeShape("ABCDEFO2")).toBe(false);
|
|
34
|
+
expect(isValidRedemptionCodeShape("ABCDEF02")).toBe(false);
|
|
35
|
+
expect(isValidRedemptionCodeShape("ABCDEF12")).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("isValidRedemptionCodeShape rejects lowercase", () => {
|
|
39
|
+
expect(isValidRedemptionCodeShape("abcdef23")).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("normalizeRedemptionCode", () => {
|
|
44
|
+
test("strips whitespace + dashes and uppercases", () => {
|
|
45
|
+
expect(normalizeRedemptionCode("ab12-cd34")).toBe("AB12CD34");
|
|
46
|
+
expect(normalizeRedemptionCode(" abcdef23 ")).toBe("ABCDEF23");
|
|
47
|
+
expect(normalizeRedemptionCode("a b\tc-d\n2 3 4 5")).toBe("ABCD2345");
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("generateRedemptionCode", () => {
|
|
52
|
+
test("produces 8-char codes from the alphabet", () => {
|
|
53
|
+
const code = generateRedemptionCode();
|
|
54
|
+
expect(code).toHaveLength(REDEMPTION_CODE_LENGTH);
|
|
55
|
+
expect(isValidRedemptionCodeShape(code)).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("respects injected random bytes", () => {
|
|
59
|
+
const bytes = new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7]);
|
|
60
|
+
const code = generateRedemptionCode(() => bytes);
|
|
61
|
+
const alphabet = __testInternals.REDEMPTION_CODE_ALPHABET;
|
|
62
|
+
expect(code).toBe(
|
|
63
|
+
Array.from(bytes)
|
|
64
|
+
.map((b) => alphabet[b % alphabet.length])
|
|
65
|
+
.join(""),
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("generateUniqueRedemptionCode", () => {
|
|
71
|
+
test("returns the first non-colliding candidate", async () => {
|
|
72
|
+
const seen = new Set<string>();
|
|
73
|
+
const code = await generateUniqueRedemptionCode(async (c) => seen.has(c));
|
|
74
|
+
expect(isValidRedemptionCodeShape(code)).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("retries until one succeeds", async () => {
|
|
78
|
+
let calls = 0;
|
|
79
|
+
const code = await generateUniqueRedemptionCode(async () => {
|
|
80
|
+
calls += 1;
|
|
81
|
+
return calls < 3;
|
|
82
|
+
});
|
|
83
|
+
expect(calls).toBe(3);
|
|
84
|
+
expect(isValidRedemptionCodeShape(code)).toBe(true);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("throws after exhausting attempts", async () => {
|
|
88
|
+
await expect(
|
|
89
|
+
generateUniqueRedemptionCode(async () => true, { maxAttempts: 4 }),
|
|
90
|
+
).rejects.toThrow(/Could not generate a unique/);
|
|
91
|
+
});
|
|
92
|
+
});
|