@bash-app/bash-common 30.275.0 → 30.277.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 (68) hide show
  1. package/dist/__tests__/groupTicketUtils.test.d.ts +2 -0
  2. package/dist/__tests__/groupTicketUtils.test.d.ts.map +1 -0
  3. package/dist/__tests__/groupTicketUtils.test.js +38 -0
  4. package/dist/__tests__/groupTicketUtils.test.js.map +1 -0
  5. package/dist/competitionIdeas.d.ts +19 -0
  6. package/dist/competitionIdeas.d.ts.map +1 -0
  7. package/dist/competitionIdeas.js +405 -0
  8. package/dist/competitionIdeas.js.map +1 -0
  9. package/dist/competitionValidation.d.ts +10 -0
  10. package/dist/competitionValidation.d.ts.map +1 -0
  11. package/dist/competitionValidation.js +29 -0
  12. package/dist/competitionValidation.js.map +1 -0
  13. package/dist/definitions.d.ts +6 -0
  14. package/dist/definitions.d.ts.map +1 -1
  15. package/dist/definitions.js.map +1 -1
  16. package/dist/extendedSchemas.d.ts +356 -1
  17. package/dist/extendedSchemas.d.ts.map +1 -1
  18. package/dist/extendedSchemas.js +15 -0
  19. package/dist/extendedSchemas.js.map +1 -1
  20. package/dist/groupTicketUtils.d.ts +19 -0
  21. package/dist/groupTicketUtils.d.ts.map +1 -0
  22. package/dist/groupTicketUtils.js +74 -0
  23. package/dist/groupTicketUtils.js.map +1 -0
  24. package/dist/index.d.ts +4 -0
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +4 -0
  27. package/dist/index.js.map +1 -1
  28. package/dist/utils/__tests__/paymentUtils.test.js +80 -8
  29. package/dist/utils/__tests__/paymentUtils.test.js.map +1 -1
  30. package/dist/utils/__tests__/recurrenceUtils.test.js +82 -1
  31. package/dist/utils/__tests__/recurrenceUtils.test.js.map +1 -1
  32. package/dist/utils/__tests__/ticketListUtils.test.js +10 -0
  33. package/dist/utils/__tests__/ticketListUtils.test.js.map +1 -1
  34. package/dist/utils/groupTicketCartUtils.d.ts +12 -0
  35. package/dist/utils/groupTicketCartUtils.d.ts.map +1 -0
  36. package/dist/utils/groupTicketCartUtils.js +30 -0
  37. package/dist/utils/groupTicketCartUtils.js.map +1 -0
  38. package/dist/utils/paymentUtils.d.ts +56 -0
  39. package/dist/utils/paymentUtils.d.ts.map +1 -1
  40. package/dist/utils/paymentUtils.js +114 -18
  41. package/dist/utils/paymentUtils.js.map +1 -1
  42. package/dist/utils/recurrenceUtils.d.ts +18 -0
  43. package/dist/utils/recurrenceUtils.d.ts.map +1 -1
  44. package/dist/utils/recurrenceUtils.js +70 -0
  45. package/dist/utils/recurrenceUtils.js.map +1 -1
  46. package/dist/utils/service/serviceBookingTypes.d.ts +1 -1
  47. package/dist/utils/service/serviceBookingTypes.d.ts.map +1 -1
  48. package/dist/utils/ticketListUtils.d.ts.map +1 -1
  49. package/dist/utils/ticketListUtils.js +15 -3
  50. package/dist/utils/ticketListUtils.js.map +1 -1
  51. package/package.json +1 -1
  52. package/prisma/migrations/competition-prize-catalog-entry-tier.sql +39 -0
  53. package/prisma/schema.prisma +118 -8
  54. package/src/__tests__/groupTicketUtils.test.ts +53 -0
  55. package/src/competitionIdeas.ts +429 -0
  56. package/src/competitionValidation.ts +42 -0
  57. package/src/definitions.ts +7 -0
  58. package/src/extendedSchemas.ts +52 -0
  59. package/src/groupTicketUtils.ts +101 -0
  60. package/src/index.ts +4 -0
  61. package/src/utils/__tests__/paymentUtils.test.ts +123 -8
  62. package/src/utils/__tests__/recurrenceUtils.test.ts +99 -0
  63. package/src/utils/__tests__/ticketListUtils.test.ts +12 -0
  64. package/src/utils/groupTicketCartUtils.ts +36 -0
  65. package/src/utils/paymentUtils.ts +165 -21
  66. package/src/utils/recurrenceUtils.ts +104 -0
  67. package/src/utils/service/serviceBookingTypes.ts +3 -0
  68. package/src/utils/ticketListUtils.ts +19 -5
