@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,201 @@
1
+ import {
2
+ allocateAcrossTickets,
3
+ allocateProportionally,
4
+ resolvePromoForCart,
5
+ type PromoCartLine,
6
+ } from "../promoCartEngine.js";
7
+
8
+ const now = new Date("2026-10-14T12:00:00Z");
9
+
10
+ const earlyBird: PromoCartLine = { ticketTierId: "early", pricing: "USD", quantity: 2, cashSubtotalCents: 2000, pointsSubtotal: 0 };
11
+ const regular: PromoCartLine = { ticketTierId: "regular", pricing: "USD", quantity: 1, cashSubtotalCents: 2500, pointsSubtotal: 0 };
12
+ const vip: PromoCartLine = { ticketTierId: "vip", pricing: "USD", quantity: 1, cashSubtotalCents: 7500, pointsSubtotal: 0 };
13
+ const points: PromoCartLine = { ticketTierId: "points", pricing: "BASHPOINTS", quantity: 2, cashSubtotalCents: 0, pointsSubtotal: 1000 };
14
+ const free: PromoCartLine = { ticketTierId: "free", pricing: "FREE", quantity: 3, cashSubtotalCents: 0, pointsSubtotal: 0 };
15
+
16
+ describe("allocateProportionally", () => {
17
+ it("splits by weight, flooring, with leftover to the largest line then tier id", () => {
18
+ const result = allocateProportionally(1000, [
19
+ { key: "b", weight: 1000 },
20
+ { key: "a", weight: 1000 },
21
+ { key: "c", weight: 1000 },
22
+ ]);
23
+ expect([...result.entries()].sort()).toEqual([["a", 334], ["b", 333], ["c", 333]]);
24
+ });
25
+
26
+ it("gives leftover cents to the largest line first", () => {
27
+ const result = allocateProportionally(1001, [
28
+ { key: "small", weight: 1000 },
29
+ { key: "big", weight: 3000 },
30
+ ]);
31
+ expect(result.get("big")).toBe(751);
32
+ expect(result.get("small")).toBe(250);
33
+ });
34
+
35
+ it("never allocates more than the eligible total", () => {
36
+ const result = allocateProportionally(9999, [{ key: "a", weight: 500 }]);
37
+ expect(result.get("a")).toBe(500);
38
+ });
39
+ });
40
+
41
+ describe("allocateAcrossTickets", () => {
42
+ it("splits evenly with leftover cents to the lowest ticket ids, summing exactly", () => {
43
+ const result = allocateAcrossTickets(1001, ["t3", "t1", "t2"]);
44
+ expect([...result.entries()]).toEqual([["t1", 334], ["t2", 334], ["t3", 333]]);
45
+ expect([...result.values()].reduce((s, v) => s + v, 0)).toBe(1001);
46
+ });
47
+ });
48
+
49
+ describe("resolvePromoForCart", () => {
50
+ it("attribution-only (NONE) on all tiers counts cash, BashPoints, and free tickets with no discount", () => {
51
+ const result = resolvePromoForCart({
52
+ promo: { eligibilityMode: "ALL_TIERS", discountType: "NONE" },
53
+ cartLines: [earlyBird, points, free],
54
+ now,
55
+ });
56
+ expect(result.status).toBe("applied");
57
+ expect(result.cashDiscountTotalCents).toBe(0);
58
+ expect(result.pointsDiscountTotal).toBe(0);
59
+ expect(result.attributedTicketCount).toBe(7);
60
+ });
61
+
62
+ it("attributes cash, hybrid, BashPoints-only, and free lines, including a tier added later, with a 0 guest discount", () => {
63
+ const hybrid: PromoCartLine = {
64
+ ticketTierId: "hybrid",
65
+ pricing: "USD",
66
+ quantity: 1,
67
+ cashSubtotalCents: 3000,
68
+ pointsSubtotal: 100,
69
+ };
70
+ const addedLater: PromoCartLine = {
71
+ ticketTierId: "tier_added_later",
72
+ pricing: "USD",
73
+ quantity: 4,
74
+ cashSubtotalCents: 4000,
75
+ pointsSubtotal: 0,
76
+ };
77
+ const result = resolvePromoForCart({
78
+ promo: { eligibilityMode: "ALL_TIERS", discountType: "NONE" },
79
+ cartLines: [earlyBird, hybrid, points, free, addedLater],
80
+ now,
81
+ });
82
+ expect(result.status).toBe("applied");
83
+ expect(result.cashDiscountTotalCents).toBe(0);
84
+ expect(result.pointsDiscountTotal).toBe(0);
85
+ expect(result.lines.map((line) => [line.ticketTierId, line.eligible, line.attributedTicketCount])).toEqual([
86
+ ["early", true, 2],
87
+ ["hybrid", true, 1],
88
+ ["points", true, 2],
89
+ ["free", true, 3],
90
+ ["tier_added_later", true, 4],
91
+ ]);
92
+ expect(result.attributedTicketCount).toBe(12);
93
+ });
94
+
95
+ it("percentage on selected tiers discounts only those cash lines", () => {
96
+ const result = resolvePromoForCart({
97
+ promo: {
98
+ eligibilityMode: "SELECTED_TIERS",
99
+ eligibleTiers: [{ ticketTierId: "regular" }, { ticketTierId: "vip" }],
100
+ discountType: "PERCENT",
101
+ discountAmountPercentage: 10,
102
+ },
103
+ cartLines: [earlyBird, regular, vip],
104
+ now,
105
+ });
106
+ expect(result.lines.map((l) => [l.ticketTierId, l.cashDiscountCents])).toEqual([
107
+ ["early", 0],
108
+ ["regular", 250],
109
+ ["vip", 750],
110
+ ]);
111
+ expect(result.attributedTicketCount).toBe(2);
112
+ });
113
+
114
+ it("percentage leaves BashPoints alone unless the host opts in", () => {
115
+ const off = resolvePromoForCart({
116
+ promo: { eligibilityMode: "ALL_TIERS", discountAmountPercentage: 20 },
117
+ cartLines: [points],
118
+ now,
119
+ });
120
+ expect(off.pointsDiscountTotal).toBe(0);
121
+ expect(off.attributedTicketCount).toBe(2);
122
+
123
+ const on = resolvePromoForCart({
124
+ promo: { eligibilityMode: "ALL_TIERS", discountAmountPercentage: 20, discountAppliesToBashPoints: true },
125
+ cartLines: [points],
126
+ now,
127
+ });
128
+ expect(on.pointsDiscountTotal).toBe(200);
129
+ expect(on.lines[0].finalPoints).toBe(800);
130
+ });
131
+
132
+ it("fixed cash applies once per order across eligible cash lines, never to points", () => {
133
+ const result = resolvePromoForCart({
134
+ promo: { eligibilityMode: "ALL_TIERS", discountType: "FIXED_CASH", discountAmountInCents: 1000 },
135
+ cartLines: [earlyBird, regular, points],
136
+ now,
137
+ });
138
+ expect(result.cashDiscountTotalCents).toBe(1000);
139
+ expect(result.pointsDiscountTotal).toBe(0);
140
+ const byTier = Object.fromEntries(result.lines.map((l) => [l.ticketTierId, l.cashDiscountCents]));
141
+ expect(byTier).toEqual({ early: 444, regular: 556, points: 0 });
142
+ });
143
+
144
+ it("caps a fixed discount at the eligible cash total and floors totals at 0", () => {
145
+ const result = resolvePromoForCart({
146
+ promo: { eligibilityMode: "ALL_TIERS", discountAmountInCents: 99999 },
147
+ cartLines: [regular],
148
+ now,
149
+ });
150
+ expect(result.lines[0].finalCashCents).toBe(0);
151
+ expect(result.cashDiscountTotalCents).toBe(2500);
152
+ });
153
+
154
+ it("caps a percentage above 100 at the line price", () => {
155
+ const result = resolvePromoForCart({
156
+ promo: { ticketTierId: "regular", discountAmountPercentage: 150 },
157
+ cartLines: [regular],
158
+ now,
159
+ });
160
+ expect(result.lines[0].cashDiscountCents).toBe(2500);
161
+ });
162
+
163
+ it("a legacy single-tier code keeps today's total: whole discount on its one line", () => {
164
+ const result = resolvePromoForCart({
165
+ promo: { ticketTierId: "regular", discountAmountInCents: 500 },
166
+ cartLines: [earlyBird, regular],
167
+ now,
168
+ });
169
+ expect(result.cashDiscountTotalCents).toBe(500);
170
+ expect(result.lines.find((l) => l.ticketTierId === "regular")?.cashDiscountCents).toBe(500);
171
+ });
172
+
173
+ it("an all-tiers code covers a tier added after the code was created", () => {
174
+ const result = resolvePromoForCart({
175
+ promo: { eligibilityMode: "ALL_TIERS", discountAmountPercentage: 10 },
176
+ cartLines: [{ ...regular, ticketTierId: "added_later" }],
177
+ now,
178
+ });
179
+ expect(result.lines[0].eligible).toBe(true);
180
+ });
181
+
182
+ it("reports not_applicable, expired, and disabled", () => {
183
+ expect(
184
+ resolvePromoForCart({ promo: { ticketTierId: "vip" }, cartLines: [regular], now }).status
185
+ ).toBe("not_applicable");
186
+ expect(
187
+ resolvePromoForCart({
188
+ promo: { eligibilityMode: "ALL_TIERS", redeemBy: new Date("2026-10-01") },
189
+ cartLines: [regular],
190
+ now,
191
+ }).status
192
+ ).toBe("expired");
193
+ expect(
194
+ resolvePromoForCart({
195
+ promo: { eligibilityMode: "ALL_TIERS", disabledAt: now },
196
+ cartLines: [regular],
197
+ now,
198
+ }).status
199
+ ).toBe("disabled");
200
+ });
201
+ });
@@ -0,0 +1,134 @@
1
+ import {
2
+ evaluatePromoRedeemability,
3
+ isPromoCodeRejectReason,
4
+ normalizePromoRedemptionEmail,
5
+ pickPromoCodeAmongDuplicates,
6
+ PROMO_CODE_REJECT_MESSAGES,
7
+ PROMO_CODE_REJECT_REASONS,
8
+ type PromoCodeDuplicateCandidate,
9
+ } from "../promoCodeRedemption.js";
10
+
11
+ const now = new Date("2026-10-14T12:00:00Z");
12
+
13
+ function base(overrides: Partial<Parameters<typeof evaluatePromoRedeemability>[0]> = {}) {
14
+ return {
15
+ redeemBy: null,
16
+ disabledAt: null,
17
+ maxRedemptions: null,
18
+ maxUsesPerAccount: null,
19
+ activeOrderCount: 0,
20
+ accountOrderCount: 0,
21
+ now,
22
+ ...overrides,
23
+ };
24
+ }
25
+
26
+ describe("evaluatePromoRedeemability", () => {
27
+ it("allows a code with no limits", () => {
28
+ expect(evaluatePromoRedeemability(base())).toBeNull();
29
+ });
30
+
31
+ it("rejects a disabled code before anything else", () => {
32
+ expect(
33
+ evaluatePromoRedeemability(
34
+ base({ disabledAt: now, redeemBy: new Date("2020-01-01"), activeOrderCount: 99, maxRedemptions: 1 })
35
+ )
36
+ ).toBe("disabled");
37
+ });
38
+
39
+ it("rejects a code past redeemBy and allows one still before it", () => {
40
+ expect(evaluatePromoRedeemability(base({ redeemBy: new Date("2026-10-14T11:59:59Z") }))).toBe("expired");
41
+ expect(evaluatePromoRedeemability(base({ redeemBy: new Date("2026-10-14T12:00:01Z") }))).toBeNull();
42
+ });
43
+
44
+ it("counts orders against maxRedemptions: 8 of 10 used leaves room, 10 of 10 is exhausted", () => {
45
+ expect(evaluatePromoRedeemability(base({ maxRedemptions: 10, activeOrderCount: 8 }))).toBeNull();
46
+ expect(evaluatePromoRedeemability(base({ maxRedemptions: 10, activeOrderCount: 10 }))).toBe("exhausted");
47
+ });
48
+
49
+ it("treats maxRedemptions 0 or null as unlimited", () => {
50
+ expect(evaluatePromoRedeemability(base({ maxRedemptions: 0, activeOrderCount: 50 }))).toBeNull();
51
+ expect(evaluatePromoRedeemability(base({ maxRedemptions: null, activeOrderCount: 50 }))).toBeNull();
52
+ });
53
+
54
+ it("applies the per-account limit only when set", () => {
55
+ expect(evaluatePromoRedeemability(base({ maxUsesPerAccount: 1, accountOrderCount: 1 }))).toBe("already_used");
56
+ expect(evaluatePromoRedeemability(base({ maxUsesPerAccount: 2, accountOrderCount: 1 }))).toBeNull();
57
+ expect(evaluatePromoRedeemability(base({ maxUsesPerAccount: null, accountOrderCount: 9 }))).toBeNull();
58
+ });
59
+ });
60
+
61
+ function row(id: string, overrides: Partial<PromoCodeDuplicateCandidate> = {}): PromoCodeDuplicateCandidate {
62
+ return {
63
+ id,
64
+ ticketTierId: `tier_${id}`,
65
+ promoterId: "promoter_row",
66
+ discountAmountInCents: 500,
67
+ discountAmountPercentage: null,
68
+ redeemBy: null,
69
+ maxRedemptions: null,
70
+ bashPointsRewardPerTicket: null,
71
+ ...overrides,
72
+ };
73
+ }
74
+
75
+ describe("pickPromoCodeAmongDuplicates", () => {
76
+ it("returns the only row", () => {
77
+ expect(pickPromoCodeAmongDuplicates([row("b")])).toEqual({ row: row("b") });
78
+ });
79
+
80
+ it("picks the oldest id among identical rows", () => {
81
+ const result = pickPromoCodeAmongDuplicates([row("c"), row("a"), row("b")]);
82
+ expect("row" in result && result.row.id).toBe("a");
83
+ });
84
+
85
+ it("prefers an identical row whose tier is in the cart", () => {
86
+ const result = pickPromoCodeAmongDuplicates([row("a"), row("b")], ["tier_b"]);
87
+ expect("row" in result && result.row.id).toBe("b");
88
+ });
89
+
90
+ it("falls back to the oldest row when no duplicate tier is in the cart", () => {
91
+ const result = pickPromoCodeAmongDuplicates([row("b"), row("a")], ["tier_z"]);
92
+ expect("row" in result && result.row.id).toBe("a");
93
+ });
94
+
95
+ it.each([
96
+ ["promoter", { promoterId: "someone_else" }],
97
+ ["discount", { discountAmountInCents: 700 }],
98
+ ["percentage", { discountAmountPercentage: 10, discountAmountInCents: null }],
99
+ ["expiry", { redeemBy: new Date("2027-01-01") }],
100
+ ["limit", { maxRedemptions: 5 }],
101
+ ["BashPoints reward", { bashPointsRewardPerTicket: 25 }],
102
+ ])("asks the host to review duplicates that differ on %s", (_label, diff) => {
103
+ expect(pickPromoCodeAmongDuplicates([row("a"), row("b", diff)])).toEqual({
104
+ reason: "host_review_required",
105
+ });
106
+ });
107
+
108
+ it("throws on an empty list", () => {
109
+ expect(() => pickPromoCodeAmongDuplicates([])).toThrow();
110
+ });
111
+ });
112
+
113
+ describe("reason helpers", () => {
114
+ it("has a guest message for every reason", () => {
115
+ for (const reason of PROMO_CODE_REJECT_REASONS) {
116
+ expect(PROMO_CODE_REJECT_MESSAGES[reason].length).toBeGreaterThan(0);
117
+ }
118
+ expect(PROMO_CODE_REJECT_MESSAGES.host_review_required).toBe(
119
+ "This code needs the host to review it."
120
+ );
121
+ });
122
+
123
+ it("recognizes reason strings", () => {
124
+ expect(isPromoCodeRejectReason("expired")).toBe(true);
125
+ expect(isPromoCodeRejectReason("nope")).toBe(false);
126
+ expect(isPromoCodeRejectReason(3)).toBe(false);
127
+ });
128
+
129
+ it("normalizes emails", () => {
130
+ expect(normalizePromoRedemptionEmail(" Guest@Example.COM ")).toBe("guest@example.com");
131
+ expect(normalizePromoRedemptionEmail(" ")).toBeNull();
132
+ expect(normalizePromoRedemptionEmail(null)).toBeNull();
133
+ });
134
+ });
@@ -0,0 +1,119 @@
1
+ import {
2
+ promoCodeAppliesToTier,
3
+ promoCodeBashEventId,
4
+ promoCodeDiscountKind,
5
+ promoCodeEligibleTierIds,
6
+ getPromoterShareUrl,
7
+ promoterCodeInputErrors,
8
+ } from "../promoCodeScope.js";
9
+
10
+ describe("promoCodeDiscountKind", () => {
11
+ it("uses the explicit type, else derives it from legacy amounts", () => {
12
+ expect(promoCodeDiscountKind({ discountType: "NONE", discountAmountPercentage: 10 })).toBe("NONE");
13
+ expect(promoCodeDiscountKind({ discountAmountPercentage: 10 })).toBe("PERCENT");
14
+ expect(promoCodeDiscountKind({ discountAmountInCents: 500 })).toBe("FIXED_CASH");
15
+ expect(promoCodeDiscountKind({})).toBe("NONE");
16
+ });
17
+ });
18
+
19
+ describe("promoCodeBashEventId", () => {
20
+ it("prefers the row's bash, else the legacy tier's bash", () => {
21
+ expect(promoCodeBashEventId({ bashEventId: "evt_1", ticketTier: { bashEventId: "evt_2" } })).toBe("evt_1");
22
+ expect(promoCodeBashEventId({ ticketTier: { bashEventId: "evt_2" } })).toBe("evt_2");
23
+ expect(promoCodeBashEventId({})).toBeNull();
24
+ });
25
+ });
26
+
27
+ describe("promoCodeEligibleTierIds", () => {
28
+ it("returns ALL for an all-tiers code, so tiers added later qualify", () => {
29
+ const allTiers = { eligibilityMode: "ALL_TIERS" as const, ticketTierId: "ga" };
30
+ expect(promoCodeEligibleTierIds(allTiers)).toBe("ALL");
31
+ expect(promoCodeAppliesToTier(allTiers, "tier_added_later")).toBe(true);
32
+ });
33
+
34
+ it("uses selected tiers, else the legacy tier", () => {
35
+ expect(
36
+ promoCodeEligibleTierIds({
37
+ eligibilityMode: "SELECTED_TIERS",
38
+ eligibleTiers: [{ ticketTierId: "ga" }, { ticketTierId: "vip" }, { ticketTierId: "ga" }],
39
+ })
40
+ ).toEqual(["ga", "vip"]);
41
+ expect(promoCodeEligibleTierIds({ ticketTierId: "ga" })).toEqual(["ga"]);
42
+ expect(promoCodeEligibleTierIds({})).toEqual([]);
43
+ expect(promoCodeAppliesToTier({ ticketTierId: "ga" }, "vip")).toBe(false);
44
+ });
45
+ });
46
+
47
+ describe("getPromoterShareUrl", () => {
48
+ it("uses the active vanity path when there is one", () => {
49
+ expect(
50
+ getPromoterShareUrl({
51
+ bashEventId: "evt_1",
52
+ slug: "summer-bash",
53
+ vanityPaths: [
54
+ { segment: "old", status: "Revoked" },
55
+ { segment: "BashFest", status: "Active" },
56
+ ],
57
+ code: " crew 1 ",
58
+ origin: "https://bash.community/",
59
+ })
60
+ ).toBe("https://bash.community/bash/bashfest?promo=CREW%201");
61
+ });
62
+
63
+ it("falls back to the id-slug path, never /bashes/", () => {
64
+ const url = getPromoterShareUrl({ bashEventId: "evt_1", slug: "summer-bash", code: "vip" });
65
+ expect(url).toBe("/bash/evt_1-summer-bash?promo=VIP");
66
+ expect(url).not.toContain("/bashes/");
67
+ });
68
+ });
69
+
70
+ describe("promoterCodeInputErrors", () => {
71
+ const ok = {
72
+ code: "CREW",
73
+ discountType: "NONE" as const,
74
+ eligibilityMode: "ALL_TIERS" as const,
75
+ };
76
+ it("accepts an attribution-only all-tiers code", () => {
77
+ expect(promoterCodeInputErrors(ok, ["ga"])).toEqual([]);
78
+ });
79
+ it("allows a guest discount of 0 and rejects negatives, separately from promoter commission", () => {
80
+ expect(promoterCodeInputErrors({ ...ok, discountType: "PERCENT", discountValue: 0 }, [])).toEqual([]);
81
+ expect(promoterCodeInputErrors({ ...ok, discountType: "FIXED_CASH", discountValue: 0 }, [])).toEqual([]);
82
+ expect(promoterCodeInputErrors({ ...ok, discountType: "PERCENT", discountValue: -1 }, [])).toContain(
83
+ "Guest discount must be between 0% and 100%."
84
+ );
85
+ expect(promoterCodeInputErrors({ ...ok, discountType: "FIXED_CASH", discountValue: -100 }, [])).toContain(
86
+ "Enter a fixed discount of $0 or more."
87
+ );
88
+ expect(promoterCodeInputErrors({ ...ok, discountType: "PERCENT", discountValue: 100 }, [])).toEqual([]);
89
+ expect(promoterCodeInputErrors({ ...ok, promoterCommissionBps: 0 }, [])).toEqual([]);
90
+ expect(promoterCodeInputErrors({ ...ok, promoterCommissionBps: 10000 }, [])).toEqual([]);
91
+ expect(promoterCodeInputErrors({ ...ok, promoterCommissionBps: 10001 }, [])).toContain(
92
+ "Promoter commission must be between 0% and 100%."
93
+ );
94
+ });
95
+ it("requires selected tiers on this bash", () => {
96
+ expect(promoterCodeInputErrors({ ...ok, eligibilityMode: "SELECTED_TIERS" }, ["ga"])).toContain(
97
+ "Pick at least one ticket tier."
98
+ );
99
+ expect(
100
+ promoterCodeInputErrors({ ...ok, eligibilityMode: "SELECTED_TIERS", eligibleTierIds: ["other"] }, ["ga"])
101
+ ).toContain("A selected tier isn't on this bash.");
102
+ });
103
+ it("blocks saving a promoter code until the reward is reviewed", () => {
104
+ expect(promoterCodeInputErrors({ ...ok, promoterEmail: "p@x.com" }, [])).toContain(
105
+ "Review the promoter reward before saving."
106
+ );
107
+ expect(
108
+ promoterCodeInputErrors({ ...ok, promoterEmail: "p@x.com", promoterRewardReviewed: true }, [])
109
+ ).toEqual([]);
110
+ });
111
+ it("accepts BashPoints as a compensation method", () => {
112
+ expect(
113
+ promoterCodeInputErrors({ ...ok, compensationMethod: "BashPoints" }, [])
114
+ ).toEqual([]);
115
+ expect(
116
+ promoterCodeInputErrors({ ...ok, compensationMethod: "Wire" as "Venmo" }, [])
117
+ ).toContain("Choose Venmo, Zelle, Cash App, or BashPoints.");
118
+ });
119
+ });
@@ -148,6 +148,7 @@ describe("status enum value sets", () => {
148
148
  "CompetitionFinalist",
149
149
  "CompetitionWin",
150
150
  "HornClear",
151
+ "IntrosEarlyReveal",
151
152
  ].sort(),
152
153
  );
