@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.
- package/dist/__tests__/groupTicketUtils.test.d.ts +2 -0
- package/dist/__tests__/groupTicketUtils.test.d.ts.map +1 -0
- package/dist/__tests__/groupTicketUtils.test.js +38 -0
- package/dist/__tests__/groupTicketUtils.test.js.map +1 -0
- package/dist/competitionIdeas.d.ts +19 -0
- package/dist/competitionIdeas.d.ts.map +1 -0
- package/dist/competitionIdeas.js +405 -0
- package/dist/competitionIdeas.js.map +1 -0
- package/dist/competitionValidation.d.ts +10 -0
- package/dist/competitionValidation.d.ts.map +1 -0
- package/dist/competitionValidation.js +29 -0
- package/dist/competitionValidation.js.map +1 -0
- package/dist/definitions.d.ts +6 -0
- package/dist/definitions.d.ts.map +1 -1
- package/dist/definitions.js.map +1 -1
- package/dist/extendedSchemas.d.ts +356 -1
- package/dist/extendedSchemas.d.ts.map +1 -1
- package/dist/extendedSchemas.js +15 -0
- package/dist/extendedSchemas.js.map +1 -1
- package/dist/groupTicketUtils.d.ts +19 -0
- package/dist/groupTicketUtils.d.ts.map +1 -0
- package/dist/groupTicketUtils.js +74 -0
- package/dist/groupTicketUtils.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/utils/__tests__/paymentUtils.test.js +80 -8
- package/dist/utils/__tests__/paymentUtils.test.js.map +1 -1
- package/dist/utils/__tests__/recurrenceUtils.test.js +82 -1
- package/dist/utils/__tests__/recurrenceUtils.test.js.map +1 -1
- package/dist/utils/__tests__/ticketListUtils.test.js +10 -0
- package/dist/utils/__tests__/ticketListUtils.test.js.map +1 -1
- package/dist/utils/groupTicketCartUtils.d.ts +12 -0
- package/dist/utils/groupTicketCartUtils.d.ts.map +1 -0
- package/dist/utils/groupTicketCartUtils.js +30 -0
- package/dist/utils/groupTicketCartUtils.js.map +1 -0
- package/dist/utils/paymentUtils.d.ts +56 -0
- package/dist/utils/paymentUtils.d.ts.map +1 -1
- package/dist/utils/paymentUtils.js +114 -18
- package/dist/utils/paymentUtils.js.map +1 -1
- package/dist/utils/recurrenceUtils.d.ts +18 -0
- package/dist/utils/recurrenceUtils.d.ts.map +1 -1
- package/dist/utils/recurrenceUtils.js +70 -0
- package/dist/utils/recurrenceUtils.js.map +1 -1
- package/dist/utils/service/serviceBookingTypes.d.ts +1 -1
- package/dist/utils/service/serviceBookingTypes.d.ts.map +1 -1
- package/dist/utils/ticketListUtils.d.ts.map +1 -1
- package/dist/utils/ticketListUtils.js +15 -3
- package/dist/utils/ticketListUtils.js.map +1 -1
- package/package.json +1 -1
- package/prisma/migrations/competition-prize-catalog-entry-tier.sql +39 -0
- package/prisma/schema.prisma +118 -8
- package/src/__tests__/groupTicketUtils.test.ts +53 -0
- package/src/competitionIdeas.ts +429 -0
- package/src/competitionValidation.ts +42 -0
- package/src/definitions.ts +7 -0
- package/src/extendedSchemas.ts +52 -0
- package/src/groupTicketUtils.ts +101 -0
- package/src/index.ts +4 -0
- package/src/utils/__tests__/paymentUtils.test.ts +123 -8
- package/src/utils/__tests__/recurrenceUtils.test.ts +99 -0
- package/src/utils/__tests__/ticketListUtils.test.ts +12 -0
- package/src/utils/groupTicketCartUtils.ts +36 -0
- package/src/utils/paymentUtils.ts +165 -21
- package/src/utils/recurrenceUtils.ts +104 -0
- package/src/utils/service/serviceBookingTypes.ts +3 -0
- 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,
|
|
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.
|
|
181
|
-
|
|
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
|
|
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.
|
|
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.
|
|
208
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|