@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.
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 +39 -8
  4. package/dist/definitions.d.ts.map +1 -1
  5. package/dist/definitions.js +31 -10
  6. package/dist/definitions.js.map +1 -1
  7. package/dist/extendedSchemas.d.ts +6 -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 +609 -214
  145. package/src/__tests__/statusEnums.test.ts +119 -11
  146. package/src/definitions.ts +57 -14
  147. package/src/extendedSchemas.ts +6 -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,644 @@
1
+ import {
2
+ PricingModel,
3
+ PromotionAndMarketingSubType,
4
+ } from "@prisma/client";
5
+
6
+ /**
7
+ * Event Promoter is a productized hire-a-promoter service profile under
8
+ * `EventService` → `PromotionAndMarketing` → `Promoter`. Its pricing model
9
+ * is intentionally locked to PER_ATTENDEE so:
10
+ *
11
+ * 1. Bookings can compute a deposit at booking time from
12
+ * `bashEvent.expectedAttendees * baseRateCents`.
13
+ * 2. The bash-close settlement job can run a delta charge/refund based on
14
+ * final checked-in attendee count (see
15
+ * `apiEventPromoterSettlementMath` below).
16
+ *
17
+ * Keep the disambiguation: this is the **Event Promoter** service, not the
18
+ * **Promo-code Promoter** referral program (`model Promoter` +
19
+ * `BashEventPromoCode`), which earns BashPoints per ticket purchased.
20
+ */
21
+
22
+ /**
23
+ * Returns true when the given `PromotionAndMarketing` subtype is locked to a
24
+ * specific `PricingModel`. Today only `Promoter` is locked (PER_ATTENDEE).
25
+ *
26
+ * Compared with the **string literal** rather than the Prisma enum object
27
+ * because the browser bundle (`index-browser`) does not always re-export
28
+ * Prisma enums as runtime values — see `bash-common/src/mirroredPrismaEnums.ts`
29
+ * for the same pattern.
30
+ */
31
+ export function isPerAttendeeLockedPromotionSubType(
32
+ subType: PromotionAndMarketingSubType | string | null | undefined
33
+ ): boolean {
34
+ return subType === "Promoter";
35
+ }
36
+
37
+ /**
38
+ * Build the URL the host clicks from an Event Promoter booking thread to
39
+ * issue/edit a `BashEventPromoCode` with the promoter pre-selected. The
40
+ * landing page reads `promoterUserId` from the query string and opens the
41
+ * promo-code create modal already focused on that user.
42
+ *
43
+ * Keeping this in `bash-common` so api emails + bash-app threads + push
44
+ * notifications all generate the same deep link.
45
+ */
46
+ export function buildIssuePromoCodeDeepLink(args: {
47
+ bashEventId: string;
48
+ promoterUserId: string;
49
+ /** Optional preselected ticket tier (skip the chooser screen when set). */
50
+ ticketTierId?: string | null;
51
+ }): string {
52
+ const params = new URLSearchParams({
53
+ promoterUserId: args.promoterUserId,
54
+ source: "event-promoter-booking",
55
+ });
56
+ if (args.ticketTierId) {
57
+ params.set("ticketTierId", args.ticketTierId);
58
+ }
59
+ return `/bashes/${args.bashEventId}/edit?step=how&open=promo-codes&${params.toString()}`;
60
+ }
61
+
62
+ /** Hours-before-bash threshold under which a host cancellation is "late". */
63
+ export const EVENT_PROMOTER_LATE_CANCEL_HOURS = 48;
64
+
65
+ /**
66
+ * Late-cancellation penalty as a fraction of the deposit (0..1). 0.25 means
67
+ * the host forfeits 25% of the deposit when they cancel inside the
68
+ * `EVENT_PROMOTER_LATE_CANCEL_HOURS` window — covers the promoter's
69
+ * sunk planning time.
70
+ */
71
+ export const EVENT_PROMOTER_LATE_CANCEL_PENALTY_FRACTION = 0.25;
72
+
73
+ /** Who is cancelling the Event Promoter booking. Maps to refund policy. */
74
+ export type EventPromoterCanceller = "host" | "promoter" | "admin";
75
+
76
+ export interface EventPromoterCancellationInput {
77
+ who: EventPromoterCanceller;
78
+ /** ISO timestamp of "now" — injected so callers can test edge cases. */
79
+ nowMs: number;
80
+ /** Bash start time. */
81
+ bashStartMs: number | null;
82
+ /** Bash end time — once we're past this, settlement takes over instead. */
83
+ bashEndMs: number | null;
84
+ /** Deposit cents already charged at booking. */
85
+ depositCents: number;
86
+ /** Has settlement already run? If so, cancellation is a no-op. */
87
+ alreadySettled: boolean;
88
+ }
89
+
90
+ export interface EventPromoterCancellationDecision {
91
+ /** Whether the cancellation can run automatically right now. */
92
+ allow: boolean;
93
+ /**
94
+ * Outcome category for telemetry + thread messaging:
95
+ * - `FullRefund` — full deposit returned (host cancel outside window, or
96
+ * promoter cancel any time before the bash).
97
+ * - `PartialRefund` — host cancels inside the late-cancel window; promoter
98
+ * keeps `EVENT_PROMOTER_LATE_CANCEL_PENALTY_FRACTION` of the deposit.
99
+ * - `NoRefund` — bash already happened; settlement handles it instead.
100
+ * - `Blocked` — booking already settled, or impossible state; admin only.
101
+ */
102
+ outcome: "FullRefund" | "PartialRefund" | "NoRefund" | "Blocked";
103
+ /** Refund amount in cents (0 when outcome is NoRefund/Blocked). */
104
+ refundCents: number;
105
+ /** Penalty fraction applied (0 when not late-cancelled by host). */
106
+ penaltyFraction: number;
107
+ /** Human-readable reason, suitable for booking-thread system messages. */
108
+ reason: string;
109
+ }
110
+
111
+ /**
112
+ * Pure decision function that returns what should happen when someone tries
113
+ * to cancel an Event Promoter booking. Mirrors the matrix called out in the
114
+ * plan:
115
+ *
116
+ * - Host, bash hasn't started, > 48h out → FullRefund.
117
+ * - Host, bash hasn't started, ≤ 48h out → PartialRefund (25% penalty).
118
+ * - Host, bash already running/ended → NoRefund (settlement takes over).
119
+ * - Promoter, bash not started → FullRefund + promoter notified
120
+ * (Bash never holds a promoter to a job they can't do).
121
+ * - Promoter, bash already running/ended → Blocked (use the booking thread
122
+ * to coordinate; nothing automatic).
123
+ * - Admin always allowed; refund amount decided by caller.
124
+ * - Already settled → Blocked (no automatic cancel).
125
+ *
126
+ * The actual Stripe refund + DB mutation lives in the api-side counterpart;
127
+ * this function only encodes policy so the same matrix surfaces in tests,
128
+ * the booking thread copy, and the audit log.
129
+ */
130
+ export function decideEventPromoterCancellation(
131
+ input: EventPromoterCancellationInput
132
+ ): EventPromoterCancellationDecision {
133
+ if (input.alreadySettled) {
134
+ return {
135
+ allow: false,
136
+ outcome: "Blocked",
137
+ refundCents: 0,
138
+ penaltyFraction: 0,
139
+ reason: "Booking already settled — cancellation no longer applies.",
140
+ };
141
+ }
142
+
143
+ const bashHasStarted =
144
+ input.bashStartMs != null && input.nowMs >= input.bashStartMs;
145
+ const bashHasEnded =
146
+ input.bashEndMs != null && input.nowMs >= input.bashEndMs;
147
+ const bashInProgressOrPast = bashHasStarted || bashHasEnded;
148
+ const hoursUntilStart =
149
+ input.bashStartMs != null
150
+ ? (input.bashStartMs - input.nowMs) / (60 * 60 * 1000)
151
+ : Number.POSITIVE_INFINITY;
152
+
153
+ if (input.who === "admin") {
154
+ if (bashInProgressOrPast) {
155
+ return {
156
+ allow: true,
157
+ outcome: "NoRefund",
158
+ refundCents: 0,
159
+ penaltyFraction: 0,
160
+ reason: "Admin cancel after bash started — settlement handles money.",
161
+ };
162
+ }
163
+ return {
164
+ allow: true,
165
+ outcome: "FullRefund",
166
+ refundCents: input.depositCents,
167
+ penaltyFraction: 0,
168
+ reason: "Admin cancel — full refund of deposit.",
169
+ };
170
+ }
171
+
172
+ if (input.who === "promoter") {
173
+ if (bashInProgressOrPast) {
174
+ return {
175
+ allow: false,
176
+ outcome: "Blocked",
177
+ refundCents: 0,
178
+ penaltyFraction: 0,
179
+ reason:
180
+ "Bash already started — promoter must coordinate with host in the thread, not auto-cancel.",
181
+ };
182
+ }
183
+ return {
184
+ allow: true,
185
+ outcome: "FullRefund",
186
+ refundCents: input.depositCents,
187
+ penaltyFraction: 0,
188
+ reason:
189
+ "Promoter cancelled — full refund of host deposit. Bash notifies the host and re-opens the slot.",
190
+ };
191
+ }
192
+
193
+ // who === "host"
194
+ if (bashInProgressOrPast) {
195
+ return {
196
+ allow: false,
197
+ outcome: "NoRefund",
198
+ refundCents: 0,
199
+ penaltyFraction: 0,
200
+ reason:
201
+ "Bash already in progress or over — the post-event settlement will calculate any refund automatically.",
202
+ };
203
+ }
204
+
205
+ if (hoursUntilStart >= EVENT_PROMOTER_LATE_CANCEL_HOURS) {
206
+ return {
207
+ allow: true,
208
+ outcome: "FullRefund",
209
+ refundCents: input.depositCents,
210
+ penaltyFraction: 0,
211
+ reason: `Host cancelled with more than ${EVENT_PROMOTER_LATE_CANCEL_HOURS}h notice — full refund of deposit.`,
212
+ };
213
+ }
214
+
215
+ const penaltyFraction = EVENT_PROMOTER_LATE_CANCEL_PENALTY_FRACTION;
216
+ const refundCents = Math.max(
217
+ 0,
218
+ Math.round(input.depositCents * (1 - penaltyFraction))
219
+ );
220
+ return {
221
+ allow: true,
222
+ outcome: "PartialRefund",
223
+ refundCents,
224
+ penaltyFraction,
225
+ reason: `Host cancelled inside the ${EVENT_PROMOTER_LATE_CANCEL_HOURS}h window — promoter keeps ${Math.round(
226
+ penaltyFraction * 100
227
+ )}% of the deposit to cover planning time.`,
228
+ };
229
+ }
230
+
231
+ /**
232
+ * The single allowed pricing model for the locked promotion subtype. Exposed
233
+ * as a constant so wizard + api validators share the same source of truth.
234
+ *
235
+ * Retained for backwards compatibility; new call sites should prefer
236
+ * {@link EVENT_PROMOTER_LOCKED_PRICING_MODELS} to allow Phase 3's
237
+ * `PER_PAID_ATTENDEE` and `PER_BASHPOINTS_ATTENDEE`.
238
+ */
239
+ export const EVENT_PROMOTER_LOCKED_PRICING_MODEL: PricingModel =
240
+ "PER_ATTENDEE";
241
+
242
+ /**
243
+ * Phase 3 — set of pricing models the locked Event Promoter subtype may use.
244
+ * Any of these guarantees a per-head settlement at bash close, which is the
245
+ * actual constraint the worker cares about. Wizard offers them as a choice;
246
+ * api validator accepts any.
247
+ */
248
+ export const EVENT_PROMOTER_LOCKED_PRICING_MODELS: ReadonlyArray<PricingModel> = [
249
+ "PER_ATTENDEE",
250
+ "PER_PAID_ATTENDEE",
251
+ "PER_BASHPOINTS_ATTENDEE",
252
+ ];
253
+
254
+ export type EventPromoterPricingValidation =
255
+ | { ok: true }
256
+ | { ok: false; reason: string; expected: ReadonlyArray<PricingModel> };
257
+
258
+ /**
259
+ * Validates that an `EventService` save with `subType === "Promoter"` uses
260
+ * one of the {@link EVENT_PROMOTER_LOCKED_PRICING_MODELS}. Intended for both:
261
+ *
262
+ * - the Event Services wizard (block save with a friendly message), and
263
+ * - the api save path (reject the request even if the wizard is bypassed).
264
+ *
265
+ * Returns `{ ok: true }` for any non-locked subtype.
266
+ */
267
+ export function validateEventPromoterPricing(args: {
268
+ eventServiceSubType: PromotionAndMarketingSubType | string | null | undefined;
269
+ pricingModel: PricingModel | null | undefined;
270
+ }): EventPromoterPricingValidation {
271
+ if (!isPerAttendeeLockedPromotionSubType(args.eventServiceSubType)) {
272
+ return { ok: true };
273
+ }
274
+
275
+ if (
276
+ args.pricingModel != null &&
277
+ EVENT_PROMOTER_LOCKED_PRICING_MODELS.includes(args.pricingModel)
278
+ ) {
279
+ return { ok: true };
280
+ }
281
+
282
+ return {
283
+ ok: false,
284
+ expected: EVENT_PROMOTER_LOCKED_PRICING_MODELS,
285
+ reason: `Event Promoter services must use one of: ${EVENT_PROMOTER_LOCKED_PRICING_MODELS.join(
286
+ ", "
287
+ )}. Got: ${args.pricingModel ?? "null"}.`,
288
+ };
289
+ }
290
+
291
+ /**
292
+ * Computes the deposit cents to charge a host at Event Promoter booking time.
293
+ *
294
+ * Logic:
295
+ * - Baseline = max(`bashExpectedAttendees`, `minimumQuantity ?? 0`).
296
+ * - Deposit = baseline * `baseRateCents`.
297
+ *
298
+ * Returns `null` if `baseRateCents` is not set (caller should surface a
299
+ * validation error rather than silently charge $0).
300
+ */
301
+ export function computeEventPromoterDepositCents(args: {
302
+ bashExpectedAttendees: number;
303
+ baseRateCents: number | null | undefined;
304
+ minimumQuantity: number | null | undefined;
305
+ }): number | null {
306
+ if (args.baseRateCents == null || args.baseRateCents <= 0) {
307
+ return null;
308
+ }
309
+ const baseline = Math.max(
310
+ Math.max(0, args.bashExpectedAttendees),
311
+ Math.max(0, args.minimumQuantity ?? 0)
312
+ );
313
+ return baseline * args.baseRateCents;
314
+ }
315
+
316
+ export interface EventPromoterSettlementInput {
317
+ baseRateCents: number;
318
+ expectedAttendeesAtBooking: number;
319
+ totalCheckedInAttendees: number;
320
+ /** Per-booking host-set ceiling on attributable attendees; `null` = no cap. */
321
+ maxAttendeesAttributable?: number | null;
322
+ /** Optional floor — usually `EventService.minimumQuantity` at booking time. */
323
+ minimumQuantity?: number | null;
324
+ }
325
+
326
+ export interface EventPromoterSettlementResult {
327
+ /**
328
+ * Attendee count actually used to bill, after applying the ceiling and any
329
+ * minimum-quantity floor. Always ≥ 0.
330
+ */
331
+ finalCount: number;
332
+ /**
333
+ * True iff `maxAttendeesAttributable` capped `finalCount`. Useful for
334
+ * analytics — high `ceilingHit` rates signal v2 (per-ticket attribution).
335
+ */
336
+ ceilingHit: boolean;
337
+ /**
338
+ * Signed delta in cents:
339
+ * - positive → charge the host this additional amount.
340
+ * - negative → refund the host this amount (refund magnitude = |delta|).
341
+ * - 0 → no Stripe action needed; mark `settledAt`.
342
+ */
343
+ settlementDeltaCents: number;
344
+ /**
345
+ * Total cents the host owes for the booking after settlement
346
+ * (`finalCount * baseRateCents`). Useful for receipts / analytics.
347
+ */
348
+ totalCents: number;
349
+ }
350
+
351
+ /**
352
+ * Pure function: compute the per-attendee settlement delta for an Event
353
+ * Promoter `ServiceBooking`. Caller is responsible for translating the result
354
+ * into Stripe API calls (charge if positive delta, refund if negative).
355
+ *
356
+ * Worked example (host hires two promoters for the same bash @ $5/attendee):
357
+ *
358
+ * Promoter A: expected=200, ceiling=250, checked-in=220
359
+ * → finalCount=220, delta = (220-200) * 500 = +$100
360
+ *
361
+ * Promoter B: expected=100, ceiling=150, same 220 checked-in
362
+ * → finalCount=min(220,150)=150, delta = (150-100) * 500 = +$250
363
+ *
364
+ * @see plan: docs/design/CONTENT-PATTERNS.md, .cursor/plans/ambashadors_page_tiers_ux_*.plan.md
365
+ */
366
+ export function computeEventPromoterSettlement(
367
+ input: EventPromoterSettlementInput
368
+ ): EventPromoterSettlementResult {
369
+ const checkedIn = Math.max(0, input.totalCheckedInAttendees);
370
+ const ceiling =
371
+ input.maxAttendeesAttributable != null && input.maxAttendeesAttributable >= 0
372
+ ? input.maxAttendeesAttributable
373
+ : Number.POSITIVE_INFINITY;
374
+ const minQty = Math.max(0, input.minimumQuantity ?? 0);
375
+
376
+ // Apply ceiling first, then enforce minimum-quantity floor for the
377
+ // billing-floor case (host committed to paying for at least `minQty`
378
+ // attendees regardless of turnout).
379
+ const cappedByCeiling = Math.min(checkedIn, ceiling);
380
+ const finalCount = Math.max(cappedByCeiling, minQty);
381
+ const ceilingHit = checkedIn > ceiling;
382
+
383
+ const totalCents = finalCount * input.baseRateCents;
384
+ const expectedCents = input.expectedAttendeesAtBooking * input.baseRateCents;
385
+ const settlementDeltaCents = totalCents - expectedCents;
386
+
387
+ return {
388
+ finalCount,
389
+ ceilingHit,
390
+ settlementDeltaCents,
391
+ totalCents,
392
+ };
393
+ }
394
+
395
+ // --------------------------------------------------------------------------
396
+ // Phase 3 — PER_PAID_ATTENDEE + PER_BASHPOINTS_ATTENDEE settlement helpers.
397
+ // --------------------------------------------------------------------------
398
+
399
+ /**
400
+ * Which checked-in attendees count toward the settlement headcount?
401
+ *
402
+ * - `AllCheckedIn` — every checked-in, non-comp guest (legacy PER_ATTENDEE).
403
+ * - `PaidUsdOnly` — only guests on USD-priced tiers (PER_PAID_ATTENDEE without
404
+ * `countsBashPointsTickets`).
405
+ * - `PaidBpOnly` — only guests on BashPoints-priced tiers
406
+ * (PER_BASHPOINTS_ATTENDEE).
407
+ * - `PaidAny` — paid guests on either currency
408
+ * (PER_PAID_ATTENDEE with `countsBashPointsTickets = true`).
409
+ */
410
+ export type EventPromoterCountingBasis =
411
+ | "AllCheckedIn"
412
+ | "PaidUsdOnly"
413
+ | "PaidBpOnly"
414
+ | "PaidAny";
415
+
416
+ /**
417
+ * Choose the correct counting basis given the pricing model and the
418
+ * per-booking `countsBashPointsTickets` knob. Pure function so wizard +
419
+ * worker share one mapping.
420
+ */
421
+ export function eventPromoterCountingBasisFor(args: {
422
+ pricingModel: PricingModel | string | null | undefined;
423
+ countsBashPointsTickets?: boolean | null;
424
+ }): EventPromoterCountingBasis {
425
+ switch (args.pricingModel) {
426
+ case "PER_PAID_ATTENDEE":
427
+ return args.countsBashPointsTickets ? "PaidAny" : "PaidUsdOnly";
428
+ case "PER_BASHPOINTS_ATTENDEE":
429
+ return "PaidBpOnly";
430
+ case "PER_ATTENDEE":
431
+ default:
432
+ return "AllCheckedIn";
433
+ }
434
+ }
435
+
436
+ /**
437
+ * Pick the matching checked-in count from the snapshots a worker has gathered
438
+ * at settlement time. Returns the **paying** head count regardless of
439
+ * currency, narrowed by basis.
440
+ */
441
+ export interface EventPromoterAttendeeSnapshots {
442
+ /** All checked-in attendees (`AllCheckedIn` basis). */
443
+ totalCheckedInAttendees: number;
444
+ /** Checked-in attendees on USD-priced tiers (`PaidUsdOnly` / `PaidAny`). */
445
+ paidAttendeesAtSettle?: number | null;
446
+ /** Checked-in attendees on BP-priced tiers (`PaidBpOnly` / `PaidAny`). */
447
+ paidInBashPointsAttendeesAtSettle?: number | null;
448
+ }
449
+
450
+ export function pickCheckedInForBasis(
451
+ basis: EventPromoterCountingBasis,
452
+ s: EventPromoterAttendeeSnapshots
453
+ ): number {
454
+ switch (basis) {
455
+ case "AllCheckedIn":
456
+ return Math.max(0, s.totalCheckedInAttendees);
457
+ case "PaidUsdOnly":
458
+ return Math.max(0, s.paidAttendeesAtSettle ?? 0);
459
+ case "PaidBpOnly":
460
+ return Math.max(0, s.paidInBashPointsAttendeesAtSettle ?? 0);
461
+ case "PaidAny":
462
+ return (
463
+ Math.max(0, s.paidAttendeesAtSettle ?? 0) +
464
+ Math.max(0, s.paidInBashPointsAttendeesAtSettle ?? 0)
465
+ );
466
+ }
467
+ }
468
+
469
+ export interface EventPromoterMixedSettlementInput {
470
+ /** Per-attendee USD rate (cents). 0/null = no USD owed. */
471
+ baseRateCents?: number | null;
472
+ /** Per-attendee BP rate. 0/null = no BP owed. */
473
+ baseRateBashPoints?: number | null;
474
+ /** Expected USD-paying head count at booking. */
475
+ expectedPaidAttendeesAtBooking?: number | null;
476
+ /** Expected BP-paying head count at booking. */
477
+ expectedBpAttendeesAtBooking?: number | null;
478
+ /** Snapshots from the bash checkout (see `EventPromoterAttendeeSnapshots`). */
479
+ snapshots: EventPromoterAttendeeSnapshots;
480
+ /** Optional ceilings — separate caps for USD and BP. */
481
+ maxUsdAttendeesAttributable?: number | null;
482
+ maxBpAttendeesAttributable?: number | null;
483
+ /** Optional dollar cap on USD side (from `maxBillableCents`). */
484
+ maxBillableCents?: number | null;
485
+ /** Optional BP cap on BP side (from `maxBillableBashPoints`). */
486
+ maxBillableBashPoints?: number | null;
487
+ }
488
+
489
+ export interface EventPromoterMixedSettlementResult {
490
+ /** USD owed at settlement. */
491
+ usdTotalCents: number;
492
+ /** USD delta vs the deposit pre-paid at booking. */
493
+ usdDeltaCents: number;
494
+ /** BashPoints owed at settlement. */
495
+ bpTotal: number;
496
+ /** BP delta vs the deposit pre-paid at booking. */
497
+ bpDelta: number;
498
+ /** True if either cap kicked in. */
499
+ cappedUsd: boolean;
500
+ cappedBp: boolean;
501
+ }
502
+
503
+ /**
504
+ * Pure function for mixed PER_PAID_ATTENDEE / PER_BASHPOINTS_ATTENDEE
505
+ * settlement. Either side can be unset (the worker only computes the side
506
+ * that applies). Caller is responsible for the actual Stripe + BP ledger
507
+ * postings.
508
+ */
509
+ export function computeEventPromoterMixedSettlement(
510
+ input: EventPromoterMixedSettlementInput
511
+ ): EventPromoterMixedSettlementResult {
512
+ // --- USD side -----------------------------------------------------------
513
+ const usdRate = Math.max(0, input.baseRateCents ?? 0);
514
+ let usdCount = pickCheckedInForBasis("PaidUsdOnly", input.snapshots);
515
+ const usdCeiling =
516
+ input.maxUsdAttendeesAttributable != null &&
517
+ input.maxUsdAttendeesAttributable >= 0
518
+ ? input.maxUsdAttendeesAttributable
519
+ : Number.POSITIVE_INFINITY;
520
+ usdCount = Math.min(usdCount, usdCeiling);
521
+
522
+ let usdTotalCents = usdRate * usdCount;
523
+ let cappedUsd = false;
524
+ if (
525
+ input.maxBillableCents != null &&
526
+ input.maxBillableCents >= 0 &&
527
+ usdTotalCents > input.maxBillableCents
528
+ ) {
529
+ usdTotalCents = input.maxBillableCents;
530
+ cappedUsd = true;
531
+ }
532
+ const usdExpected =
533
+ Math.max(0, input.expectedPaidAttendeesAtBooking ?? 0) * usdRate;
534
+ const usdDeltaCents = usdTotalCents - usdExpected;
535
+
536
+ // --- BP side ------------------------------------------------------------
537
+ const bpRate = Math.max(0, input.baseRateBashPoints ?? 0);
538
+ let bpCount = pickCheckedInForBasis("PaidBpOnly", input.snapshots);
539
+ const bpCeiling =
540
+ input.maxBpAttendeesAttributable != null &&
541
+ input.maxBpAttendeesAttributable >= 0
542
+ ? input.maxBpAttendeesAttributable
543
+ : Number.POSITIVE_INFINITY;
544
+ bpCount = Math.min(bpCount, bpCeiling);
545
+
546
+ let bpTotal = bpRate * bpCount;
547
+ let cappedBp = false;
548
+ if (
549
+ input.maxBillableBashPoints != null &&
550
+ input.maxBillableBashPoints >= 0 &&
551
+ bpTotal > input.maxBillableBashPoints
552
+ ) {
553
+ bpTotal = input.maxBillableBashPoints;
554
+ cappedBp = true;
555
+ }
556
+ const bpExpected =
557
+ Math.max(0, input.expectedBpAttendeesAtBooking ?? 0) * bpRate;
558
+ const bpDelta = bpTotal - bpExpected;
559
+
560
+ return {
561
+ usdTotalCents,
562
+ usdDeltaCents,
563
+ bpTotal,
564
+ bpDelta,
565
+ cappedUsd,
566
+ cappedBp,
567
+ };
568
+ }
569
+
570
+ // --------------------------------------------------------------------------
571
+ // Phase 3 — BashPoints payout source resolver (pool-then-wallet).
572
+ // --------------------------------------------------------------------------
573
+
574
+ export type BashPointsPayoutSourceValue = "HostWallet" | "BashBpPool";
575
+
576
+ export interface BashPointsPayoutResolverInput {
577
+ /** From `BashEvent.bashPointsPayoutSource`. */
578
+ source: BashPointsPayoutSourceValue;
579
+ /** BP the worker owes the provider for this settlement. */
580
+ payoutBp: number;
581
+ /** BP the host has in their wallet at settlement time. */
582
+ hostWalletBp: number;
583
+ /**
584
+ * For `BashBpPool` source: live remaining BP in the bash's BP pool. This is
585
+ * `sum(BP from BP-ticket purchases) − sum(BP already paid out) −
586
+ * sum(outstanding BP-refund obligations)`. Caller computes; resolver only
587
+ * drains.
588
+ */
589
+ bashPoolBp?: number;
590
+ }
591
+
592
+ export interface BashPointsPayoutResolverResult {
593
+ /** BP drained from the bash's BashPoints pool. */
594
+ fromPoolBp: number;
595
+ /** BP debited from the host's wallet. */
596
+ fromWalletBp: number;
597
+ /** BP that could NOT be sourced (resulting failure → dunning). */
598
+ shortfallBp: number;
599
+ /** True when fromWalletBp > 0 — used to surface UI / push notifications. */
600
+ walletDebited: boolean;
601
+ }
602
+
603
+ /**
604
+ * Pure function that decides which BashPoints buckets to drain to satisfy a
605
+ * provider payout. Mirrors the worker's behavior:
606
+ *
607
+ * - `HostWallet`: take everything from the host's wallet; any shortfall is
608
+ * a payment failure (`SettlementPaymentFailed`).
609
+ * - `BashBpPool`: drain the pool first; pay the remainder from the host's
610
+ * wallet; if still short, mark `SettlementPaymentFailed`.
611
+ *
612
+ * Negative inputs are clamped to zero so the resolver never returns a
613
+ * negative bucket.
614
+ */
615
+ export function resolveBashPointsPayoutSources(
616
+ input: BashPointsPayoutResolverInput
617
+ ): BashPointsPayoutResolverResult {
618
+ const need = Math.max(0, Math.floor(input.payoutBp));
619
+ const wallet = Math.max(0, Math.floor(input.hostWalletBp));
620
+ const pool = Math.max(0, Math.floor(input.bashPoolBp ?? 0));
621
+
622
+ if (input.source === "HostWallet") {
623
+ const fromWalletBp = Math.min(need, wallet);
624
+ const shortfallBp = need - fromWalletBp;
625
+ return {
626
+ fromPoolBp: 0,
627
+ fromWalletBp,
628
+ shortfallBp,
629
+ walletDebited: fromWalletBp > 0,
630
+ };
631
+ }
632
+
633
+ // BashBpPool
634
+ const fromPoolBp = Math.min(need, pool);
635
+ const remainAfterPool = need - fromPoolBp;
636
+ const fromWalletBp = Math.min(remainAfterPool, wallet);
637
+ const shortfallBp = remainAfterPool - fromWalletBp;
638
+ return {
639
+ fromPoolBp,
640
+ fromWalletBp,
641
+ shortfallBp,
642
+ walletDebited: fromWalletBp > 0,
643
+ };
644
+ }