153
154
  });
@@ -15,7 +15,8 @@ export type AppleIapProductFamily =
15
15
  | "membership"
16
16
  | "listing"
17
17
  | "bashpass"
18
- | "feature_boost";
18
+ | "feature_boost"
19
+ | "early_reveal";
19
20
 
20
21
  export type AppleMembershipInterval = "monthly" | "yearly" | "lifetime";
21
22
 
@@ -54,11 +55,19 @@ export interface AppleFeatureBoostProduct {
54
55
  priceCents: number;
55
56
  }
56
57
 
58
+ /** Consumable. App Store Connect must exist before a live iOS purchase can complete. */
59
+ export interface AppleEarlyRevealProduct {
60
+ productId: string;
61
+ family: "early_reveal";
62
+ priceCents: number;
63
+ }
64
+
57
65
  export type AppleIapProduct =
58
66
  | AppleMembershipProduct
59
67
  | AppleListingProduct
60
68
  | AppleBashPassProduct
61
- | AppleFeatureBoostProduct;
69
+ | AppleFeatureBoostProduct
70
+ | AppleEarlyRevealProduct;
62
71
 
63
72
  const MEMBERSHIP_TIERS = [
64
73
  MembershipTier.Premium,
@@ -145,11 +154,18 @@ export const APPLE_FEATURE_BOOST_PRODUCTS: readonly AppleFeatureBoostProduct[] =
145
154
  priceCents: d.priceCents,
146
155
  }));
