@bash-app/bash-common 30.432.0 → 30.434.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 (164) hide show
  1. package/dist/__tests__/appleIapProducts.test.js +1 -0
  2. package/dist/__tests__/appleIapProducts.test.js.map +1 -1
  3. package/dist/__tests__/bashSectionTitleForBashType.test.js +2 -0
  4. package/dist/__tests__/bashSectionTitleForBashType.test.js.map +1 -1
  5. package/dist/__tests__/discoveryScope.test.js +9 -1
  6. package/dist/__tests__/discoveryScope.test.js.map +1 -1
  7. package/dist/__tests__/promoCartEngine.test.d.ts +2 -0
  8. package/dist/__tests__/promoCartEngine.test.d.ts.map +1 -0
  9. package/dist/__tests__/promoCartEngine.test.js +174 -0
  10. package/dist/__tests__/promoCartEngine.test.js.map +1 -0
  11. package/dist/__tests__/promoCodeRedemption.test.d.ts +2 -0
  12. package/dist/__tests__/promoCodeRedemption.test.d.ts.map +1 -0
  13. package/dist/__tests__/promoCodeRedemption.test.js +103 -0
  14. package/dist/__tests__/promoCodeRedemption.test.js.map +1 -0
  15. package/dist/__tests__/promoCodeScope.test.d.ts +2 -0
  16. package/dist/__tests__/promoCodeScope.test.d.ts.map +1 -0
  17. package/dist/__tests__/promoCodeScope.test.js +84 -0
  18. package/dist/__tests__/promoCodeScope.test.js.map +1 -0
  19. package/dist/__tests__/statusEnums.test.js +1 -0
  20. package/dist/__tests__/statusEnums.test.js.map +1 -1
  21. package/dist/appleIapProducts.d.ts +12 -2
  22. package/dist/appleIapProducts.d.ts.map +1 -1
  23. package/dist/appleIapProducts.js +13 -0
  24. package/dist/appleIapProducts.js.map +1 -1
  25. package/dist/definitions.d.ts +3 -1
  26. package/dist/definitions.d.ts.map +1 -1
  27. package/dist/definitions.js +4 -2
  28. package/dist/definitions.js.map +1 -1
  29. package/dist/discoveryScope.d.ts +6 -6
  30. package/dist/discoveryScope.d.ts.map +1 -1
  31. package/dist/discoveryScope.js +25 -16
  32. package/dist/discoveryScope.js.map +1 -1
  33. package/dist/extendedSchemas.d.ts +1 -0
  34. package/dist/extendedSchemas.d.ts.map +1 -1
  35. package/dist/extendedSchemas.js.map +1 -1
  36. package/dist/index.d.ts +4 -0
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +4 -0
  39. package/dist/index.js.map +1 -1
  40. package/dist/intros/__tests__/introsReleaseGates.test.d.ts +2 -0
  41. package/dist/intros/__tests__/introsReleaseGates.test.d.ts.map +1 -0
  42. package/dist/intros/__tests__/introsReleaseGates.test.js +140 -0
  43. package/dist/intros/__tests__/introsReleaseGates.test.js.map +1 -0
  44. package/dist/intros/__tests__/psychExperiment.test.d.ts +2 -0
  45. package/dist/intros/__tests__/psychExperiment.test.d.ts.map +1 -0
  46. package/dist/intros/__tests__/psychExperiment.test.js +63 -0
  47. package/dist/intros/__tests__/psychExperiment.test.js.map +1 -0
  48. package/dist/intros/__tests__/reasons.test.d.ts +2 -0
  49. package/dist/intros/__tests__/reasons.test.d.ts.map +1 -0
  50. package/dist/intros/__tests__/reasons.test.js +72 -0
  51. package/dist/intros/__tests__/reasons.test.js.map +1 -0
  52. package/dist/intros/__tests__/sharedNotes.test.d.ts +2 -0
  53. package/dist/intros/__tests__/sharedNotes.test.d.ts.map +1 -0
  54. package/dist/intros/__tests__/sharedNotes.test.js +19 -0
  55. package/dist/intros/__tests__/sharedNotes.test.js.map +1 -0
  56. package/dist/intros/__tests__/transitions.test.d.ts +2 -0
  57. package/dist/intros/__tests__/transitions.test.d.ts.map +1 -0
  58. package/dist/intros/__tests__/transitions.test.js +43 -0
  59. package/dist/intros/__tests__/transitions.test.js.map +1 -0
  60. package/dist/intros/__tests__/warmDisclosure.test.d.ts +2 -0
  61. package/dist/intros/__tests__/warmDisclosure.test.d.ts.map +1 -0
  62. package/dist/intros/__tests__/warmDisclosure.test.js +10 -0
  63. package/dist/intros/__tests__/warmDisclosure.test.js.map +1 -0
  64. package/dist/intros/age.d.ts +4 -0
  65. package/dist/intros/age.d.ts.map +1 -0
  66. package/dist/intros/age.js +14 -0
  67. package/dist/intros/age.js.map +1 -0
  68. package/dist/intros/attendance.d.ts +11 -0
  69. package/dist/intros/attendance.d.ts.map +1 -0
  70. package/dist/intros/attendance.js +27 -0
  71. package/dist/intros/attendance.js.map +1 -0
  72. package/dist/intros/attributeCatalog.d.ts +17 -0
  73. package/dist/intros/attributeCatalog.d.ts.map +1 -0
  74. package/dist/intros/attributeCatalog.js +96 -0
  75. package/dist/intros/attributeCatalog.js.map +1 -0
  76. package/dist/intros/index.d.ts +10 -0
  77. package/dist/intros/index.d.ts.map +1 -0
  78. package/dist/intros/index.js +10 -0
  79. package/dist/intros/index.js.map +1 -0
  80. package/dist/intros/intents.d.ts +8 -0
  81. package/dist/intros/intents.d.ts.map +1 -0
  82. package/dist/intros/intents.js +44 -0
  83. package/dist/intros/intents.js.map +1 -0
  84. package/dist/intros/psychExperiment.d.ts +29 -0
  85. package/dist/intros/psychExperiment.d.ts.map +1 -0
  86. package/dist/intros/psychExperiment.js +82 -0
  87. package/dist/intros/psychExperiment.js.map +1 -0
  88. package/dist/intros/reasons.d.ts +38 -0
  89. package/dist/intros/reasons.d.ts.map +1 -0
  90. package/dist/intros/reasons.js +162 -0
  91. package/dist/intros/reasons.js.map +1 -0
  92. package/dist/intros/sharedNotes.d.ts +10 -0
  93. package/dist/intros/sharedNotes.d.ts.map +1 -0
  94. package/dist/intros/sharedNotes.js +56 -0
  95. package/dist/intros/sharedNotes.js.map +1 -0
  96. package/dist/intros/transitions.d.ts +29 -0
  97. package/dist/intros/transitions.d.ts.map +1 -0
  98. package/dist/intros/transitions.js +128 -0
  99. package/dist/intros/transitions.js.map +1 -0
  100. package/dist/intros/validation.d.ts +50 -0
  101. package/dist/intros/validation.d.ts.map +1 -0
  102. package/dist/intros/validation.js +212 -0
  103. package/dist/intros/validation.js.map +1 -0
  104. package/dist/mirroredPrismaEnums.d.ts +1 -0
  105. package/dist/mirroredPrismaEnums.d.ts.map +1 -1
  106. package/dist/mirroredPrismaEnums.js +1 -0
  107. package/dist/mirroredPrismaEnums.js.map +1 -1
  108. package/dist/promoCartEngine.d.ts +55 -0
  109. package/dist/promoCartEngine.d.ts.map +1 -0
  110. package/dist/promoCartEngine.js +135 -0
  111. package/dist/promoCartEngine.js.map +1 -0
  112. package/dist/promoCodeRedemption.d.ts +44 -0
  113. package/dist/promoCodeRedemption.d.ts.map +1 -0
  114. package/dist/promoCodeRedemption.js +81 -0
  115. package/dist/promoCodeRedemption.js.map +1 -0
  116. package/dist/promoCodeScope.d.ts +91 -0
  117. package/dist/promoCodeScope.d.ts.map +1 -0
  118. package/dist/promoCodeScope.js +102 -0
  119. package/dist/promoCodeScope.js.map +1 -0
  120. package/dist/utils/__tests__/discountEngine/discountCalculator.test.js +8 -1
  121. package/dist/utils/__tests__/discountEngine/discountCalculator.test.js.map +1 -1
  122. package/dist/utils/__tests__/paymentUtils.test.js +87 -4
  123. package/dist/utils/__tests__/paymentUtils.test.js.map +1 -1
  124. package/dist/utils/paymentUtils.d.ts +30 -5
  125. package/dist/utils/paymentUtils.d.ts.map +1 -1
  126. package/dist/utils/paymentUtils.js +43 -11
  127. package/dist/utils/paymentUtils.js.map +1 -1
  128. package/package.json +1 -1
  129. package/prisma/schema.prisma +427 -6
  130. package/src/__tests__/appleIapProducts.test.ts +3 -0
  131. package/src/__tests__/bashSectionTitleForBashType.test.ts +4 -0
  132. package/src/__tests__/discoveryScope.test.ts +22 -1
  133. package/src/__tests__/promoCartEngine.test.ts +201 -0
  134. package/src/__tests__/promoCodeRedemption.test.ts +134 -0
  135. package/src/__tests__/promoCodeScope.test.ts +119 -0
  136. package/src/__tests__/statusEnums.test.ts +1 -0
  137. package/src/appleIapProducts.ts +29 -2
  138. package/src/definitions.ts +4 -2
  139. package/src/discoveryScope.ts +30 -20
  140. package/src/extendedSchemas.ts +1 -0
  141. package/src/index.ts +4 -0
  142. package/src/intros/__tests__/introsReleaseGates.test.ts +181 -0
  143. package/src/intros/__tests__/psychExperiment.test.ts +99 -0
  144. package/src/intros/__tests__/reasons.test.ts +122 -0
  145. package/src/intros/__tests__/sharedNotes.test.ts +20 -0
  146. package/src/intros/__tests__/transitions.test.ts +61 -0
  147. package/src/intros/__tests__/warmDisclosure.test.ts +10 -0
  148. package/src/intros/age.ts +13 -0
  149. package/src/intros/attendance.ts +33 -0
  150. package/src/intros/attributeCatalog.ts +113 -0
  151. package/src/intros/index.ts +9 -0
  152. package/src/intros/intents.ts +49 -0
  153. package/src/intros/psychExperiment.ts +130 -0
  154. package/src/intros/reasons.ts +187 -0
  155. package/src/intros/sharedNotes.ts +59 -0
  156. package/src/intros/transitions.ts +172 -0
  157. package/src/intros/validation.ts +248 -0
  158. package/src/mirroredPrismaEnums.ts +1 -0
  159. package/src/promoCartEngine.ts +193 -0
  160. package/src/promoCodeRedemption.ts +123 -0
  161. package/src/promoCodeScope.ts +180 -0
  162. package/src/utils/__tests__/discountEngine/discountCalculator.test.ts +8 -0
  163. package/src/utils/__tests__/paymentUtils.test.ts +100 -6
  164. package/src/utils/paymentUtils.ts +56 -13
