@bash-app/bash-common 30.276.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.
@@ -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 isVerifiedNonprofitHost = args.isVerifiedNonprofitHost === true;
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
 
@@ -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.
@@ -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