@bash-app/bash-common 30.385.0 → 30.391.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 (56) hide show
  1. package/dist/__tests__/scenePulse.test.d.ts +2 -0
  2. package/dist/__tests__/scenePulse.test.d.ts.map +1 -0
  3. package/dist/__tests__/scenePulse.test.js +52 -0
  4. package/dist/__tests__/scenePulse.test.js.map +1 -0
  5. package/dist/definitions.d.ts +19 -13
  6. package/dist/definitions.d.ts.map +1 -1
  7. package/dist/definitions.js +13 -12
  8. package/dist/definitions.js.map +1 -1
  9. package/dist/extendedSchemas.d.ts +22 -0
  10. package/dist/extendedSchemas.d.ts.map +1 -1
  11. package/dist/extendedSchemas.js +17 -0
  12. package/dist/extendedSchemas.js.map +1 -1
  13. package/dist/index.d.ts +3 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +3 -1
  16. package/dist/index.js.map +1 -1
  17. package/dist/scenePulse.d.ts +66 -0
  18. package/dist/scenePulse.d.ts.map +1 -0
  19. package/dist/scenePulse.js +54 -0
  20. package/dist/scenePulse.js.map +1 -0
  21. package/dist/utils/__tests__/paymentUtils.test.js +28 -1
  22. package/dist/utils/__tests__/paymentUtils.test.js.map +1 -1
  23. package/dist/utils/__tests__/serviceAvailabilityDisplay.test.js +56 -1
  24. package/dist/utils/__tests__/serviceAvailabilityDisplay.test.js.map +1 -1
  25. package/dist/utils/__tests__/slugUtils.test.js +5 -3
  26. package/dist/utils/__tests__/slugUtils.test.js.map +1 -1
  27. package/dist/utils/paymentUtils.d.ts +18 -1
  28. package/dist/utils/paymentUtils.d.ts.map +1 -1
  29. package/dist/utils/paymentUtils.js +29 -2
  30. package/dist/utils/paymentUtils.js.map +1 -1
  31. package/dist/utils/service/__tests__/foodAndBeverageQuote.test.d.ts +2 -0
  32. package/dist/utils/service/__tests__/foodAndBeverageQuote.test.d.ts.map +1 -0
  33. package/dist/utils/service/__tests__/foodAndBeverageQuote.test.js +353 -0
  34. package/dist/utils/service/__tests__/foodAndBeverageQuote.test.js.map +1 -0
  35. package/dist/utils/service/foodAndBeverageQuote.d.ts +171 -0
  36. package/dist/utils/service/foodAndBeverageQuote.d.ts.map +1 -0
  37. package/dist/utils/service/foodAndBeverageQuote.js +467 -0
  38. package/dist/utils/service/foodAndBeverageQuote.js.map +1 -0
  39. package/dist/utils/serviceAvailabilityDisplay.d.ts +28 -2
  40. package/dist/utils/serviceAvailabilityDisplay.d.ts.map +1 -1
  41. package/dist/utils/serviceAvailabilityDisplay.js +39 -0
  42. package/dist/utils/serviceAvailabilityDisplay.js.map +1 -1
  43. package/package.json +1 -1
  44. package/prisma/schema.prisma +95 -4
  45. package/src/__tests__/scenePulse.test.ts +69 -0
  46. package/src/definitions.ts +18 -12
  47. package/src/extendedSchemas.ts +23 -0
  48. package/src/index.ts +6 -0
  49. package/src/scenePulse.ts +113 -0
  50. package/src/utils/__tests__/paymentUtils.test.ts +36 -0
  51. package/src/utils/__tests__/serviceAvailabilityDisplay.test.ts +73 -0
  52. package/src/utils/__tests__/slugUtils.test.ts +5 -3
  53. package/src/utils/paymentUtils.ts +47 -2
  54. package/src/utils/service/__tests__/foodAndBeverageQuote.test.ts +502 -0
  55. package/src/utils/service/foodAndBeverageQuote.ts +613 -0
  56. package/src/utils/serviceAvailabilityDisplay.ts +82 -2