@@ -0,0 +1,180 @@
1
+ import { getPublicBashDetailPath } from "./utils/slugUtils.js";
2
+
3
+ /**
4
+ * Which bash and tiers a promo code applies to, and what kind of discount it
5
+ * gives, for both legacy single-tier rows and bash-level rows. Every reader goes
6
+ * through these instead of `promo.ticketTier.bashEventId` or `promo.ticketTierId`.
7
+ */
8
+
9
+ export type PromoDiscountKind = "NONE" | "PERCENT" | "FIXED_CASH";
10
+ export type PromoEligibilityMode = "ALL_TIERS" | "SELECTED_TIERS";
11
+
12
+ /** How the host pays a promoter for a code. BashPoints is credited here; the others are paid outside Bash. */
13
+ export const PROMO_COMPENSATION_METHODS = ["Venmo", "Zelle", "CashApp", "BashPoints"] as const;
14
+ export type PromoCompensationMethod = (typeof PROMO_COMPENSATION_METHODS)[number];
15
+
16
+ export function isPromoCompensationMethod(value: unknown): value is PromoCompensationMethod {
17
+ return (
18
+ typeof value === "string" &&
19
+ (PROMO_COMPENSATION_METHODS as readonly string[]).includes(value)
20
+ );
21
+ }
22
+
23
+ export type PromoScopeFields = {
24
+ ticketTierId?: string | null;
25
+ bashEventId?: string | null;
26
+ eligibilityMode?: PromoEligibilityMode | null;
27
+ discountType?: PromoDiscountKind | null;
28
+ discountAmountInCents?: number | null;
29
+ discountAmountPercentage?: number | null;
30
+ eligibleTiers?: ReadonlyArray<{ ticketTierId: string }> | null;
31
+ ticketTier?: { bashEventId: string } | null;
32
+ };
33
+
34
+ /** Explicit type wins; legacy rows derive it from whichever amount is set. */
35
+ export function promoCodeDiscountKind(promo: PromoScopeFields): PromoDiscountKind {
36
+ if (promo.discountType) return promo.discountType;
37
+ if ((promo.discountAmountPercentage ?? 0) > 0) return "PERCENT";
38
+ if ((promo.discountAmountInCents ?? 0) > 0) return "FIXED_CASH";
39
+ return "NONE";
40
+ }
41
+
42
+ export function promoCodeBashEventId(promo: PromoScopeFields): string | null {
43
+ return promo.bashEventId ?? promo.ticketTier?.bashEventId ?? null;
44
+ }
45
+
46
+ /**
47
+ * "ALL" for an all-tiers code (including tiers added later); otherwise the
48
+ * selected tier ids, falling back to the legacy single tier.
49
+ */
50
+ export function promoCodeEligibleTierIds(promo: PromoScopeFields): "ALL" | string[] {
51
+ if (promo.eligibilityMode === "ALL_TIERS") return "ALL";
52
+ const selected = (promo.eligibleTiers ?? []).map((t) => t.ticketTierId);
53
+ if (selected.length > 0) return [...new Set(selected)];
54
+ return promo.ticketTierId ? [promo.ticketTierId] : [];
55
+ }
56
+
57
+ export function promoCodeAppliesToTier(promo: PromoScopeFields, ticketTierId: string): boolean {
58
+ const ids = promoCodeEligibleTierIds(promo);
59
+ return ids === "ALL" || ids.includes(ticketTierId);
60
+ }
61
+
62
+ export type PromoCodeStatusReason =
63
+ | "expired"
64
+ | "exhausted"
65
+ | "already_used"
66
+ | "disabled"
67
+ | "invalid"
68
+ | "host_review_required";
69
+
70
+ /** Public, logged-out view of a code. Never includes a promoter id or email. */
71
+ export type PromoCodeStatus = {
72
+ status: "valid" | PromoCodeStatusReason;
73
+ reason: PromoCodeStatusReason | null;
74
+ code: string;
75
+ discountType: PromoDiscountKind | null;
76
+ /** Percent for PERCENT, cents for FIXED_CASH, 0 for NONE. */
77
+ value: number;
78
+ eligibilityMode: PromoEligibilityMode | null;
79
+ /** Empty for all-tiers codes. */
80
+ eligibleTierIds: string[];
81
+ appliesToBashPoints: boolean;
82
+ };
83
+
84
+ /**
85
+ * Promoter share link: `/bash/<active vanity>?promo=CODE`, else the public
86
+ * id-slug path. The code is uppercased and URL-encoded. Legacy `?promocode=`
87
+ * and `?ref=` links keep working on the bash page.
88
+ */
89
+ export function getPromoterShareUrl(args: {
90
+ bashEventId: string;
91
+ slug?: string | null;
92
+ vanityPaths?: ReadonlyArray<{ segment: string; status: string }> | null;
93
+ code: string;
94
+ /** Absolute origin, e.g. "https://bash.community". Omit for a relative path. */
95
+ origin?: string;
96
+ }): string {
97
+ const active = args.vanityPaths?.find((v) => v.status === "Active" && v.segment.trim());
98
+ const path = active
99
+ ? `/bash/${encodeURIComponent(active.segment.trim().toLowerCase())}`
100
+ : getPublicBashDetailPath(args.bashEventId, args.slug);
101
+ const query = `?promo=${encodeURIComponent(args.code.trim().toUpperCase())}`;
102
+ const origin = args.origin?.replace(/\/$/, "") ?? "";
103
+ return `${origin}${path}${query}`;
104
+ }
105
+
106
+ /** Host form payload for a bash-level promoter or promo code. */
107
+ export type PromoterCodeInput = {
108
+ code: string;
109
+ promoterUserId?: string | null;
110
+ promoterEmail?: string | null;
111
+ discountType: PromoDiscountKind;
112
+ /** Percent (0-100) for PERCENT, cents (0 or more) for FIXED_CASH, ignored for NONE. */
113
+ discountValue?: number | null;
114
+ eligibilityMode: PromoEligibilityMode;
115
+ eligibleTierIds?: string[];
116
+ discountAppliesToBashPoints?: boolean;
117
+ redeemBy?: string | null;
118
+ /** Maximum orders. */
119
+ maxRedemptions?: number | null;
120
+ maxUsesPerAccount?: number | null;
121
+ bashPointsRewardPerTicket?: number | null;
122
+ /** The promoter's rate for this bash, 0-10000 bps of eligible net ticket proceeds. */
123
+ promoterCommissionBps?: number | null;
124
+ /** The host opened and confirmed the Promoter reward section. */
125
+ promoterRewardReviewed?: boolean;
126
+ /** Venmo, Zelle, Cash App, or BashPoints. Null clears a saved choice. */
127
+ compensationMethod?: PromoCompensationMethod | null;
128
+ };
129
+
130
+ export type PromoterCodeStatusLabel = "active" | "expired" | "exhausted" | "disabled";
131
+
132
+ /** Validation shared by the host form and the API. Empty array means valid. */
133
+ export function promoterCodeInputErrors(
134
+ input: PromoterCodeInput,
135
+ bashTierIds: readonly string[]
136
+ ): string[] {
137
+ const errors: string[] = [];
138
+ const code = input.code?.trim() ?? "";
139
+ if (!code) errors.push("Enter a code.");
140
+ else if (!/^[A-Za-z0-9_-]{2,32}$/.test(code)) {
141
+ errors.push("Codes use 2-32 letters, numbers, dashes, or underscores.");
142
+ }
143
+ if (input.discountType === "PERCENT") {
144
+ const pct = Number(input.discountValue);
145
+ if (!Number.isInteger(pct) || pct < 0 || pct > 100) {
146
+ errors.push("Guest discount must be between 0% and 100%.");
147
+ }
148
+ } else if (input.discountType === "FIXED_CASH") {
149
+ const cents = Number(input.discountValue);
150
+ if (!Number.isInteger(cents) || cents < 0) errors.push("Enter a fixed discount of $0 or more.");
151
+ }
152
+ if (input.eligibilityMode === "SELECTED_TIERS") {
153
+ const ids = input.eligibleTierIds ?? [];
154
+ if (ids.length === 0) errors.push("Pick at least one ticket tier.");
155
+ if (ids.some((id) => !bashTierIds.includes(id))) errors.push("A selected tier isn't on this bash.");
156
+ }
157
+ if (input.promoterCommissionBps != null) {
158
+ const bps = input.promoterCommissionBps;
159
+ if (!Number.isInteger(bps) || bps < 0 || bps > 10_000) {
160
+ errors.push("Promoter commission must be between 0% and 100%.");
161
+ }
162
+ }
163
+ for (const [label, value] of [
164
+ ["Maximum orders", input.maxRedemptions],
165
+ ["Per-guest limit", input.maxUsesPerAccount],
166
+ ["BashPoints per ticket", input.bashPointsRewardPerTicket],
167
+ ] as const) {
168
+ if (value != null && (!Number.isInteger(value) || value < 0)) {
169
+ errors.push(`${label} must be a whole number of 0 or more.`);
170
+ }
171
+ }
172
+ const hasPromoter = !!(input.promoterUserId || input.promoterEmail?.trim());
173
+ if (hasPromoter && !input.promoterRewardReviewed) {
174
+ errors.push("Review the promoter reward before saving.");
175
+ }
176
+ if (input.compensationMethod != null && !isPromoCompensationMethod(input.compensationMethod)) {
177
+ errors.push("Choose Venmo, Zelle, Cash App, or BashPoints.");
178
+ }
179
+ return errors;
180
+ }
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  OfferDiscountType,
3
+ PromoCodeEligibilityMode,
3
4
  SpecialOffer,