@@ -20,6 +20,11 @@ import {
20
20
  calculateBashPlatformFeeCents,
21
21
  splitBashPlatformFeeForTicketCheckout,
22
22
  computeTicketApplicationFeeAmountCents,
23
+ computeGuestTicketCheckoutBreakdown,
24
+ guestAllInUnitPriceCents,
25
+ guestCheckoutHasNoAddedFees,
26
+ lowestGuestAllInPriceCents,
27
+ hostAbsorbsTicketProcessing,
23
28
  userHasActiveBashPass,
24
29
  getEffectiveBashPassTier,
25
30
  getBashPassMonthlyCapRemaining,
@@ -166,7 +171,7 @@ describe("PaymentUtils - Core Functions", () => {
166
171
  expect(explicitFalse).toEqual(defaulted);
167
172
  });
168
173
 
169
- test("computeTicketApplicationFeeAmountCents — verified nonprofit host: zero Bash platform, Stripe untouched", () => {
174
+ test("computeTicketApplicationFeeAmountCents — verified nonprofit host: zero Bash platform, guest may pay processing when GuestPays", () => {
170
175
  const r = computeTicketApplicationFeeAmountCents({
171
176
  discountedSubtotalCents: 10000,
172
177
  platformFeeEnabled: true,
@@ -177,12 +182,19 @@ describe("PaymentUtils - Core Functions", () => {
177
182
  });
178
183
  expect(r.bashPlatformTotalCents).toBe(0);
179
184
  expect(r.guestPlatformLineCents).toBe(0);
180
- expect(r.totalChargedCents).toBe(10000);
181
- expect(r.stripeProcessingCents).toBe(calculateStripeProcessingFeeCents(10000));
185
+ expect(r.guestProcessingLineCents).toBe(
186
+ calculateStripeProcessingFeeCents(10000),
187
+ );
188
+ expect(r.totalChargedCents).toBe(
189
+ 10000 + calculateStripeProcessingFeeCents(10000),
190
+ );
191
+ expect(r.stripeProcessingCents).toBe(
192
+ calculateStripeProcessingFeeCents(r.totalChargedCents),
193
+ );
182
194
  expect(r.applicationFeeAmountCents).toBe(r.stripeProcessingCents);
183
195
  });
184
196
 
185
- test("computeTicketApplicationFeeAmountCents — verified nonprofit waiver overrides feeHandling=GuestPays", () => {
197
+ test("computeTicketApplicationFeeAmountCents — verified nonprofit waiver overrides platform on GuestPays", () => {
186
198
  const nonNonprofit = computeTicketApplicationFeeAmountCents({
187
199
  discountedSubtotalCents: 5000,
188
200
  platformFeeEnabled: true,
@@ -192,7 +204,12 @@ describe("PaymentUtils - Core Functions", () => {
192
204
  });
193
205
  expect(nonNonprofit.bashPlatformTotalCents).toBe(250);
194
206
  expect(nonNonprofit.guestPlatformLineCents).toBe(250);
195
- expect(nonNonprofit.totalChargedCents).toBe(5250);
207
+ expect(nonNonprofit.guestProcessingLineCents).toBe(
208
+ calculateStripeProcessingFeeCents(5000),
209
+ );
210
+ expect(nonNonprofit.totalChargedCents).toBe(
211
+ 5000 + 250 + calculateStripeProcessingFeeCents(5000),
212
+ );
196
213
 
197
214
  const nonprofit = computeTicketApplicationFeeAmountCents({
198
215
  discountedSubtotalCents: 5000,
@@ -204,8 +221,15 @@ describe("PaymentUtils - Core Functions", () => {
204
221
  });
205
222
  expect(nonprofit.bashPlatformTotalCents).toBe(0);
206
223
  expect(nonprofit.guestPlatformLineCents).toBe(0);
207
- expect(nonprofit.totalChargedCents).toBe(5000);
208
- expect(nonprofit.applicationFeeAmountCents).toBe(nonprofit.stripeProcessingCents);
224
+ expect(nonprofit.guestProcessingLineCents).toBe(
225
+ calculateStripeProcessingFeeCents(5000),
226
+ );
227
+ expect(nonprofit.totalChargedCents).toBe(
228
+ 5000 + calculateStripeProcessingFeeCents(5000),
229
+ );
230
+ expect(nonprofit.applicationFeeAmountCents).toBe(
231
+ nonprofit.stripeProcessingCents,
232
+ );
209
233
  });
210
234
 
211
235
  test("computeTicketApplicationFeeAmountCents — verified nonprofit waiver beats any membership-tier math", () => {
@@ -225,7 +249,17 @@ describe("PaymentUtils - Core Functions", () => {
225
249
  });
226
250
  expect(r.bashPlatformTotalCents).toBe(0);
227
251
  expect(r.guestPlatformLineCents).toBe(0);
228
- expect(r.totalChargedCents).toBe(12345);
252
+ if (tier.feeHandling === "HostAbsorbs") {
253
+ expect(r.guestProcessingLineCents).toBe(0);
254
+ expect(r.totalChargedCents).toBe(12345);
255
+ } else {
256
+ expect(r.guestProcessingLineCents).toBe(
257
+ calculateStripeProcessingFeeCents(12345),
258
+ );
259
+ expect(r.totalChargedCents).toBe(
260
+ 12345 + calculateStripeProcessingFeeCents(12345),
261
+ );
262
+ }
229
263
  expect(r.applicationFeeAmountCents).toBe(r.stripeProcessingCents);
230
264
  }
231
265
  });
@@ -289,6 +323,25 @@ describe("PaymentUtils - Core Functions", () => {
289
323
  test("calculateTotalPriceWithoutTax returns 0 for empty list", () => {
290
324
  expect(calculateTotalPriceWithoutTax([], new Map())).toBe(0);
291
325
  });
326
+
327
+ test("calculateTotalPriceWithoutTax uses unitPriceCents for pay-what-you-can tiers", () => {
328
+ const tiers = [
329
+ { id: "t1", price: 2000, pricingType: "USD", isDonationTier: true },
330
+ ] as any;
331
+ const ticketList = new Map([
332
+ [
333
+ "t1",
334
+ [
335
+ {
336
+ numberOfTickets: 2,
337
+ ticketDateTime: new Date(),
338
+ unitPriceCents: 5000,
339
+ },
340
+ ],
341
+ ],
342
+ ]);
343
+ expect(calculateTotalPriceWithoutTax(tiers, ticketList)).toBe(10000);
344
+ });
292
345
  });
293
346
 
294
347
  describe("Ticket total BashPoints", () => {
@@ -370,6 +423,68 @@ describe("PaymentUtils - Core Functions", () => {
370
423
  });
371
424
  });
372
425
 
426
+ describe("guest ticket checkout breakdown (No Fees for Attendees)", () => {
427
+ test("HostAbsorbs — $20 ticket → $20 due, no processing line", () => {
428
+ const r = computeGuestTicketCheckoutBreakdown({
429
+ discountedSubtotalCents: 2000,
430
+ platformFeeEnabled: false,
431
+ platformFeeRate: 0.03,
432
+ feeHandling: "HostAbsorbs",
433
+ bashPassWaivesGuestFee: false,
434
+ });
435
+ expect(r.ticketSubtotalCents).toBe(2000);
436
+ expect(r.guestProcessingLineCents).toBe(0);
437
+ expect(r.totalDueCents).toBe(2000);
438
+ });
439
+
440
+ test("GuestPays — $20 ticket → $20.88 all-in with processing line on subtotal", () => {
441
+ const processing = calculateStripeProcessingFeeCents(2000);
442
+ expect(processing).toBe(88);
443
+ const r = computeGuestTicketCheckoutBreakdown({
444
+ discountedSubtotalCents: 2000,
445
+ platformFeeEnabled: false,
446
+ platformFeeRate: 0.03,
447
+ feeHandling: "GuestPays",
448
+ bashPassWaivesGuestFee: false,
449
+ });
450
+ expect(r.guestProcessingLineCents).toBe(88);
451
+ expect(r.totalDueCents).toBe(2088);
452
+ });
453
+
454
+ test("guestCheckoutHasNoAddedFees and display helpers", () => {
455
+ expect(
456
+ guestCheckoutHasNoAddedFees({ feeHandling: "HostAbsorbs" }),
457
+ ).toBe(true);
458
+ expect(
459
+ guestCheckoutHasNoAddedFees({ feeHandling: "GuestPays" }),
460
+ ).toBe(false);
461
+ expect(
462
+ guestCheckoutHasNoAddedFees({
463
+ feeHandling: "HostAbsorbs",
464
+ externalTicketUrl: "https://eventbrite.com/x",
465
+ }),
466
+ ).toBe(false);
467
+ expect(hostAbsorbsTicketProcessing("HostAbsorbs")).toBe(true);
468
+ expect(hostAbsorbsTicketProcessing("GuestPays")).toBe(false);
469
+ expect(guestAllInUnitPriceCents(2000, { feeHandling: "HostAbsorbs" })).toBe(
470
+ 2000,
471
+ );
472
+ expect(guestAllInUnitPriceCents(2000, { feeHandling: "GuestPays" })).toBe(
473
+ 2088,
474
+ );
475
+ expect(
476
+ lowestGuestAllInPriceCents(
477
+ [
478
+ { price: 5000, pricingType: "USD" },
479
+ { price: 2000, pricingType: "USD" },
480
+ { price: 100, pricingType: "BASHPOINTS" },
481
+ ],
482
+ { feeHandling: "HostAbsorbs" },
483
+ ),
484
+ ).toBe(2000);
485
+ });
486
+ });
487
+
373
488
  describe("splitBashPlatformFeeForTicketCheckout edge cases", () => {
374
489
  test("returns zeros when platform fee disabled", () => {
375
490
  expect(
@@ -7,6 +7,8 @@ import { DateTime } from "luxon";
7
7
  import {
8
8
  getCompactRunFirstAndLastOccurrence,
9
9
  getRecurringBashEventPossibleLuxonTimes,
10
+ occurrenceOverrideKey,
11
+ parseOccurrenceOverrides,
10
12
  } from "../recurrenceUtils.js";
11
13
 
12
14
  function buildRecurrence(partial: Partial<Recurrence>): Recurrence {
@@ -94,3 +96,100 @@ describe("getCompactRunFirstAndLastOccurrence", () => {
94
96
  ).toBeNull();
95
97
  });
96
98
  });
99
+
100
+ describe("occurrenceOverrideKey", () => {
101
+ it("returns the same key for Date and ISO string of the same instant", () => {
102
+ const iso = "2026-06-15T20:00:00.000Z";
103
+ expect(occurrenceOverrideKey(new Date(iso))).toBe(
104
+ occurrenceOverrideKey(iso)
105
+ );
106
+ });
107
+ });
108
+
109
+ describe("parseOccurrenceOverrides", () => {
110
+ it("returns empty object for nullish or non-object input", () => {
111
+ expect(parseOccurrenceOverrides(null)).toEqual({});
112
+ expect(parseOccurrenceOverrides(undefined)).toEqual({});
113
+ expect(parseOccurrenceOverrides([])).toEqual({});
114
+ expect(parseOccurrenceOverrides("bad")).toEqual({});
115
+ });
116
+
117
+ it("parses valid override entries and ignores malformed ones", () => {
118
+ expect(
119
+ parseOccurrenceOverrides({
120
+ "Jun 15, 2026 8:00 PM": {
121
+ startDateTime: "2026-06-16T20:00:00.000Z",
122
+ endDateTime: "2026-06-16T22:00:00.000Z",
123
+ },
124
+ bad: { noStart: true },
125
+ empty: null,
126
+ })
127
+ ).toEqual({
128
+ "Jun 15, 2026 8:00 PM": {
129
+ startDateTime: "2026-06-16T20:00:00.000Z",
130
+ endDateTime: "2026-06-16T22:00:00.000Z",
131
+ },
132
+ });
133
+ });
134
+ });
135
+
136
+ describe("occurrence exceptions in getRecurringBashEventPossibleLuxonTimes", () => {
137
+ it("drops skipped occurrences from excludedDates", () => {
138
+ const base = DateTime.utc().plus({ weeks: 2 }).startOf("day");
139
+ const start = base
140
+ .set({ hour: 20, minute: 0, second: 0, millisecond: 0 })
141
+ .toJSDate();
142
+ const recurrence = buildRecurrence({
143
+ frequency: RecurringFrequency.Daily,
144
+ interval: 1,
145
+ repeatCount: 3,
146
+ endsType: RecurrenceEndsType.AFTER_COUNT,
147
+ });
148
+
149
+ const baseline = getRecurringBashEventPossibleLuxonTimes(start, recurrence);
150
+ expect(baseline).toHaveLength(3);
151
+
152
+ const skipped = baseline[1];
153
+ const withSkip = buildRecurrence({
154
+ ...recurrence,
155
+ excludedDates: [skipped.toJSDate()],
156
+ } as Partial<Recurrence>);
157
+
158
+ const result = getRecurringBashEventPossibleLuxonTimes(start, withSkip);
159
+ expect(result).toHaveLength(2);
160
+ expect(result.map((d) => d.toMillis())).not.toContain(skipped.toMillis());
161
+ });
162
+
163
+ it("applies moved occurrences from occurrenceOverrides", () => {
164
+ const base = DateTime.utc().plus({ weeks: 2 }).startOf("day");
165
+ const start = base
166
+ .set({ hour: 20, minute: 0, second: 0, millisecond: 0 })
167
+ .toJSDate();
168
+ const recurrence = buildRecurrence({
169
+ frequency: RecurringFrequency.Daily,
170
+ interval: 1,
171
+ repeatCount: 3,
172
+ endsType: RecurrenceEndsType.AFTER_COUNT,
173
+ });
174
+
175
+ const baseline = getRecurringBashEventPossibleLuxonTimes(start, recurrence);
176
+ const original = baseline[1];
177
+ const movedStart = original.plus({ days: 2 }).toISO();
178
+ const withMove = buildRecurrence({
179
+ ...recurrence,
180
+ occurrenceOverrides: {
181
+ [occurrenceOverrideKey(original)]: {
182
+ startDateTime: movedStart!,
183
+ endDateTime: null,
184
+ },
185
+ },
186
+ } as Partial<Recurrence>);
187
+
188
+ const result = getRecurringBashEventPossibleLuxonTimes(start, withMove);
189
+ expect(result).toHaveLength(3);
190
+ expect(result.map((d) => d.toMillis())).toContain(
191
+ DateTime.fromISO(movedStart!).toMillis()
192
+ );
193
+ expect(result.map((d) => d.toMillis())).not.toContain(original.toMillis());
194
+ });
195
+ });
@@ -29,6 +29,18 @@ describe("ticketListUtils", () => {
29
29
  expect(decoded.get("tier-xyz")![0].numberOfTickets).toBe(3);
30
30
  });
31
31
 
32
+ test("round-trips unitPriceCents for pay-what-you-can tiers", () => {
33
+ const ticketList = new Map<string, NumberOfTicketsForDate[]>([
34
+ [
35
+ "tier-pwyc",
36
+ [{ numberOfTickets: 1, ticketDateTime: isoDate, unitPriceCents: 7500 }],
37
+ ],
38
+ ]);
39
+
40
+ const decoded = ticketListStrToTicketList(ticketListToString(ticketList));
41
+ expect(decoded.get("tier-pwyc")![0].unitPriceCents).toBe(7500);
42
+ });
43
+
32
44
  test("omits tiers with zero tickets", () => {
33
45
  const ticketList = new Map<string, NumberOfTicketsForDate[]>([
34
46
  ["tier-empty", [{ numberOfTickets: 0, ticketDateTime: isoDate }]],
@@ -0,0 +1,36 @@
1
+ import type { TicketTier } from "@prisma/client";
2
+
3
+ import type { NumberOfTicketsForDate } from "../definitions.js";
4
+ import { isGroupTicketTier } from "../groupTicketUtils.js";
5
+
6
+ /**
7
+ * Validates cart quantities for group package tiers: qty = packages, not seats.
8
+ */
9
+ export function validateGroupTicketCartForTiers(
10
+ ticketTiers: TicketTier[],
11
+ ticketList: Map<string, NumberOfTicketsForDate[]>
12
+ ): { ok: true } | { ok: false; message: string } {
13
+ for (const tier of ticketTiers) {
14
+ if (!isGroupTicketTier(tier)) {
15
+ continue;
16
+ }
17
+ const rows = ticketList.get(tier.id);
18
+ if (!rows?.length) {
19
+ continue;
20
+ }
21
+ const packageQty = rows.reduce((s, r) => s + (r.numberOfTickets ?? 0), 0);
22
+ if (packageQty < 1) {
23
+ return {
24
+ ok: false,
25
+ message: `${tier.title} is a group package — select one package to buy.`,
26
+ };
27
+ }
28
+ if (packageQty > 1) {
29
+ return {
30
+ ok: false,
31
+ message: `${tier.title} is one package per checkout. Buy one table/package at a time.`,
32
+ };
33
+ }
34
+ }
35
+ return { ok: true };
36
+ }
@@ -44,6 +44,46 @@ export function calculateDiscountFromPromoCode(
44
44
  return 0;
45
45
  }
46
46
 
47
+ /**
48
+ * Unit price in cents for checkout (supports pay-what-you-can tiers).
49
+ */
50
+ export function effectiveTicketUnitPriceCents(
51
+ tier: TicketTier,
52
+ row: NumberOfTicketsForDate
53
+ ): number {
54
+ if (tier.isDonationTier === true && row.unitPriceCents != null) {
55
+ return Math.max(tier.price, row.unitPriceCents);
56
+ }
57
+ return tier.price;
58
+ }
59
+
60
+ export function validatePayWhatYouCanTicketList(
61
+ ticketTiers: TicketTier[],
62
+ ticketList: Map<string, NumberOfTicketsForDate[]>
63
+ ): { ok: true } | { ok: false; message: string } {
64
+ for (const tier of ticketTiers) {
65
+ if (tier.isDonationTier !== true) continue;
66
+ const rows = ticketList.get(tier.id);
67
+ if (!rows?.length) continue;
68
+ for (const row of rows) {
69
+ if ((row.numberOfTickets ?? 0) <= 0) continue;
70
+ if (row.unitPriceCents == null) {
71
+ return {
72
+ ok: false,
73
+ message: `Enter an amount for ${tier.title} (minimum $${convertCentsToDollars(tier.price).toFixed(2)}).`,
74
+ };
75
+ }
76
+ if (row.unitPriceCents < tier.price) {
77
+ return {
78
+ ok: false,
79
+ message: `${tier.title} requires at least $${convertCentsToDollars(tier.price).toFixed(2)} per ticket.`,
80
+ };
81
+ }
82
+ }
83
+ }
84
+ return { ok: true };
85
+ }
86
+
47
87
  /**
48
88
  * Returns the total price in USD cents based on a map where the keys are the ticketTierIds
49
89
  * Only counts USD-priced tickets. BashPoints tickets are excluded (they have price=0).
@@ -60,7 +100,8 @@ export function calculateTotalPriceWithoutTax(
60
100
  if (tier.pricingType !== "BASHPOINTS") {
61
101
  const ticketListNumAndDate = ticketList.get(tier.id);
62
102
  ticketListNumAndDate?.forEach((numAndDate: NumberOfTicketsForDate) => {
63
- total += tier.price * (numAndDate.numberOfTickets ?? 0);
103
+ const unitCents = effectiveTicketUnitPriceCents(tier, numAndDate);
104
+ total += unitCents * (numAndDate.numberOfTickets ?? 0);
64
105
  });
65
106
  }
66
107
  });
@@ -275,6 +316,120 @@ export function splitBashPlatformFeeForTicketCheckout(args: {
275
316
  return { totalPlatformCents: total, guestPlatformLineCents: guestLine };
276
317
  }
277
318
 
319
+ /** Event fields needed for guest-facing ticket fee display and checkout breakdown. */
320
+ export type TicketFeeEventContext = {
321
+ feeHandling?: string | null;
322
+ platformFeeEnabled?: boolean;
323
+ platformFeeRate?: number;
324
+ externalTicketUrl?: string | null;
325
+ };
326
+
327
+ export type GuestTicketCheckoutBreakdown = {
328
+ ticketSubtotalCents: number;
329
+ /** Guest-visible Stripe processing line (0 when host absorbs). */
330
+ guestProcessingLineCents: number;
331
+ guestPlatformLineCents: number;
332
+ /** Amount the guest pays (before tax). */
333
+ totalDueCents: number;
334
+ /** Stripe's 2.9% + $0.30 pass-through on the charge amount. */
335
+ stripeProcessingCents: number;
336
+ bashPlatformTotalCents: number;
337
+ applicationFeeAmountCents: number;
338
+ };
339
+
340
+ /** True when the host absorbs Stripe processing (`feeHandling` default). */
341
+ export function hostAbsorbsTicketProcessing(
342
+ feeHandling: string | null | undefined,
343
+ ): boolean {
344
+ return (feeHandling ?? "HostAbsorbs") === "HostAbsorbs";
345
+ }
346
+
347
+ /**
348
+ * Whether the event qualifies for the public "No fees for attendees" badge.
349
+ * External-ticket events are excluded (Bash does not control checkout).
350
+ */
351
+ export function guestCheckoutHasNoAddedFees(
352
+ event: TicketFeeEventContext,
353
+ ): boolean {
354
+ if (event.externalTicketUrl) return false;
355
+ return hostAbsorbsTicketProcessing(event.feeHandling);
356
+ }
357
+
358
+ /**
359
+ * Full guest ticket checkout breakdown — single source for API charges and UI copy.
360
+ *
361
+ * HostAbsorbs (default): guest pays subtotal (+ optional platform guest line only).
362
+ * GuestPays/Split (enterprise): guest also pays a visible processing line (fee on subtotal, donation-style).
363
+ */
364
+ export function computeGuestTicketCheckoutBreakdown(args: {
365
+ discountedSubtotalCents: number;
366
+ platformFeeEnabled: boolean;
367
+ platformFeeRate: number;
368
+ feeHandling: string | null | undefined;
369
+ bashPassWaivesGuestFee: boolean;
370
+ isVerifiedNonprofitHost?: boolean;
371
+ }): GuestTicketCheckoutBreakdown {
372
+ const ticketSubtotalCents = args.discountedSubtotalCents;
373
+ const isVerifiedNonprofitHost = args.isVerifiedNonprofitHost === true;
374
+ const split = isVerifiedNonprofitHost
375
+ ? { totalPlatformCents: 0, guestPlatformLineCents: 0 }
376
+ : splitBashPlatformFeeForTicketCheckout({
377
+ grossCents: ticketSubtotalCents,
378
+ platformFeeEnabled: args.platformFeeEnabled,
379
+ platformFeeRate: args.platformFeeRate,
380
+ feeHandling: args.feeHandling,
381
+ bashPassWaivesGuestFee: args.bashPassWaivesGuestFee,
382
+ });
383
+ const guestPlatformLineCents = split.guestPlatformLineCents;
384
+ const bashPlatformTotalCents = split.totalPlatformCents;
385
+ const guestProcessingLineCents = hostAbsorbsTicketProcessing(args.feeHandling)
386
+ ? 0
387
+ : calculateStripeProcessingFeeCents(ticketSubtotalCents);
388
+ const totalDueCents =
389
+ ticketSubtotalCents + guestProcessingLineCents + guestPlatformLineCents;
390
+ const stripeProcessingCents = calculateStripeProcessingFeeCents(totalDueCents);
391
+ const applicationFeeAmountCents =
392
+ stripeProcessingCents + bashPlatformTotalCents;
393
+ return {
394
+ ticketSubtotalCents,
395
+ guestProcessingLineCents,
396
+ guestPlatformLineCents,
397
+ totalDueCents,
398
+ stripeProcessingCents,
399
+ bashPlatformTotalCents,
400
+ applicationFeeAmountCents,
401
+ };
402
+ }
403
+
404
+ /** All-in mandatory guest price for one USD tier (cards, detail page). */
405
+ export function guestAllInUnitPriceCents(
406
+ unitPriceCents: number,
407
+ event: TicketFeeEventContext,
408
+ ): number {
409
+ if (unitPriceCents <= 0) return 0;
410
+ return computeGuestTicketCheckoutBreakdown({
411
+ discountedSubtotalCents: unitPriceCents,
412
+ platformFeeEnabled: event.platformFeeEnabled ?? false,
413
+ platformFeeRate: event.platformFeeRate ?? 0.03,
414
+ feeHandling: event.feeHandling,
415
+ bashPassWaivesGuestFee: false,
416
+ }).totalDueCents;
417
+ }
418
+
419
+ /** Lowest all-in USD tier price for "From $X" previews. */
420
+ export function lowestGuestAllInPriceCents(
421
+ tiers: ReadonlyArray<Pick<TicketTier, "price" | "pricingType">>,
422
+ event: TicketFeeEventContext,
423
+ ): number | null {
424
+ let min: number | null = null;
425
+ for (const tier of tiers) {
426
+ if (tier.pricingType === "BASHPOINTS" || tier.price <= 0) continue;
427
+ const allIn = guestAllInUnitPriceCents(tier.price, event);
428
+ if (min === null || allIn < min) min = allIn;
429
+ }
430
+ return min;
431
+ }
432
+
278
433
  /**
279
434
  * Compute the Stripe Connect `application_fee_amount` for a ticket-style charge,
280
435
  * along with its constituent parts.
@@ -310,28 +465,17 @@ export function computeTicketApplicationFeeAmountCents(args: {
310
465
  applicationFeeAmountCents: number;
311
466
  /** Guest-visible portion of the platform line. Zero when `isVerifiedNonprofitHost` is true. */
312
467
  guestPlatformLineCents: number;
468
+ /** Guest-visible Stripe processing line (0 when host absorbs). */
469
+ guestProcessingLineCents: number;
313
470
  } {
314
- const isVerifiedNonprofitHost = args.isVerifiedNonprofitHost === true;
315
- const split = isVerifiedNonprofitHost
316
- ? { totalPlatformCents: 0, guestPlatformLineCents: 0 }
317
- : splitBashPlatformFeeForTicketCheckout({
318
- grossCents: args.discountedSubtotalCents,
319
- platformFeeEnabled: args.platformFeeEnabled,
320
- platformFeeRate: args.platformFeeRate,
321
- feeHandling: args.feeHandling,
322
- bashPassWaivesGuestFee: args.bashPassWaivesGuestFee,
323
- });
324
- const guestPlatformLineCents = split.guestPlatformLineCents;
325
- const bashPlatformTotalCents = split.totalPlatformCents;
326
- const totalChargedCents = args.discountedSubtotalCents + guestPlatformLineCents;
327
- const stripeProcessingCents = calculateStripeProcessingFeeCents(totalChargedCents);
328
- const applicationFeeAmountCents = stripeProcessingCents + bashPlatformTotalCents;
471
+ const breakdown = computeGuestTicketCheckoutBreakdown(args);
329
472
  return {
330
- totalChargedCents,
331
- stripeProcessingCents,
332
- bashPlatformTotalCents,
333
- applicationFeeAmountCents,
334
- guestPlatformLineCents,
473
+ totalChargedCents: breakdown.totalDueCents,
474
+ stripeProcessingCents: breakdown.stripeProcessingCents,
475
+ bashPlatformTotalCents: breakdown.bashPlatformTotalCents,
476
+ applicationFeeAmountCents: breakdown.applicationFeeAmountCents,
477
+ guestPlatformLineCents: breakdown.guestPlatformLineCents,
478
+ guestProcessingLineCents: breakdown.guestProcessingLineCents,
335
479
  };
336
480
  }
337
481
 
@@ -10,6 +10,66 @@ import {
10
10
  import { compareDateTime } from "./dateTimeUtils.js";
11
11
  import { dayOfWeekToIdx } from "./generalDateTimeUtils.js";
12
12
 
13
+ /**
14
+ * Per-occurrence date/time move. Keyed (in `Recurrence.occurrenceOverrides`) by the
15
+ * original generated occurrence's `LUXON_DATETIME_FORMAT_ISO_LIKE` string.
16
+ */
17
+ export interface OccurrenceOverride {
18
+ startDateTime: string; // ISO
19
+ endDateTime?: string | null; // ISO
20
+ }
21
+
22
+ export type OccurrenceOverrideMap = Record<string, OccurrenceOverride>;
23
+
24
+ /**
25
+ * Stable key for an occurrence within a series. Both the generator and the
26
+ * skip/move persistence must derive the key the same way, so callers should use this.
27
+ */
28
+ export function occurrenceOverrideKey(
29
+ occurrenceStart: Date | string | DateTime
30
+ ): string {
31
+ const dt = DateTime.isDateTime(occurrenceStart)
32
+ ? occurrenceStart
33
+ : dateTimeFromDate(occurrenceStart);
34
+ return dt.toFormat(LUXON_DATETIME_FORMAT_ISO_LIKE);
35
+ }
36
+
37
+ /** Safely coerce the `Recurrence.occurrenceOverrides` Json column into a typed map. */
38
+ export function parseOccurrenceOverrides(
39
+ raw: unknown
40
+ ): OccurrenceOverrideMap {
41
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
42
+ const out: OccurrenceOverrideMap = {};
43
+ for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
44
+ if (
45
+ value &&
46
+ typeof value === "object" &&
47
+ typeof (value as { startDateTime?: unknown }).startDateTime === "string"
48
+ ) {
49
+ const v = value as { startDateTime: string; endDateTime?: unknown };
50
+ out[key] = {
51
+ startDateTime: v.startDateTime,
52
+ endDateTime:
53
+ typeof v.endDateTime === "string" ? v.endDateTime : null,
54
+ };
55
+ }
56
+ }
57
+ return out;
58
+ }
59
+
60
+ /** True when the host has skipped or moved at least one occurrence in the series. */
61
+ export function recurrenceHasOccurrenceExceptions(
62
+ recurrence: Recurrence | null | undefined
63
+ ): boolean {
64
+ if (!recurrence) return false;
65
+ const excluded = (recurrence as { excludedDates?: Date[] }).excludedDates ?? [];
66
+ if (excluded.length > 0) return true;
67
+ const overrides = parseOccurrenceOverrides(
68
+ (recurrence as { occurrenceOverrides?: unknown }).occurrenceOverrides
69
+ );
70
+ return Object.keys(overrides).length > 0;
71
+ }
72
+
13
73
  export function getRecurringBashEventPossibleDateTimes(
14
74
  startDateTime: DateTimeArgType,
15
75
  recurrence: Recurrence | undefined | null
@@ -79,6 +139,11 @@ function getRecurringBashEventPossibleDatesTimesInternal(
79
139
  recurrenceDates = recurrenceDates.slice(0, recurrence.repeatCount);
80
140
  }
81
141
 
142
+ // Apply per-occurrence exceptions against the original generated instants:
143
+ // 1) drop occurrences the host removed (excludedDates)
144
+ // 2) move occurrences the host rescheduled (occurrenceOverrides)
145
+ recurrenceDates = applyOccurrenceExceptions(recurrenceDates, recurrence);
146
+
82
147
  return recurrenceDates
83
148
  .map((date: DateTime): string =>
84
149
  date.toFormat(LUXON_DATETIME_FORMAT_ISO_LIKE)
@@ -97,6 +162,45 @@ function getRecurringBashEventPossibleDatesTimesInternal(
97
162
  });
98
163
  }
99
164
 
165
+ /**
166
+ * Remove skipped occurrences and apply rescheduled (moved) occurrences. Matching is by
167
+ * the original occurrence's `LUXON_DATETIME_FORMAT_ISO_LIKE` key so it survives the
168
+ * format→reparse round-trip the generator already does.
169
+ */
170
+ function applyOccurrenceExceptions(
171
+ occurrences: DateTime[],
172
+ recurrence: Recurrence
173
+ ): DateTime[] {
174
+ const excludedKeys = new Set(
175
+ (
176
+ ((recurrence as { excludedDates?: Date[] }).excludedDates) ?? []
177
+ ).map((d) => occurrenceOverrideKey(d))
178
+ );
179
+ const overrides = parseOccurrenceOverrides(
180
+ (recurrence as { occurrenceOverrides?: unknown }).occurrenceOverrides
181
+ );
182
+
183
+ if (excludedKeys.size === 0 && Object.keys(overrides).length === 0) {
184
+ return occurrences;
185
+ }
186
+
187
+ const result: DateTime[] = [];
188
+ for (const occ of occurrences) {
189
+ const key = occurrenceOverrideKey(occ);
190
+ if (excludedKeys.has(key)) continue;
191
+ const override = overrides[key];
192
+ if (override?.startDateTime) {
193
+ const moved = dateTimeFromDate(new Date(override.startDateTime));
194
+ if (moved.isValid) {
195
+ result.push(moved);
196
+ continue;
197
+ }
198
+ }
199
+ result.push(occ);
200
+ }
201
+ return result;
202
+ }
203
+
100
204
  /**
101
205
  * Get the daily recurring dates
102
206
  * @param beginningDateTime The beginning DateTime of the event, adjusted to be no earlier than now.