147
156
 
157
+ export const APPLE_EARLY_REVEAL_PRODUCT: AppleEarlyRevealProduct = {
158
+ productId: "community.bash.us.intros.earlyreveal",
159
+ family: "early_reveal",
160
+ priceCents: 500,
161
+ };
162
+
148
163
  export const APPLE_IAP_PRODUCTS: readonly AppleIapProduct[] = [
149
164
  ...APPLE_MEMBERSHIP_PRODUCTS,
150
165
  ...APPLE_LISTING_PRODUCTS,
151
166
  ...APPLE_BASHPASS_PRODUCTS,
152
167
  ...APPLE_FEATURE_BOOST_PRODUCTS,
168
+ APPLE_EARLY_REVEAL_PRODUCT,
153
169
  ];
154
170
 
155
171
  const BY_PRODUCT_ID: ReadonlyMap<string, AppleIapProduct> = new Map(
@@ -164,6 +180,13 @@ export function isAppleIapProductId(productId: string): boolean {
164
180
  return BY_PRODUCT_ID.has(productId);
165
181
  }
166
182
 
183
+ /** Play Console product IDs for this release. Early reveal is excluded. */
184
+ export function listPlayBillingProductIds(): string[] {
185
+ return APPLE_IAP_PRODUCTS.filter((p) => p.family !== "early_reveal").map(
186
+ (p) => p.productId
187
+ );
188
+ }
189
+
167
190
  export function listAppleIapProductIds(family?: AppleIapProductFamily): string[] {
168
191
  if (!family) {
169
192
  return APPLE_IAP_PRODUCTS.map((p) => p.productId);
@@ -198,6 +221,10 @@ export function findAppleFeatureBoostProduct(
198
221
  return APPLE_FEATURE_BOOST_PRODUCTS.find((p) => p.durationId === durationId);
199
222
  }
200
223
 
224
+ export function findAppleEarlyRevealProduct(): AppleEarlyRevealProduct {
225
+ return APPLE_EARLY_REVEAL_PRODUCT;
226
+ }
227
+
201
228
  /** Apple subscription management deep link (Settings → Subscriptions). */
202
229
  export const APPLE_SUBSCRIPTIONS_MANAGEMENT_URL =
203
230
  "https://apps.apple.com/account/subscriptions";
@@ -1225,6 +1225,8 @@ export enum ApiErrorType {
1225
1225
  EventWaiverNotAccepted,
1226
1226
  /** Bash is not Published/PreSale. Ticket purchase and checkout creation are blocked */
1227
1227
  EventNotAcceptingPurchases,
1228
+ /** Promo code can't be used; `code` carries the PromoCodeRejectReason */
1229
+ PromoCodeRejected,
1228
1230
  }
1229
1231
 
1230
1232
  export type ErrorDataType = Record<RecordKey, unknown>;
@@ -1979,8 +1981,8 @@ const SYNTHETIC_BASH_SECTION_TITLE: Record<string, string> = {
1979
1981
  Popular: "Popular",
1980
1982
  Trending: "Trending",
1981
1983
  CrewGoing: "The crew's going",
1982
- Local: "Local",
1983
- State: "Statewide",
1984
+ Local: "City & Community Events",
1985
+ State: "State Events",
1984
1986
  National: "Across the country",
1985
1987
  International: "Worth the flight",
1986
1988
  };
@@ -1,6 +1,8 @@
1
1
  /**
2
- * Geographic / importance scope for public discovery.
3
- * Orthogonal to `BashEventType`. A Fair can be Local or State.
2
+ * Who puts the event on, for the City & Community and State homepage rows.
3
+ * Nearby events are a location filter, not a value here.
4
+ * National and International stay destination rows.
5
+ * Orthogonal to `BashEventType`.
4
6
  */
5
7
 
6
8
  export const DiscoveryScope = {
@@ -34,8 +36,8 @@ export const DISCOVERY_SCOPE_SECTION_TITLE: Record<
34
36
  (typeof DISCOVERY_SCOPE_ROW_KEYS)[number],
35
37
  string
36
38
  > = {
37
- Local: "Local",
38
- State: "Statewide",
39
+ Local: "City & Community Events",
40
+ State: "State Events",
39
41
  National: "Across the country",
40
42
  International: "Worth the flight",
41
43
  };
@@ -47,18 +49,6 @@ export const HOST_SETTABLE_DISCOVERY_SCOPES: readonly DiscoveryScope[] = [
47
49
  DiscoveryScope.State,
48
50
  ];
49
51
 
50
- export const HOST_SCOPE_CHIPS: ReadonlyArray<{
51
- id: DiscoveryScope;
52
- label: string;
53
- }> = [
54
- { id: DiscoveryScope.Unspecified, label: "Not sure yet" },
55
- { id: DiscoveryScope.Local, label: "Just the neighborhood / city-wide" },
56
- {
57
- id: DiscoveryScope.State,
58
- label: "People will drive from across the state",
59
- },
60
- ];
61
-
62
52
  export function isDiscoveryScope(value: unknown): value is DiscoveryScope {
63
53
  return (
64
54
  typeof value === "string" &&
@@ -91,11 +81,27 @@ export const NATIONAL_SCOPE_BRAND_PATTERN =
91
81
  export const INTERNATIONAL_SCOPE_PATTERN =
92
82
  /\b(world tour|world cup|olympics|tomorrowland|glastonbury|primavera sound|oktoberfest|cannes|eurovision|ibiza)\b/i;
93
83
 
84
+ /** Organizer is a state agency or an official statewide organization. Sponsorship is not enough. */
94
85
  export const STATE_SCOPE_PATTERN =
95
- /\b(state fair|statewide|governor'?s? (?:ball|festival)|days of ['’]?47|pioneer day)\b/i;
86
+ /\b(state fair|state agency|state parks|governor'?s? (?:ball|festival))\b/i;
96
87
 
88
+ /** Organizer is a city, municipality, or local community organization. */
97
89
  export const LOCAL_SCOPE_PATTERN =
98
- /\b(block party|neighborhood|community day|city-sponsored|city festival)\b/i;
90
+ /\b(city of|municipality|municipal|parks and recreation|community organization|city council)\b/i;
91
+
92
+ const SPONSORSHIP_ONLY_PATTERN = /\bsponsored by\b/i;
93
+ const ORGANIZER_PATTERN = /\b(organized|hosted|presented|produced) by\b/i;
94
+
95
+ function matchesOrganizerScope(text: string, pattern: RegExp): boolean {
96
+ if (!pattern.test(text)) return false;
97
+ if (
98
+ SPONSORSHIP_ONLY_PATTERN.test(text) &&
99
+ !ORGANIZER_PATTERN.test(text)
100
+ ) {
101
+ return false;
102
+ }
103
+ return true;
104
+ }
99
105
 
100
106
  export function inferDiscoveryScopeFromText(
101
107
  title: string | undefined,
@@ -109,8 +115,12 @@ export function inferDiscoveryScopeFromText(
109
115
  if (NATIONAL_SCOPE_BRAND_PATTERN.test(text)) {
110
116
  return DiscoveryScope.National;
111
117
  }
112
- if (STATE_SCOPE_PATTERN.test(text)) return DiscoveryScope.State;
113
- if (LOCAL_SCOPE_PATTERN.test(text)) return DiscoveryScope.Local;
118
+ if (matchesOrganizerScope(text, STATE_SCOPE_PATTERN)) {
119
+ return DiscoveryScope.State;
120
+ }
121
+ if (matchesOrganizerScope(text, LOCAL_SCOPE_PATTERN)) {
122
+ return DiscoveryScope.Local;
123
+ }
114
124
  return DiscoveryScope.Unspecified;
115
125
  }
116
126
 
@@ -672,6 +672,7 @@ type BashEventExtBase = Override<
672
672
  hideCancellationPolicy?: boolean;
673
673
  hideGroupUp?: boolean;
674
674
  hideServicesOpenTo?: boolean;
675
+ hideLiveLocation?: boolean;
675
676
  /** GET /event/:id. Tickets sold (non-cancelled), for social proof */
676
677
  goingCount?: number;
677
678
  /** GET /event/:id. True when at least one Budget line has `showOnBashDetailPage` set. Drives the "View budget" reveal on the public bash page; never carries dollar amounts. */
package/src/index.ts CHANGED
@@ -50,10 +50,14 @@ export * from "./hostCrmAutomation.js";
50
50
  export * from "./relationshipHealth.js";
51
51
  export * from "./hostCrmConstants.js";
52
52
  export * from "./hostPromoter.js";
53
+ export * from "./promoCodeRedemption.js";
54
+ export * from "./promoCodeScope.js";
55
+ export * from "./promoCartEngine.js";
53
56
  export * from "./audienceImport.js";
54
57
  export * from "./eventStaffRoles.js";
55
58
  export * from "./bashEventCancellationPolicy.js";
56
59
  export * from "./userReportTypes.js";
60
+ export * from "./intros/index.js";
57
61
  export * from "./mirroredPrismaEnums.js";
58
62
  export * from "./locationShare.js";
59
63
  export * from "./seatMap.js";