@bash-app/bash-common 30.276.0 → 30.279.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/definitions.d.ts +2 -0
- package/dist/definitions.d.ts.map +1 -1
- package/dist/definitions.js.map +1 -1
- package/dist/extendedSchemas.d.ts +9 -1
- package/dist/extendedSchemas.d.ts.map +1 -1
- package/dist/extendedSchemas.js.map +1 -1
- package/dist/utils/__tests__/paymentUtils.test.js +62 -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/paymentUtils.d.ts +46 -0
- package/dist/utils/paymentUtils.d.ts.map +1 -1
- package/dist/utils/paymentUtils.js +77 -17
- package/dist/utils/paymentUtils.js.map +1 -1
- package/dist/utils/recurrenceUtils.d.ts +26 -1
- 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/package.json +1 -1
- package/prisma/schema.prisma +28 -1
- package/src/definitions.ts +2 -0
- package/src/extendedSchemas.ts +16 -5
- package/src/utils/__tests__/paymentUtils.test.ts +104 -8
- package/src/utils/__tests__/recurrenceUtils.test.ts +99 -0
- package/src/utils/paymentUtils.ts +123 -20
- package/src/utils/recurrenceUtils.ts +116 -1
- package/src/utils/service/serviceBookingTypes.ts +3 -0
|
@@ -316,6 +316,120 @@ export function splitBashPlatformFeeForTicketCheckout(args: {
|
|
|
316
316
|
return { totalPlatformCents: total, guestPlatformLineCents: guestLine };
|
|
317
317
|
}
|
|
318
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
|
+
|
|
319
433
|
/**
|
|
320
434
|
* Compute the Stripe Connect `application_fee_amount` for a ticket-style charge,
|
|
321
435
|
* along with its constituent parts.
|
|
@@ -351,28 +465,17 @@ export function computeTicketApplicationFeeAmountCents(args: {
|
|
|
351
465
|
applicationFeeAmountCents: number;
|
|
352
466
|
/** Guest-visible portion of the platform line. Zero when `isVerifiedNonprofitHost` is true. */
|
|
353
467
|
guestPlatformLineCents: number;
|
|
468
|
+
/** Guest-visible Stripe processing line (0 when host absorbs). */
|
|
469
|
+
guestProcessingLineCents: number;
|
|
354
470
|
} {
|
|
355
|
-
const
|
|
356
|
-
const split = isVerifiedNonprofitHost
|
|
357
|
-
? { totalPlatformCents: 0, guestPlatformLineCents: 0 }
|
|
358
|
-
: splitBashPlatformFeeForTicketCheckout({
|
|
359
|
-
grossCents: args.discountedSubtotalCents,
|
|
360
|
-
platformFeeEnabled: args.platformFeeEnabled,
|
|
361
|
-
platformFeeRate: args.platformFeeRate,
|
|
362
|
-
feeHandling: args.feeHandling,
|
|
363
|
-
bashPassWaivesGuestFee: args.bashPassWaivesGuestFee,
|
|
364
|
-
});
|
|
365
|
-
const guestPlatformLineCents = split.guestPlatformLineCents;
|
|
366
|
-
const bashPlatformTotalCents = split.totalPlatformCents;
|
|
367
|
-
const totalChargedCents = args.discountedSubtotalCents + guestPlatformLineCents;
|
|
368
|
-
const stripeProcessingCents = calculateStripeProcessingFeeCents(totalChargedCents);
|
|
369
|
-
const applicationFeeAmountCents = stripeProcessingCents + bashPlatformTotalCents;
|
|
471
|
+
const breakdown = computeGuestTicketCheckoutBreakdown(args);
|
|
370
472
|
return {
|
|
371
|
-
totalChargedCents,
|
|
372
|
-
stripeProcessingCents,
|
|
373
|
-
bashPlatformTotalCents,
|
|
374
|
-
applicationFeeAmountCents,
|
|
375
|
-
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,
|
|
376
479
|
};
|
|
377
480
|
}
|
|
378
481
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { DayOfWeek, Recurrence, RecurringFrequency } from "@prisma/client";
|
|
1
|
+
import { DayOfWeek, Prisma, Recurrence, RecurringFrequency } from "@prisma/client";
|
|
2
2
|
import { DateTime, WeekdayNumbers } from "luxon";
|
|
3
3
|
import { DateTimeArgType } from "../definitions.js";
|
|
4
4
|
import {
|
|
@@ -10,6 +10,77 @@ 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
|
+
* Recurrence fields sent on create/update; `occurrenceOverrides` is the typed map
|
|
26
|
+
* (not Prisma `JsonValue`) so app/API mappers compile without casts.
|
|
27
|
+
*/
|
|
28
|
+
export type RecurrenceWireInput = Omit<
|
|
29
|
+
Partial<Recurrence>,
|
|
30
|
+
"occurrenceOverrides"
|
|
31
|
+
> & {
|
|
32
|
+
occurrenceOverrides?: OccurrenceOverrideMap | Prisma.JsonValue | null;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Stable key for an occurrence within a series. Both the generator and the
|
|
37
|
+
* skip/move persistence must derive the key the same way, so callers should use this.
|
|
38
|
+
*/
|
|
39
|
+
export function occurrenceOverrideKey(
|
|
40
|
+
occurrenceStart: Date | string | DateTime
|
|
41
|
+
): string {
|
|
42
|
+
const dt = DateTime.isDateTime(occurrenceStart)
|
|
43
|
+
? occurrenceStart
|
|
44
|
+
: dateTimeFromDate(occurrenceStart);
|
|
45
|
+
return dt.toFormat(LUXON_DATETIME_FORMAT_ISO_LIKE);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Safely coerce the `Recurrence.occurrenceOverrides` Json column into a typed map. */
|
|
49
|
+
export function parseOccurrenceOverrides(
|
|
50
|
+
raw: unknown
|
|
51
|
+
): OccurrenceOverrideMap {
|
|
52
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
|
53
|
+
const out: OccurrenceOverrideMap = {};
|
|
54
|
+
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
|
|
55
|
+
if (
|
|
56
|
+
value &&
|
|
57
|
+
typeof value === "object" &&
|
|
58
|
+
typeof (value as { startDateTime?: unknown }).startDateTime === "string"
|
|
59
|
+
) {
|
|
60
|
+
const v = value as { startDateTime: string; endDateTime?: unknown };
|
|
61
|
+
out[key] = {
|
|
62
|
+
startDateTime: v.startDateTime,
|
|
63
|
+
endDateTime:
|
|
64
|
+
typeof v.endDateTime === "string" ? v.endDateTime : null,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** True when the host has skipped or moved at least one occurrence in the series. */
|
|
72
|
+
export function recurrenceHasOccurrenceExceptions(
|
|
73
|
+
recurrence: Recurrence | null | undefined
|
|
74
|
+
): boolean {
|
|
75
|
+
if (!recurrence) return false;
|
|
76
|
+
const excluded = (recurrence as { excludedDates?: Date[] }).excludedDates ?? [];
|
|
77
|
+
if (excluded.length > 0) return true;
|
|
78
|
+
const overrides = parseOccurrenceOverrides(
|
|
79
|
+
(recurrence as { occurrenceOverrides?: unknown }).occurrenceOverrides
|
|
80
|
+
);
|
|
81
|
+
return Object.keys(overrides).length > 0;
|
|
82
|
+
}
|
|
83
|
+
|
|
13
84
|
export function getRecurringBashEventPossibleDateTimes(
|
|
14
85
|
startDateTime: DateTimeArgType,
|
|
15
86
|
recurrence: Recurrence | undefined | null
|
|
@@ -79,6 +150,11 @@ function getRecurringBashEventPossibleDatesTimesInternal(
|
|
|
79
150
|
recurrenceDates = recurrenceDates.slice(0, recurrence.repeatCount);
|
|
80
151
|
}
|
|
81
152
|
|
|
153
|
+
// Apply per-occurrence exceptions against the original generated instants:
|
|
154
|
+
// 1) drop occurrences the host removed (excludedDates)
|
|
155
|
+
// 2) move occurrences the host rescheduled (occurrenceOverrides)
|
|
156
|
+
recurrenceDates = applyOccurrenceExceptions(recurrenceDates, recurrence);
|
|
157
|
+
|
|
82
158
|
return recurrenceDates
|
|
83
159
|
.map((date: DateTime): string =>
|
|
84
160
|
date.toFormat(LUXON_DATETIME_FORMAT_ISO_LIKE)
|
|
@@ -97,6 +173,45 @@ function getRecurringBashEventPossibleDatesTimesInternal(
|
|
|
97
173
|
});
|
|
98
174
|
}
|
|
99
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Remove skipped occurrences and apply rescheduled (moved) occurrences. Matching is by
|
|
178
|
+
* the original occurrence's `LUXON_DATETIME_FORMAT_ISO_LIKE` key so it survives the
|
|
179
|
+
* format→reparse round-trip the generator already does.
|
|
180
|
+
*/
|
|
181
|
+
function applyOccurrenceExceptions(
|
|
182
|
+
occurrences: DateTime[],
|
|
183
|
+
recurrence: Recurrence
|
|
184
|
+
): DateTime[] {
|
|
185
|
+
const excludedKeys = new Set(
|
|
186
|
+
(
|
|
187
|
+
((recurrence as { excludedDates?: Date[] }).excludedDates) ?? []
|
|
188
|
+
).map((d) => occurrenceOverrideKey(d))
|
|
189
|
+
);
|
|
190
|
+
const overrides = parseOccurrenceOverrides(
|
|
191
|
+
(recurrence as { occurrenceOverrides?: unknown }).occurrenceOverrides
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
if (excludedKeys.size === 0 && Object.keys(overrides).length === 0) {
|
|
195
|
+
return occurrences;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const result: DateTime[] = [];
|
|
199
|
+
for (const occ of occurrences) {
|
|
200
|
+
const key = occurrenceOverrideKey(occ);
|
|
201
|
+
if (excludedKeys.has(key)) continue;
|
|
202
|
+
const override = overrides[key];
|
|
203
|
+
if (override?.startDateTime) {
|
|
204
|
+
const moved = dateTimeFromDate(new Date(override.startDateTime));
|
|
205
|
+
if (moved.isValid) {
|
|
206
|
+
result.push(moved);
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
result.push(occ);
|
|
211
|
+
}
|
|
212
|
+
return result;
|
|
213
|
+
}
|
|
214
|
+
|
|
100
215
|
/**
|
|
101
216
|
* Get the daily recurring dates
|
|
102
217
|
* @param beginningDateTime The beginning DateTime of the event, adjusted to be no earlier than now.
|
|
@@ -67,6 +67,9 @@ export type ServiceBookingRequiredData = MakeNullablePropsOptional<
|
|
|
67
67
|
// so callers building a new booking shape may omit them.
|
|
68
68
|
| "settlementStatus"
|
|
69
69
|
| "settlementAttempts"
|
|
70
|
+
// Reschedule lifecycle — DB defaults `rescheduleStatus` to None; the rest are
|
|
71
|
+
// nullable and set only when a host moves the date.
|
|
72
|
+
| "rescheduleStatus"
|
|
70
73
|
>
|
|
71
74
|
>;
|
|
72
75
|
|