@bash-app/bash-common 30.235.0 → 30.237.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.
Files changed (180) hide show
  1. package/dist/__tests__/statusEnums.test.js +88 -7
  2. package/dist/__tests__/statusEnums.test.js.map +1 -1
  3. package/dist/definitions.d.ts +45 -14
  4. package/dist/definitions.d.ts.map +1 -1
  5. package/dist/definitions.js +38 -17
  6. package/dist/definitions.js.map +1 -1
  7. package/dist/extendedSchemas.d.ts +8 -0
  8. package/dist/extendedSchemas.d.ts.map +1 -1
  9. package/dist/extendedSchemas.js.map +1 -1
  10. package/dist/index.d.ts +13 -1
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +16 -4
  13. package/dist/index.js.map +1 -1
  14. package/dist/mirroredPrismaEnums.d.ts +145 -0
  15. package/dist/mirroredPrismaEnums.d.ts.map +1 -0
  16. package/dist/mirroredPrismaEnums.js +136 -0
  17. package/dist/mirroredPrismaEnums.js.map +1 -0
  18. package/dist/utils/__tests__/nonprofitUtils.test.d.ts +2 -0
  19. package/dist/utils/__tests__/nonprofitUtils.test.d.ts.map +1 -0
  20. package/dist/utils/__tests__/nonprofitUtils.test.js +84 -0
  21. package/dist/utils/__tests__/nonprofitUtils.test.js.map +1 -0
  22. package/dist/utils/__tests__/paymentUtils.test.js +87 -0
  23. package/dist/utils/__tests__/paymentUtils.test.js.map +1 -1
  24. package/dist/utils/nonprofitUtils.d.ts +21 -0
  25. package/dist/utils/nonprofitUtils.d.ts.map +1 -0
  26. package/dist/utils/nonprofitUtils.js +29 -0
  27. package/dist/utils/nonprofitUtils.js.map +1 -0
  28. package/dist/utils/paymentUtils.d.ts +23 -0
  29. package/dist/utils/paymentUtils.d.ts.map +1 -1
  30. package/dist/utils/paymentUtils.js +24 -7
  31. package/dist/utils/paymentUtils.js.map +1 -1
  32. package/dist/utils/service/__tests__/billingCapUtils.test.d.ts +2 -0
  33. package/dist/utils/service/__tests__/billingCapUtils.test.d.ts.map +1 -0
  34. package/dist/utils/service/__tests__/billingCapUtils.test.js +120 -0
  35. package/dist/utils/service/__tests__/billingCapUtils.test.js.map +1 -0
  36. package/dist/utils/service/__tests__/cancellationPolicyRefundResolver.test.js +1 -1
  37. package/dist/utils/service/__tests__/cancellationPolicyRefundResolver.test.js.map +1 -1
  38. package/dist/utils/service/__tests__/eventPromoterPhase3.test.d.ts +2 -0
  39. package/dist/utils/service/__tests__/eventPromoterPhase3.test.d.ts.map +1 -0
  40. package/dist/utils/service/__tests__/eventPromoterPhase3.test.js +243 -0
  41. package/dist/utils/service/__tests__/eventPromoterPhase3.test.js.map +1 -0
  42. package/dist/utils/service/__tests__/eventPromoterUtils.test.d.ts +2 -0
  43. package/dist/utils/service/__tests__/eventPromoterUtils.test.d.ts.map +1 -0
  44. package/dist/utils/service/__tests__/eventPromoterUtils.test.js +333 -0
  45. package/dist/utils/service/__tests__/eventPromoterUtils.test.js.map +1 -0
  46. package/dist/utils/service/__tests__/offerAllowListUtils.test.d.ts +2 -0
  47. package/dist/utils/service/__tests__/offerAllowListUtils.test.d.ts.map +1 -0
  48. package/dist/utils/service/__tests__/offerAllowListUtils.test.js +52 -0
  49. package/dist/utils/service/__tests__/offerAllowListUtils.test.js.map +1 -0
  50. package/dist/utils/service/__tests__/offerComparisonUtils.test.d.ts +2 -0
  51. package/dist/utils/service/__tests__/offerComparisonUtils.test.d.ts.map +1 -0
  52. package/dist/utils/service/__tests__/offerComparisonUtils.test.js +111 -0
  53. package/dist/utils/service/__tests__/offerComparisonUtils.test.js.map +1 -0
  54. package/dist/utils/service/__tests__/serviceBookingExposureUtils.test.d.ts +2 -0
  55. package/dist/utils/service/__tests__/serviceBookingExposureUtils.test.d.ts.map +1 -0
  56. package/dist/utils/service/__tests__/serviceBookingExposureUtils.test.js +155 -0
  57. package/dist/utils/service/__tests__/serviceBookingExposureUtils.test.js.map +1 -0
  58. package/dist/utils/service/__tests__/unifiedPricingResolver.test.d.ts +2 -0
  59. package/dist/utils/service/__tests__/unifiedPricingResolver.test.d.ts.map +1 -0
  60. package/dist/utils/service/__tests__/unifiedPricingResolver.test.js +77 -0
  61. package/dist/utils/service/__tests__/unifiedPricingResolver.test.js.map +1 -0
  62. package/dist/utils/service/__tests__/venueFBSettlementUtils.test.d.ts +2 -0
  63. package/dist/utils/service/__tests__/venueFBSettlementUtils.test.d.ts.map +1 -0
  64. package/dist/utils/service/__tests__/venueFBSettlementUtils.test.js +157 -0
  65. package/dist/utils/service/__tests__/venueFBSettlementUtils.test.js.map +1 -0
  66. package/dist/utils/service/__tests__/venueSettlementUtils.test.d.ts +2 -0
  67. package/dist/utils/service/__tests__/venueSettlementUtils.test.d.ts.map +1 -0
  68. package/dist/utils/service/__tests__/venueSettlementUtils.test.js +175 -0
  69. package/dist/utils/service/__tests__/venueSettlementUtils.test.js.map +1 -0
  70. package/dist/utils/service/__tests__/volunteerCompensationUtils.test.d.ts +2 -0
  71. package/dist/utils/service/__tests__/volunteerCompensationUtils.test.d.ts.map +1 -0
  72. package/dist/utils/service/__tests__/volunteerCompensationUtils.test.js +118 -0
  73. package/dist/utils/service/__tests__/volunteerCompensationUtils.test.js.map +1 -0
  74. package/dist/utils/service/apiServiceBookingApiUtils.d.ts.map +1 -1
  75. package/dist/utils/service/apiServiceBookingApiUtils.js +4 -0
  76. package/dist/utils/service/apiServiceBookingApiUtils.js.map +1 -1
  77. package/dist/utils/service/billingCapUtils.d.ts +86 -0
  78. package/dist/utils/service/billingCapUtils.d.ts.map +1 -0
  79. package/dist/utils/service/billingCapUtils.js +96 -0
  80. package/dist/utils/service/billingCapUtils.js.map +1 -0
  81. package/dist/utils/service/cancellationPolicyRefundResolver.d.ts +1 -1
  82. package/dist/utils/service/cancellationPolicyRefundResolver.d.ts.map +1 -1
  83. package/dist/utils/service/cancellationPolicyRefundResolver.js +1 -1
  84. package/dist/utils/service/cancellationPolicyRefundResolver.js.map +1 -1
  85. package/dist/utils/service/eventPromoterUtils.d.ts +319 -0
  86. package/dist/utils/service/eventPromoterUtils.d.ts.map +1 -0
  87. package/dist/utils/service/eventPromoterUtils.js +372 -0
  88. package/dist/utils/service/eventPromoterUtils.js.map +1 -0
  89. package/dist/utils/service/offerAllowListUtils.d.ts +42 -0
  90. package/dist/utils/service/offerAllowListUtils.d.ts.map +1 -0
  91. package/dist/utils/service/offerAllowListUtils.js +49 -0
  92. package/dist/utils/service/offerAllowListUtils.js.map +1 -0
  93. package/dist/utils/service/offerComparisonUtils.d.ts +69 -0
  94. package/dist/utils/service/offerComparisonUtils.d.ts.map +1 -0
  95. package/dist/utils/service/offerComparisonUtils.js +110 -0
  96. package/dist/utils/service/offerComparisonUtils.js.map +1 -0
  97. package/dist/utils/service/serviceBookingExposureUtils.d.ts +111 -0
  98. package/dist/utils/service/serviceBookingExposureUtils.d.ts.map +1 -0
  99. package/dist/utils/service/serviceBookingExposureUtils.js +133 -0
  100. package/dist/utils/service/serviceBookingExposureUtils.js.map +1 -0
  101. package/dist/utils/service/serviceBookingTypes.d.ts +1 -1
  102. package/dist/utils/service/serviceBookingTypes.d.ts.map +1 -1
  103. package/dist/utils/service/serviceUtils.d.ts +2 -1
  104. package/dist/utils/service/serviceUtils.d.ts.map +1 -1
  105. package/dist/utils/service/serviceUtils.js +17 -16
  106. package/dist/utils/service/serviceUtils.js.map +1 -1
  107. package/dist/utils/service/unifiedPricingResolver.d.ts +67 -0
  108. package/dist/utils/service/unifiedPricingResolver.d.ts.map +1 -0
  109. package/dist/utils/service/unifiedPricingResolver.js +75 -0
  110. package/dist/utils/service/unifiedPricingResolver.js.map +1 -0
  111. package/dist/utils/service/venueFBSettlementUtils.d.ts +122 -0
  112. package/dist/utils/service/venueFBSettlementUtils.d.ts.map +1 -0
  113. package/dist/utils/service/venueFBSettlementUtils.js +106 -0
  114. package/dist/utils/service/venueFBSettlementUtils.js.map +1 -0
  115. package/dist/utils/service/venueSettlementUtils.d.ts +107 -0
  116. package/dist/utils/service/venueSettlementUtils.d.ts.map +1 -0
  117. package/dist/utils/service/venueSettlementUtils.js +117 -0
  118. package/dist/utils/service/venueSettlementUtils.js.map +1 -0
  119. package/dist/utils/service/volunteerCompensationUtils.d.ts +75 -0
  120. package/dist/utils/service/volunteerCompensationUtils.d.ts.map +1 -0
  121. package/dist/utils/service/volunteerCompensationUtils.js +88 -0
  122. package/dist/utils/service/volunteerCompensationUtils.js.map +1 -0
  123. package/dist/utils/venueLoyalty/__tests__/eligibilityUtils.test.d.ts +2 -0
  124. package/dist/utils/venueLoyalty/__tests__/eligibilityUtils.test.d.ts.map +1 -0
  125. package/dist/utils/venueLoyalty/__tests__/eligibilityUtils.test.js +67 -0
  126. package/dist/utils/venueLoyalty/__tests__/eligibilityUtils.test.js.map +1 -0
  127. package/dist/utils/venueLoyalty/__tests__/redemptionCodeUtils.test.d.ts +2 -0
  128. package/dist/utils/venueLoyalty/__tests__/redemptionCodeUtils.test.d.ts.map +1 -0
  129. package/dist/utils/venueLoyalty/__tests__/redemptionCodeUtils.test.js +71 -0
  130. package/dist/utils/venueLoyalty/__tests__/redemptionCodeUtils.test.js.map +1 -0
  131. package/dist/utils/venueLoyalty/eligibilityUtils.d.ts +33 -0
  132. package/dist/utils/venueLoyalty/eligibilityUtils.d.ts.map +1 -0
  133. package/dist/utils/venueLoyalty/eligibilityUtils.js +49 -0
  134. package/dist/utils/venueLoyalty/eligibilityUtils.js.map +1 -0
  135. package/dist/utils/venueLoyalty/redemptionCodeUtils.d.ts +35 -0
  136. package/dist/utils/venueLoyalty/redemptionCodeUtils.d.ts.map +1 -0
  137. package/dist/utils/venueLoyalty/redemptionCodeUtils.js +74 -0
  138. package/dist/utils/venueLoyalty/redemptionCodeUtils.js.map +1 -0
  139. package/dist/venueLoyaltyRedemption.d.ts +146 -1
  140. package/dist/venueLoyaltyRedemption.d.ts.map +1 -1
  141. package/dist/venueLoyaltyRedemption.js +18 -2
  142. package/dist/venueLoyaltyRedemption.js.map +1 -1
  143. package/package.json +1 -1
  144. package/prisma/schema.prisma +638 -214
  145. package/src/__tests__/statusEnums.test.ts +119 -11
  146. package/src/definitions.ts +65 -22
  147. package/src/extendedSchemas.ts +8 -0
  148. package/src/index.ts +16 -6
  149. package/src/mirroredPrismaEnums.ts +181 -0
  150. package/src/utils/__tests__/nonprofitUtils.test.ts +117 -0
  151. package/src/utils/__tests__/paymentUtils.test.ts +93 -0
  152. package/src/utils/nonprofitUtils.ts +44 -0
  153. package/src/utils/paymentUtils.ts +33 -7
  154. package/src/utils/service/__tests__/billingCapUtils.test.ts +175 -0
  155. package/src/utils/service/__tests__/cancellationPolicyRefundResolver.test.ts +1 -1
  156. package/src/utils/service/__tests__/eventPromoterPhase3.test.ts +296 -0
  157. package/src/utils/service/__tests__/eventPromoterUtils.test.ts +396 -0
  158. package/src/utils/service/__tests__/offerAllowListUtils.test.ts +76 -0
  159. package/src/utils/service/__tests__/offerComparisonUtils.test.ts +124 -0
  160. package/src/utils/service/__tests__/serviceBookingExposureUtils.test.ts +171 -0
  161. package/src/utils/service/__tests__/unifiedPricingResolver.test.ts +83 -0
  162. package/src/utils/service/__tests__/venueSettlementUtils.test.ts +219 -0
  163. package/src/utils/service/__tests__/volunteerCompensationUtils.test.ts +172 -0
  164. package/src/utils/service/apiServiceBookingApiUtils.ts +4 -0
  165. package/src/utils/service/billingCapUtils.ts +145 -0
  166. package/src/utils/service/cancellationPolicyRefundResolver.ts +1 -1
  167. package/src/utils/service/eventPromoterUtils.ts +644 -0
  168. package/src/utils/service/offerAllowListUtils.ts +81 -0
  169. package/src/utils/service/offerComparisonUtils.ts +197 -0
  170. package/src/utils/service/serviceBookingExposureUtils.ts +218 -0
  171. package/src/utils/service/serviceBookingTypes.ts +4 -0
  172. package/src/utils/service/serviceUtils.ts +18 -21
  173. package/src/utils/service/unifiedPricingResolver.ts +149 -0
  174. package/src/utils/service/venueSettlementUtils.ts +198 -0
  175. package/src/utils/service/volunteerCompensationUtils.ts +145 -0
  176. package/src/utils/venueLoyalty/__tests__/eligibilityUtils.test.ts +103 -0
  177. package/src/utils/venueLoyalty/__tests__/redemptionCodeUtils.test.ts +92 -0
  178. package/src/utils/venueLoyalty/eligibilityUtils.ts +84 -0
  179. package/src/utils/venueLoyalty/redemptionCodeUtils.ts +94 -0
  180. 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
+ });