@@ -694,7 +694,11 @@ model Promoter {
694
694
  competitionId String?
695
695
  /// Cached count of qualified ticket sales for the linked competition (updated async after checkout/refund)
696
696
  qualifiedSales Int @default(0)
697
+ /// Basis points of attributed ticket subtotal withheld from the host and paid to this promoter via Connect (1500 = 15%). Guest price unchanged.
698
+ ticketCommissionBps Int?
697
699
  promoCodes BashEventPromoCode[]
700
+ ticketCommissions PromoterTicketCommission[]
701
+ lineupCredits BashEventLineupSlot[]
698
702
  promoterUser User? @relation(fields: [userId], references: [id])
699
703
  competition Competition? @relation(fields: [competitionId], references: [id], onDelete: SetNull)
700
704
 
@@ -702,6 +706,43 @@ model Promoter {
702
706
  @@index([competitionId])
703
707
  }
704
708
 
709
+ enum PromoterTicketCommissionStatus {
710
+ Accrued
711
+ Transferred
712
+ Reversed
713
+ Held
714
+ }
715
+
716
+ /// Ledger for % of ticket sales owed to a promo-code promoter / performer.
717
+ /// Withheld from the host via Stripe application_fee, then transferred to the
718
+ /// promoter's Connect account. Guest checkout total does not change.
719
+ model PromoterTicketCommission {
720
+ id String @id @default(cuid())
721
+ promoterId String
722
+ bashEventId String
723
+ promoCodeId String?
724
+ checkoutSessionId String?
725
+ paymentIntentId String?
726
+ chargeId String?
727
+ transferId String?
728
+ reversalId String?
729
+ ticketCount Int
730
+ basisCents Int
731
+ commissionBps Int
732
+ amountCents Int
733
+ status PromoterTicketCommissionStatus @default(Accrued)
734
+ createdAt DateTime @default(now())
735
+ updatedAt DateTime @updatedAt
736
+
737
+ promoter Promoter @relation(fields: [promoterId], references: [id], onDelete: Cascade)
738
+ bashEvent BashEvent @relation(fields: [bashEventId], references: [id], onDelete: Cascade)
739
+
740
+ @@unique([checkoutSessionId])
741
+ @@index([promoterId, status])
742
+ @@index([bashEventId])
743
+ @@index([paymentIntentId])
744
+ }
745
+
705
746
  model PromoterStats {
706
747
  id String @id @default(cuid())
707
748
  userId String @unique
@@ -1356,7 +1397,8 @@ model BashEvent {
1356
1397
  eventGroups EventGroup[]
1357
1398
  groupUnlockOffers GroupUnlockOffer[]
1358
1399
 
1359
- bashEventArtists BashEventArtist[]
1400
+ lineupSlots BashEventLineupSlot[]
1401
+ promoterTicketCommissions PromoterTicketCommission[]
1360
1402
  vanityPaths EventVanityPath[]
1361
1403
  flyerCampaigns FlyerCampaign[]
1362
1404
  featuredGuests EventFeaturedGuest[]
@@ -1403,13 +1445,16 @@ model BashEvent {
1403
1445
  }
1404
1446
 
1405
1447
  /// Lineup / attribution roster for an event (optional act list for checkout tagging).
1406
- model BashEventArtist {
1448
+ model BashEventLineupSlot {
1407
1449
  id String @id @default(cuid())
1408
1450
  bashEventId String
1409
1451
  bashEvent BashEvent @relation(fields: [bashEventId], references: [id], onDelete: Cascade)
1410
1452
  name String
1411
1453
  serviceId String?
1412
1454
  service Service? @relation(fields: [serviceId], references: [id], onDelete: SetNull)
1455
+ invitedEmail String?
1456
+ promoterId String?
1457
+ promoter Promoter? @relation(fields: [promoterId], references: [id], onDelete: SetNull)
1413
1458
  sortOrder Int @default(0)
1414
1459
  createdAt DateTime @default(now())
1415
1460
 
@@ -1417,6 +1462,7 @@ model BashEventArtist {
1417
1462
 
1418
1463
  @@index([bashEventId])
1419
1464
  @@index([serviceId])
1465
+ @@index([promoterId])
1420
1466
  }
1421
1467
 
1422
1468
  // --- Flyer Blast (USPS EDDM + Lob addressed mail) ---
@@ -3680,7 +3726,7 @@ model Ticket {
3680
3726
 
3681
3727
  bashEvent BashEvent @relation(fields: [bashEventId], references: [id])
3682
3728
  attributedArtistId String?
3683
- attributedArtist BashEventArtist? @relation(fields: [attributedArtistId], references: [id], onDelete: SetNull)
3729
+ attributedArtist BashEventLineupSlot? @relation(fields: [attributedArtistId], references: [id], onDelete: SetNull)
3684
3730
  checkout Checkout? @relation(fields: [checkoutId], references: [id])
3685
3731
  forUser User? @relation("TicketsISent", fields: [forUserId], references: [id])
3686
3732
  invitation Invitation? @relation("TicketsForInvitation", fields: [invitationId], references: [id])
@@ -5485,7 +5531,7 @@ model Service {
5485
5531
  latitude Float?
5486
5532
  longitude Float?
5487
5533
  bashFeedPosts BashFeedPost[]
5488
- bashEventArtists BashEventArtist[]
5534
+ lineupSlots BashEventLineupSlot[]
5489
5535
  bashCreativeSubmissions BashCreativeSubmission[]
5490
5536
  associatedServicesReferencingMe AssociatedService[]
5491
5537
  exhibitorBookingRequests ExhibitorBookingRequest[] @relation("ExhibitorBookingService")
@@ -5690,6 +5736,10 @@ model EventService {
5690
5736
  rateUnit String?
5691
5737
  minimumChargeCents Int?
5692
5738
  minimumQuantity Int?
5739
+ /// Delivery logistics for F&B listings: Pickup | Delivery | FullService.
5740
+ fulfillmentModes String[] @default([])
5741
+ /// Minimum advance-notice days required for F&B bookings (e.g. 7 = one week).
5742
+ leadTimeDays Int?
5693
5743
  crowdSize AmountOfGuests? @relation(fields: [crowdSizeId], references: [id], onDelete: Cascade)
5694
5744
  serviceRange ServiceRange? @relation(fields: [serviceRangeId], references: [id])
5695
5745
  service Service?
@@ -9444,6 +9494,8 @@ enum FoodAndBeverageSubType {
9444
9494
  DessertBar
9445
9495
  BeverageCart
9446
9496
  ConcessionStand
9497
+ CelebrationCake
9498
+ GrazingTable
9447
9499
  FoodAndBeverageOther
9448
9500
  }
9449
9501
 
@@ -9484,6 +9536,7 @@ enum RentalEquipmentSubType {
9484
9536
  BounceHouse
9485
9537
  WaterSlide
9486
9538
  CarnivalGames
9539
+ RestroomTrailer
9487
9540
  RentalEquipmentOther
9488
9541
  }
9489
9542
 
@@ -9571,6 +9624,7 @@ enum WellnessSubType {
9571
9624
  }
9572
9625
 
9573
9626
  enum HostingSupportSubType {
9627
+ Officiant
9574
9628
  HostingSupportOther
9575
9629
  }
9576
9630
 
@@ -9822,6 +9876,24 @@ enum DessertBarFormat {
9822
9876
  MultiStationDessert
9823
9877
  }
9824
9878
 
9879
+ enum CelebrationCakeFormat {
9880
+ WeddingCake
9881
+ BirthdayCake
9882
+ AnniversaryCake
9883
+ CorporateCake
9884
+ CustomOrder
9885
+ }
9886
+
9887
+ enum GrazingTableFormat {
9888
+ CharcuterieBoard
9889
+ CheeseBoard
9890
+ DessertBoard
9891
+ BreakfastBoard
9892
+ GrazingTableSetup
9893
+ CorporateSpread
9894
+ CustomOrder
9895
+ }
9896
+
9825
9897
  enum BeverageCartFormat {
9826
9898
  FullBarCart
9827
9899
  PremiumBarCart
@@ -10105,6 +10177,25 @@ enum TablesFormat {
10105
10177
  CustomTables
10106
10178
  }
10107
10179
 
10180
+ enum RestroomTrailerFormat {
10181
+ LuxuryTrailer
10182
+ StandardPortable
10183
+ ADAAccessible
10184
+ MultiStall
10185
+ HandwashStation
10186
+ }
10187
+
10188
+ enum OfficiantFormat {
10189
+ WeddingCeremony
10190
+ Elopement
10191
+ VowRenewal
10192
+ CommitmentCeremony
10193
+ Interfaith
10194
+ Nondenominational
10195
+ Civil
10196
+ CustomCeremony
10197
+ }
10198
+
10108
10199
  enum TentsFormat {
10109
10200
  ClearSpanTents
10110
10201
  FrameTents
@@ -0,0 +1,69 @@
1
+ /**
2
+ * parseScenePulseScope — city default, invalid → null.
3
+ */
4
+ import { describe, expect, test } from "@jest/globals";
5
+
6
+ import {
7
+ emptyScenePulseEnergy,
8
+ normalizeScenePulseGeoOverride,
9
+ parseScenePulseScope,
10
+ } from "../scenePulse.js";
11
+
12
+ describe("parseScenePulseScope", () => {
13
+ test("defaults missing to city", () => {
14
+ expect(parseScenePulseScope(undefined)).toBe("city");
15
+ expect(parseScenePulseScope(null)).toBe("city");
16
+ expect(parseScenePulseScope("")).toBe("city");
17
+ });
18
+
19
+ test("accepts city region world", () => {
20
+ expect(parseScenePulseScope("city")).toBe("city");
21
+ expect(parseScenePulseScope("Region")).toBe("region");
22
+ expect(parseScenePulseScope(" WORLD ")).toBe("world");
23
+ });
24
+
25
+ test("rejects unknown values", () => {
26
+ expect(parseScenePulseScope("you")).toBeNull();
27
+ expect(parseScenePulseScope(12)).toBeNull();
28
+ });
29
+ });
30
+
31
+ describe("normalizeScenePulseGeoOverride", () => {
32
+ test("returns null when city and state are blank", () => {
33
+ expect(normalizeScenePulseGeoOverride({})).toBeNull();
34
+ expect(
35
+ normalizeScenePulseGeoOverride({ city: " ", state: "" })
36
+ ).toBeNull();
37
+ });
38
+
39
+ test("trims city and state and ignores empty sibling", () => {
40
+ expect(
41
+ normalizeScenePulseGeoOverride({ city: " Denver ", state: " " })
42
+ ).toEqual({ city: "Denver", state: null });
43
+ expect(
44
+ normalizeScenePulseGeoOverride({ city: "", state: "CO" })
45
+ ).toEqual({ city: null, state: "CO" });
46
+ });
47
+
48
+ test("caps overly long values", () => {
49
+ const city = "A".repeat(90);
50
+ const parsed = normalizeScenePulseGeoOverride({ city, state: "Utah" });
51
+ expect(parsed?.city).toHaveLength(80);
52
+ expect(parsed?.state).toBe("Utah");
53
+ });
54
+ });
55
+
56
+ describe("emptyScenePulseEnergy", () => {
57
+ test("zeros every energy field", () => {
58
+ expect(emptyScenePulseEnergy()).toEqual({
59
+ liveNow: 0,
60
+ going: 0,
61
+ tonightBashes: 0,
62
+ tonightGoing: 0,
63
+ weekendBashes: 0,
64
+ weekendGoing: 0,
65
+ newThisWeek: 0,
66
+ namesInTheRoom: 0,
67
+ });
68
+ });
69
+ });
@@ -617,18 +617,19 @@ export const BASH_EVENT_WIZARD_WHY_SUBSTEP = {
617
617
  export const BASH_EVENT_WIZARD_HOW_SUBSTEP = {
618
618
  PRICE: 0,
619
619
  APPLICATION: 1,
620
- PROMO_CODES: 2,
621
- SPECIAL_OFFERS: 3,
622
- BUDGET: 4,
623
- MERCH: 5,
624
- GIVEAWAYS: 6,
625
- SERVICES_PARTNERS: 7,
626
- TASKS: 8,
627
- PAYOUT: 9,
628
- WAIVERS: 10,
629
- CANCELLATION_POLICIES: 11,
630
- FLYER_BLAST: 12,
631
- ANALYTICS: 13,
620
+ PROMOTER_CHALLENGE: 2,
621
+ PROMO_CODES: 3,
622
+ SPECIAL_OFFERS: 4,
623
+ BUDGET: 5,
624
+ MERCH: 6,
625
+ GIVEAWAYS: 7,
626
+ SERVICES_PARTNERS: 8,
627
+ TASKS: 9,
628
+ PAYOUT: 10,
629
+ WAIVERS: 11,
630
+ CANCELLATION_POLICIES: 12,
631
+ FLYER_BLAST: 13,
632
+ ANALYTICS: 14,
632
633
  } as const;
633
634
 
634
635
  export const BASH_EVENT_WIZARD_FINISH_SUBSTEP = {
@@ -830,6 +831,11 @@ export type ApiServiceBookingParams = {
830
831
  bashEventId?: string;
831
832
  /** Booking-level bundled packages (e.g. "Gold DJ Package"), independent of hourly/daily rates. */
832
833
  packages?: ApiServicePackageParams[];
834
+ /**
835
+ * F&B per-guest bookings: host-entered guest count snapshotted at booking time.
836
+ * Saved to ServiceBooking.expectedAttendeesAtBooking. No promoter true-up.
837
+ */
838
+ foodAndBeverageHeadcount?: number;
833
839
  };
834
840
 
835
841
  export type ApiServiceCanBookParams = {} & ApiServiceBookingParams;
@@ -1027,6 +1027,15 @@ export const SERVICE_PUBLIC_LIST_DATA_TO_INCLUDE = {
1027
1027
  serviceRatesAssociation: {
1028
1028
  include: {
1029
1029
  serviceGeneralRates: true,
1030
+ // Lean blocked/premium windows so list calendars can count open days
1031
+ // without the full rates association payload.
1032
+ serviceSpecialRates: {
1033
+ select: {
1034
+ startDate: true,
1035
+ endDate: true,
1036
+ isAvailable: true,
1037
+ },
1038
+ },
1030
1039
  },
1031
1040
  },
1032
1041
  venue: {
@@ -1045,6 +1054,14 @@ export const SERVICE_PUBLIC_LIST_DATA_TO_INCLUDE = {
1045
1054
  eventServiceSubType: true,
1046
1055
  formatOptions: true,
1047
1056
  crowdSize: true,
1057
+ // F&B per-guest pricing & logistics fields
1058
+ pricingModel: true,
1059
+ baseRateCents: true,
1060
+ minimumQuantity: true,
1061
+ minimumChargeCents: true,
1062
+ goodsOrServices: true,
1063
+ fulfillmentModes: true,
1064
+ leadTimeDays: true,
1048
1065
  },
1049
1066
  },
1050
1067
  entertainmentService: {
@@ -1414,6 +1431,12 @@ export interface ServiceExt extends Service {
1414
1431
  /** Attached by list endpoints (public/owner) for popularity sort and recency badges. */
1415
1432
  bookingStats?: ServiceBookingStats;
1416
1433
 
1434
+ /**
1435
+ * Confirmed/Approved booking windows for the public availability calendar.
1436
+ * Attached by the public list endpoint; not a Prisma relation.
1437
+ */
1438
+ bookedRanges?: Array<{ start: Date | string; end: Date | string }>;
1439
+
1417
1440
  // bookedCheckouts: ServiceBookingCheckoutExt[]; //not necessary to include
1418
1441
  }
1419
1442
 
package/src/index.ts CHANGED
@@ -36,6 +36,7 @@ export * from "./accessScreening.js";
36
36
  export * from "./applicationFunnel.js";
37
37
  export * from "./hostCrmSegments.js";
38
38
  export * from "./hostCrmCrowdPulse.js";
39
+ export * from "./scenePulse.js";
39
40
  export * from "./hostCrmLastBashProof.js";
40
41
  export * from "./hostCrmInsights.js";
41
42
  export * from "./hostCrmAutomation.js";
@@ -107,6 +108,8 @@ export {
107
108
  BrandStrategistFormat,
108
109
  CPAsFormat,
109
110
  CateringFormat,
111
+ CelebrationCakeFormat,
112
+ GrazingTableFormat,
110
113
  CelebrityAppearanceFormat,
111
114
  CenterpiecesFormat,
112
115
  ChairsFormat,
@@ -189,6 +192,7 @@ export {
189
192
  NotificationPriority,
190
193
  NotificationType,
191
194
  Occupation,
195
+ OfficiantFormat,
192
196
  OfferClaimStatus,
193
197
  OfferDiscountType,
194
198
  OrganizationArtistInvitationStatus,
@@ -225,6 +229,7 @@ export {
225
229
  RecurringFrequency,
226
230
  RedemptionMethod,
227
231
  RentalEquipmentSubType,
232
+ RestroomTrailerFormat,
228
233
  ReportStatus,
229
234
  ReportingFormat,
230
235
  RewardReportStatus,
@@ -429,6 +434,7 @@ export * from "./utils/mathUtils.js";
429
434
  export * from "./utils/birthdayExperienceQuality.js";
430
435
  export * from "./utils/service/apiServiceBookingApiUtils.js";
431
436
  export * from "./utils/service/frontendServiceBookingUtils.js";
437
+ export * from "./utils/service/foodAndBeverageQuote.js";
432
438
  export * from "./utils/service/serviceBookingStatusUtils.js";
433
439
  export * from "./utils/service/serviceDBUtils.js";
434
440
  export * from "./utils/service/serviceRateUtils.js";
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Community Builder scene pulse — city / region / world aggregates.
3
+ * You (Crowd Pulse) stays host ops; these scopes are public scene energy + platform proof.
4
+ */
5
+
6
+ export const SCENE_PULSE_SCOPES = ["city", "region", "world"] as const;
7
+
8
+ export type ScenePulseScope = (typeof SCENE_PULSE_SCOPES)[number];
9
+
10
+ export type ScenePulseGeo = {
11
+ city: string | null;
12
+ state: string | null;
13
+ country: string | null;
14
+ /** Short label for copy ("Salt Lake City", "Utah", "World"). */
15
+ label: string;
16
+ timeZone: string;
17
+ };
18
+
19
+ export type ScenePulseEnergy = {
20
+ /** Public rooms currently live. */
21
+ liveNow: number;
22
+ /** Unique Going RSVPs + ticketed people on live + tonight + this weekend. */
23
+ going: number;
24
+ tonightBashes: number;
25
+ tonightGoing: number;
26
+ weekendBashes: number;
27
+ weekendGoing: number;
28
+ /** Unique people who RSVP'd Going or paid for a ticket in the last 7 days. */
29
+ newThisWeek: number;
30
+ /**
31
+ * Distinct live/tonight/weekend public bashes with a lineup slot or a
32
+ * host-spotlighted featured guest (names in the room — not CRM).
33
+ */
34
+ namesInTheRoom: number;
35
+ };
36
+
37
+ /** World-only cumulative credibility. Null on city/region. */
38
+ export type ScenePulseWorldCred = {
39
+ totalPeople: number;
40
+ bashesHosted: number;
41
+ ticketsSold: number;
42
+ gmvCents: number;
43
+ citiesRepresented: number;
44
+ regionsRepresented: number;
45
+ };
46
+
47
+ export type ScenePulse = {
48
+ scope: ScenePulseScope;
49
+ geo: ScenePulseGeo | null;
50
+ /** True when city (or region) can't be resolved from profile or hosted bashes. */
51
+ needsLocation: boolean;
52
+ energy: ScenePulseEnergy;
53
+ world: ScenePulseWorldCred | null;
54
+ };
55
+
56
+ /** Optional city/state used to pulse a scene other than the host's profile. */
57
+ export type ScenePulseGeoOverride = {
58
+ city: string | null;
59
+ state: string | null;
60
+ };
61
+
62
+ const MAX_GEO_CITY_CHARS = 80;
63
+ const MAX_GEO_STATE_CHARS = 40;
64
+
65
+ function normalizeGeoPart(raw: unknown, maxChars: number): string | null {
66
+ if (typeof raw !== "string") return null;
67
+ const trimmed = raw.replace(/\s+/g, " ").trim();
68
+ if (!trimmed) return null;
69
+ return trimmed.slice(0, maxChars);
70
+ }
71
+
72
+ /**
73
+ * Blank city+state → null (use profile). Otherwise a trimmed override.
74
+ */
75
+ export function normalizeScenePulseGeoOverride(input: {
76
+ city?: unknown;
77
+ state?: unknown;
78
+ }): ScenePulseGeoOverride | null {
79
+ const city = normalizeGeoPart(input.city, MAX_GEO_CITY_CHARS);
80
+ const state = normalizeGeoPart(input.state, MAX_GEO_STATE_CHARS);
81
+ if (!city && !state) return null;
82
+ return { city, state };
83
+ }
84
+
85
+ export function emptyScenePulseEnergy(): ScenePulseEnergy {
86
+ return {
87
+ liveNow: 0,
88
+ going: 0,
89
+ tonightBashes: 0,
90
+ tonightGoing: 0,
91
+ weekendBashes: 0,
92
+ weekendGoing: 0,
93
+ newThisWeek: 0,
94
+ namesInTheRoom: 0,
95
+ };
96
+ }
97
+
98
+ /**
99
+ * Missing / blank → city. Invalid string → null (caller should 400).
100
+ */
101
+ export function parseScenePulseScope(raw: unknown): ScenePulseScope | null {
102
+ if (raw == null || raw === "") return "city";
103
+ if (typeof raw !== "string") return null;
104
+ const normalized = raw.trim().toLowerCase();
105
+ if (
106
+ normalized === "city" ||
107
+ normalized === "region" ||
108
+ normalized === "world"
109
+ ) {
110
+ return normalized;
111
+ }
112
+ return null;
113
+ }
@@ -21,6 +21,9 @@ import {
21
21
  splitBashPlatformFeeForTicketCheckout,
22
22
  computeTicketApplicationFeeAmountCents,
23
23
  computeGuestTicketCheckoutBreakdown,
24
+ ticketCommissionCents,
25
+ clampTicketCommissionBps,
26
+ MAX_TICKET_COMMISSION_BPS,
24
27
  guestAllInUnitPriceCents,
25
28
  guestCheckoutHasNoAddedFees,
26
29
  lowestGuestAllInPriceCents,
@@ -153,6 +156,7 @@ describe("PaymentUtils - Core Functions", () => {
153
156
  expect(r.bashPlatformTotalCents).toBe(300);
154
157
  expect(r.stripeProcessingCents).toBe(calculateStripeProcessingFeeCents(10000));
155
158
  expect(r.applicationFeeAmountCents).toBe(r.stripeProcessingCents + 300);
159
+ expect(r.performerCommissionCents).toBe(0);
156
160
  });
157
161
 
158
162
  test("computeTicketApplicationFeeAmountCents — explicit isVerifiedNonprofitHost: false matches default", () => {
@@ -277,6 +281,38 @@ describe("PaymentUtils - Core Functions", () => {
277
281
  expect(r.applicationFeeAmountCents).toBe(r.stripeProcessingCents);
278
282
  });
279
283
 
284
+ test("ticketCommissionCents floors bps of the subtotal and caps at 50%", () => {
285
+ expect(ticketCommissionCents(10000, 1500)).toBe(1500);
286
+ expect(ticketCommissionCents(10000, 0)).toBe(0);
287
+ expect(ticketCommissionCents(10000, MAX_TICKET_COMMISSION_BPS + 100)).toBe(
288
+ 5000
289
+ );
290
+ expect(clampTicketCommissionBps(99999)).toBe(MAX_TICKET_COMMISSION_BPS);
291
+ });
292
+
293
+ test("PROMO-002: performer cut is withheld on the application fee, not the guest total", () => {
294
+ const base = computeTicketApplicationFeeAmountCents({
295
+ discountedSubtotalCents: 10000,
296
+ platformFeeEnabled: false,
297
+ platformFeeRate: 0.03,
298
+ feeHandling: "HostAbsorbs",
299
+ bashPassWaivesGuestFee: false,
300
+ });
301
+ const withCut = computeTicketApplicationFeeAmountCents({
302
+ discountedSubtotalCents: 10000,
303
+ platformFeeEnabled: false,
304
+ platformFeeRate: 0.03,
305
+ feeHandling: "HostAbsorbs",
306
+ bashPassWaivesGuestFee: false,
307
+ performerCommissionCents: 1500,
308
+ });
309
+ expect(withCut.totalChargedCents).toBe(base.totalChargedCents);
310
+ expect(withCut.performerCommissionCents).toBe(1500);
311
+ expect(withCut.applicationFeeAmountCents).toBe(
312
+ base.applicationFeeAmountCents + 1500
313
+ );
314
+ });
315
+
280
316
  test("userHasActiveBashPass", () => {
281
317
  expect(userHasActiveBashPass({ bashPassStripeSubscriptionId: null, bashPassCurrentPeriodEnd: null })).toBe(false);
282
318
  expect(userHasActiveBashPass({ bashPassStripeSubscriptionId: "sub_1", bashPassCurrentPeriodEnd: null })).toBe(true);
@@ -1,9 +1,14 @@
1
1
  import {
2
2
  applyAvailabilityPreset,
3
+ blockedRangesFromSpecialRates,
3
4
  computeEarliestOpenDate,
5
+ countServicesAvailableOnDate,
4
6
  detectAvailabilityPreset,
7
+ filterServicesAvailableOnDate,
5
8
  formatAvailabilityPresetLabel,
9
+ isServiceAvailableOnDate,
6
10
  parseAvailableHours,
11
+ type ServiceDayAvailabilityInput,
7
12
  } from "../serviceAvailabilityDisplay.js";
8
13
 
9
14
  describe("applyAvailabilityPreset", () => {
@@ -150,3 +155,71 @@ describe("computeEarliestOpenDate", () => {
150
155
  expect(result).toBeNull();
151
156
  });
152
157
  });
158
+
159
+ describe("isServiceAvailableOnDate", () => {
160
+ const friday = new Date(2026, 8, 11); // Friday Sep 11 2026
161
+ const saturday = new Date(2026, 8, 12);
162
+
163
+ it("treats a listing with no hours or blocks as available", () => {
164
+ expect(isServiceAvailableOnDate({}, friday)).toBe(true);
165
+ });
166
+
167
+ it("closes weekdays when the service only works weekends", () => {
168
+ const service = {
169
+ availableHours: applyAvailabilityPreset("weekends"),
170
+ };
171
+ expect(isServiceAvailableOnDate(service, friday)).toBe(false);
172
+ expect(isServiceAvailableOnDate(service, saturday)).toBe(true);
173
+ });
174
+
175
+ it("treats owner-blocked special rates as unavailable", () => {
176
+ const service = {
177
+ serviceRatesAssociation: {
178
+ serviceSpecialRates: [
179
+ {
180
+ isAvailable: false,
181
+ startDate: friday,
182
+ endDate: friday,
183
+ },
184
+ ],
185
+ },
186
+ };
187
+ expect(isServiceAvailableOnDate(service, friday)).toBe(false);
188
+ expect(isServiceAvailableOnDate(service, saturday)).toBe(true);
189
+ });
190
+
191
+ it("treats a confirmed booking window as occupying the day", () => {
192
+ const service = {
193
+ bookedRanges: [{ start: friday, end: friday }],
194
+ };
195
+ expect(isServiceAvailableOnDate(service, friday)).toBe(false);
196
+ expect(isServiceAvailableOnDate(service, saturday)).toBe(true);
197
+ });
198
+
199
+ it("counts and filters a mixed set of listings", () => {
200
+ type ListedService = ServiceDayAvailabilityInput & { id: string };
201
+ const open: ListedService = { id: "open" };
202
+ const booked: ListedService = {
203
+ id: "booked",
204
+ bookedRanges: [{ start: friday, end: friday }],
205
+ };
206
+ const blocked: ListedService = {
207
+ id: "blocked",
208
+ serviceRatesAssociation: {
209
+ serviceSpecialRates: [
210
+ { isAvailable: false, startDate: friday, endDate: friday },
211
+ ],
212
+ },
213
+ };
214
+ const all: ListedService[] = [open, booked, blocked];
215
+ expect(countServicesAvailableOnDate(all, friday)).toBe(1);
216
+ expect(filterServicesAvailableOnDate(all, friday).map((s) => s.id)).toEqual(
217
+ ["open"]
218
+ );
219
+ expect(
220
+ blockedRangesFromSpecialRates(
221
+ blocked.serviceRatesAssociation?.serviceSpecialRates
222
+ )
223
+ ).toHaveLength(1);
224
+ });
225
+ });
@@ -117,9 +117,11 @@ describe("getBashEventWizardPayoutStepPath", () => {
117
117
  test("How substep indices match host-flow order", () => {
118
118
  expect(BASH_EVENT_WIZARD_HOW_SUBSTEP.PRICE).toBe(0);
119
119
  expect(BASH_EVENT_WIZARD_HOW_SUBSTEP.APPLICATION).toBe(1);
120
- expect(BASH_EVENT_WIZARD_HOW_SUBSTEP.PROMO_CODES).toBe(2);
121
- expect(BASH_EVENT_WIZARD_HOW_SUBSTEP.TASKS).toBe(8);
122
- expect(BASH_EVENT_WIZARD_HOW_SUBSTEP.PAYOUT).toBe(9);
120
+ expect(BASH_EVENT_WIZARD_HOW_SUBSTEP.PROMOTER_CHALLENGE).toBe(2);
121
+ expect(BASH_EVENT_WIZARD_HOW_SUBSTEP.PROMO_CODES).toBe(3);
122
+ expect(BASH_EVENT_WIZARD_HOW_SUBSTEP.GIVEAWAYS).toBe(7);
123
+ expect(BASH_EVENT_WIZARD_HOW_SUBSTEP.TASKS).toBe(9);
124
+ expect(BASH_EVENT_WIZARD_HOW_SUBSTEP.PAYOUT).toBe(10);
123
125
  expect(BASH_EVENT_WIZARD_HOW_SUBSTEP_PAYOUT).toBe(
124
126
  BASH_EVENT_WIZARD_HOW_SUBSTEP.PAYOUT
125
127
  );