4
5
  SpecialOfferType,
5
6
  } from "@prisma/client";
@@ -23,10 +24,17 @@ function basePromoCode(
23
24
  Pick<BashEventPromoCodeWithPromoter, "id" | "code">
24
25
  ): BashEventPromoCodeWithPromoter {
25
26
  return {
27
+ bashEventId: null,
26
28
  ticketTierId: "tier-1",
29
+ eligibilityMode: PromoCodeEligibilityMode.SELECTED_TIERS,
30
+ discountType: null,
31
+ discountAppliesToBashPoints: false,
32
+ disabledAt: null,
33
+ legacyReviewFlag: null,
27
34
  discountAmountInCents: null,
28
35
  discountAmountPercentage: null,
29
36
  maxRedemptions: null,
37
+ maxUsesPerAccount: null,
30
38
  redeemBy: null,
31
39
  promoterId: null,
32
40
  stripeCouponId: null,
@@ -23,7 +23,9 @@ import {
23
23
  computeTicketApplicationFeeAmountCents,
24
24
  computeGuestTicketCheckoutBreakdown,
25
25
  ticketCommissionCents,
26
- clampTicketCommissionBps,
26
+ eligibleNetTicketProceedsCents,
27
+ ticketCommissionBpsError,
28
+ TEMPORARY_SETTLEMENT_SAFETY_REMAINDER_CENTS,
27
29
  MAX_TICKET_COMMISSION_BPS,
28
30
  guestAllInUnitPriceCents,
29
31
  guestCheckoutHasNoAddedFees,
@@ -282,13 +284,105 @@ describe("PaymentUtils - Core Functions", () => {
282
284
  expect(r.applicationFeeAmountCents).toBe(r.stripeProcessingCents);
283
285
  });
284
286
 
285
- test("ticketCommissionCents floors bps of the subtotal and caps at 50%", () => {
287
+ test("ticketCommissionCents floors bps of the basis and pays up to 100%", () => {
288
+ expect(MAX_TICKET_COMMISSION_BPS).toBe(10000);
286
289
  expect(ticketCommissionCents(10000, 1500)).toBe(1500);
287
290
  expect(ticketCommissionCents(10000, 0)).toBe(0);
288
- expect(ticketCommissionCents(10000, MAX_TICKET_COMMISSION_BPS + 100)).toBe(
289
- 5000
290
- );
291
- expect(clampTicketCommissionBps(99999)).toBe(MAX_TICKET_COMMISSION_BPS);
291
+ expect(ticketCommissionCents(1912, 10000)).toBe(1912);
292
+ expect(ticketCommissionCents(1912, 5000)).toBe(956);
293
+ expect(ticketCommissionCents(1912, 99999)).toBe(1912);
294
+ expect(ticketCommissionCents(0, 10000)).toBe(0);
295
+ });
296
+
297
+ test("ticketCommissionBpsError accepts 0-100% and rejects the rest", () => {
298
+ expect(ticketCommissionBpsError(0)).toBeNull();
299
+ expect(ticketCommissionBpsError(5000)).toBeNull();
300
+ expect(ticketCommissionBpsError(10000)).toBeNull();
301
+ expect(ticketCommissionBpsError(null)).toBeNull();
302
+ expect(ticketCommissionBpsError(10001)).toMatch(/between 0% and 100%/);
303
+ expect(ticketCommissionBpsError(-1)).toMatch(/between 0% and 100%/);
304
+ expect(ticketCommissionBpsError(12.5)).toMatch(/whole number/);
305
+ });
306
+
307
+ describe("eligibleNetTicketProceedsCents: $20 sale", () => {
308
+ const fees = (feeHandling: string, platformFeeEnabled: boolean) =>
309
+ computeTicketApplicationFeeAmountCents({
310
+ discountedSubtotalCents: 2000,
311
+ platformFeeEnabled,
312
+ platformFeeRate: 0.03,
313
+ feeHandling,
314
+ bashPassWaivesGuestFee: false,
315
+ });
316
+ const net = (f: ReturnType<typeof fees>, eligible = 2000) =>
317
+ eligibleNetTicketProceedsCents({
318
+ eligibleSubtotalCents: eligible,
319
+ discountedSubtotalCents: 2000,
320
+ bashPlatformTotalCents: f.bashPlatformTotalCents,
321
+ guestPlatformLineCents: f.guestPlatformLineCents,
322
+ stripeProcessingCents: f.stripeProcessingCents,
323
+ guestProcessingLineCents: f.guestProcessingLineCents,
324
+ });
325
+
326
+ test("host absorbs, platform fee off: 2000 - 88 = 1912; 100% owes 1912, 50% owes 956", () => {
327
+ const f = fees("HostAbsorbs", false);
328
+ expect(f.applicationFeeAmountCents).toBe(88);
329
+ expect(net(f)).toBe(1912);
330
+ expect(ticketCommissionCents(net(f), 10000)).toBe(1912);
331
+ expect(ticketCommissionCents(net(f), 5000)).toBe(956);
332
+ });
333
+
334
+ test("host absorbs a 3% platform fee: app fee 148, net 1852", () => {
335
+ const f = fees("HostAbsorbs", true);
336
+ expect(f.applicationFeeAmountCents).toBe(148);
337
+ expect(net(f)).toBe(1852);
338
+ expect(ticketCommissionCents(net(f), 5000)).toBe(926);
339
+ });
340
+
341
+ test("guest pays the fees: the guest lines cover them, so the host share is close to the subtotal", () => {
342
+ const f = fees("GuestPays", true);
343
+ const hostShare =
344
+ Math.max(0, f.bashPlatformTotalCents - f.guestPlatformLineCents) +
345
+ Math.max(0, f.stripeProcessingCents - f.guestProcessingLineCents);
346
+ expect(net(f)).toBe(2000 - hostShare);
347
+ expect(net(f)).toBeGreaterThan(1900);
348
+ });
349
+
350
+ test("only the code's tier counts: a $20 eligible line in a $40 cart carries half the fees", () => {
351
+ const both = computeTicketApplicationFeeAmountCents({
352
+ discountedSubtotalCents: 4000,
353
+ platformFeeEnabled: false,
354
+ platformFeeRate: 0.03,
355
+ feeHandling: "HostAbsorbs",
356
+ bashPassWaivesGuestFee: false,
357
+ });
358
+ const eligible = eligibleNetTicketProceedsCents({
359
+ eligibleSubtotalCents: 2000,
360
+ discountedSubtotalCents: 4000,
361
+ bashPlatformTotalCents: both.bashPlatformTotalCents,
362
+ guestPlatformLineCents: both.guestPlatformLineCents,
363
+ stripeProcessingCents: both.stripeProcessingCents,
364
+ guestProcessingLineCents: both.guestProcessingLineCents,
365
+ });
366
+ expect(eligible).toBe(2000 - Math.ceil(both.stripeProcessingCents / 2));
367
+ });
368
+
369
+ test("zero eligible proceeds owe nothing", () => {
370
+ expect(net(fees("HostAbsorbs", false), 0)).toBe(0);
371
+ });
372
+ });
373
+
374
+ test("withholding can take the full eligible net when the safety remainder is 0", () => {
375
+ expect(TEMPORARY_SETTLEMENT_SAFETY_REMAINDER_CENTS).toBe(0);
376
+ const r = computeTicketApplicationFeeAmountCents({
377
+ discountedSubtotalCents: 2000,
378
+ platformFeeEnabled: false,
379
+ platformFeeRate: 0.03,
380
+ feeHandling: "HostAbsorbs",
381
+ bashPassWaivesGuestFee: false,
382
+ performerCommissionCents: 1912,
383
+ });
384
+ expect(r.performerCommissionCents).toBe(1912);
385
+ expect(r.totalChargedCents - r.applicationFeeAmountCents).toBe(0);
292
386
  });
293
387
 
294
388
  test("PROMO-002: performer cut is withheld on the application fee, not the guest total", () => {
@@ -448,12 +448,35 @@ export function lowestGuestAllInPriceCents(
448
448
  return min;
449
449
  }
450
450
 
451
- /** Max share of attributed ticket subtotal a host can assign to a performer (50%). */
452
- export const MAX_TICKET_COMMISSION_BPS = 5000;
451
+ /** A host can assign up to 100% of eligible net ticket proceeds to a promoter. */
452
+ export const MAX_TICKET_COMMISSION_BPS = 10_000;
453
453
 
454
454
  /**
455
- * Floor cents owed to a promo-code performer from the discounted ticket subtotal.
456
- * Guest price is unchanged; this amount is withheld from the host via application_fee.
455
+ * Cents left on the host transfer when withholding commission. Stripe test mode
456
+ * accepted application_fee_amount equal to the charge (check 4b, 2026-10-14),
457
+ * so a $0 host transfer is allowed and this stays 0. This is not a host share.
458
+ */
459
+ export const TEMPORARY_SETTLEMENT_SAFETY_REMAINDER_CENTS = 0;
460
+
461
+ /** Error message for an out-of-range rate, or null when 0-100% (0-10000 bps). */
462
+ export function ticketCommissionBpsError(
463
+ commissionBps: number | null | undefined
464
+ ): string | null {
465
+ if (commissionBps == null) return null;
466
+ if (!Number.isInteger(commissionBps)) {
467
+ return "Commission must be a whole number of basis points.";
468
+ }
469
+ if (commissionBps < 0 || commissionBps > MAX_TICKET_COMMISSION_BPS) {
470
+ return "Commission must be between 0% and 100% of eligible net ticket proceeds.";
471
+ }
472
+ return null;
473
+ }
474
+
475
+ /**
476
+ * Floor cents owed to a promoter: `commissionBps` of `basisCents`, where the basis
477
+ * is eligible net ticket proceeds. Guest price is unchanged; the amount is
478
+ * withheld from the host via application_fee. Rates are validated when written;
479
+ * this never pays more than 100% of the basis.
457
480
  */
458
481
  export function ticketCommissionCents(
459
482
  basisCents: number,
@@ -462,16 +485,34 @@ export function ticketCommissionCents(
462
485
  if (basisCents <= 0) return 0;
463
486
  const bps = Math.floor(Number(commissionBps) || 0);
464
487
  if (bps <= 0) return 0;
465
- const clamped = Math.min(MAX_TICKET_COMMISSION_BPS, bps);
466
- return Math.floor((basisCents * clamped) / 10_000);
488
+ return Math.floor((basisCents * Math.min(MAX_TICKET_COMMISSION_BPS, bps)) / 10_000);
467
489
  }
468
490
 
469
- export function clampTicketCommissionBps(
470
- commissionBps: number | null | undefined
471
- ): number {
472
- const bps = Math.floor(Number(commissionBps) || 0);
473
- if (bps <= 0) return 0;
474
- return Math.min(MAX_TICKET_COMMISSION_BPS, bps);
491
+ /**
492
+ * What the host would otherwise receive for the eligible ticket lines: the
493
+ * eligible discounted subtotal, less its share of the Bash platform fee and the
494
+ * Stripe processing estimate that no guest fee line pays. Tax, guest fee lines,
495
+ * coat check, and delivery are never part of it. Floored at 0.
496
+ */
497
+ export function eligibleNetTicketProceedsCents(args: {
498
+ /** Discounted subtotal of the lines the code applies to. */
499
+ eligibleSubtotalCents: number;
500
+ /** Discounted ticket subtotal of the whole cart (the fee basis). */
501
+ discountedSubtotalCents: number;
502
+ bashPlatformTotalCents: number;
503
+ guestPlatformLineCents: number;
504
+ stripeProcessingCents: number;
505
+ guestProcessingLineCents: number;
506
+ }): number {
507
+ const eligible = Math.max(0, Math.floor(args.eligibleSubtotalCents));
508
+ const cart = Math.max(0, Math.floor(args.discountedSubtotalCents));
509
+ if (eligible <= 0 || cart <= 0) return 0;
510
+ const share = Math.min(1, eligible / cart);
511
+ const hostPlatform = Math.max(0, args.bashPlatformTotalCents - args.guestPlatformLineCents);
512
+ const hostProcessing = Math.max(0, args.stripeProcessingCents - args.guestProcessingLineCents);
513
+ const net =
514
+ eligible - Math.ceil(hostPlatform * share) - Math.ceil(hostProcessing * share);
515
+ return Math.max(0, net);
475
516
  }
476
517
 
477
518
  /**
@@ -528,7 +569,9 @@ export function computeTicketApplicationFeeAmountCents(args: {
528
569
  );
529
570
  const maxWithhold = Math.max(
530
571
  0,
531
- breakdown.totalDueCents - breakdown.applicationFeeAmountCents - 1
572
+ breakdown.totalDueCents -
573
+ breakdown.applicationFeeAmountCents -
574
+ TEMPORARY_SETTLEMENT_SAFETY_REMAINDER_CENTS
532
575
  );
533
576
  const withheld = Math.min(performerCommissionCents, maxWithhold);
534
577
  return {