@bash-app/bash-common 30.260.0 → 30.262.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 (82) hide show
  1. package/dist/__tests__/ticketBnplPaymentMethods.cashapp.test.d.ts +2 -0
  2. package/dist/__tests__/ticketBnplPaymentMethods.cashapp.test.d.ts.map +1 -0
  3. package/dist/__tests__/ticketBnplPaymentMethods.cashapp.test.js +33 -0
  4. package/dist/__tests__/ticketBnplPaymentMethods.cashapp.test.js.map +1 -0
  5. package/dist/__tests__/ticketBnplPaymentMethods.test.d.ts +2 -0
  6. package/dist/__tests__/ticketBnplPaymentMethods.test.d.ts.map +1 -0
  7. package/dist/__tests__/ticketBnplPaymentMethods.test.js +46 -0
  8. package/dist/__tests__/ticketBnplPaymentMethods.test.js.map +1 -0
  9. package/dist/definitions.d.ts +6 -1
  10. package/dist/definitions.d.ts.map +1 -1
  11. package/dist/definitions.js.map +1 -1
  12. package/dist/index.d.ts +6 -0
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +6 -0
  15. package/dist/index.js.map +1 -1
  16. package/dist/ticketBnplPaymentMethods.d.ts +32 -0
  17. package/dist/ticketBnplPaymentMethods.d.ts.map +1 -0
  18. package/dist/ticketBnplPaymentMethods.js +42 -0
  19. package/dist/ticketBnplPaymentMethods.js.map +1 -0
  20. package/dist/utils/__tests__/featuredDiscoveryGeo.test.d.ts +2 -0
  21. package/dist/utils/__tests__/featuredDiscoveryGeo.test.d.ts.map +1 -0
  22. package/dist/utils/__tests__/featuredDiscoveryGeo.test.js +98 -0
  23. package/dist/utils/__tests__/featuredDiscoveryGeo.test.js.map +1 -0
  24. package/dist/utils/__tests__/flyerUtils.test.js +7 -1
  25. package/dist/utils/__tests__/flyerUtils.test.js.map +1 -1
  26. package/dist/utils/__tests__/guestSurface.test.d.ts +2 -0
  27. package/dist/utils/__tests__/guestSurface.test.d.ts.map +1 -0
  28. package/dist/utils/__tests__/guestSurface.test.js +94 -0
  29. package/dist/utils/__tests__/guestSurface.test.js.map +1 -0
  30. package/dist/utils/__tests__/ticketTierSchedule.test.d.ts +2 -0
  31. package/dist/utils/__tests__/ticketTierSchedule.test.d.ts.map +1 -0
  32. package/dist/utils/__tests__/ticketTierSchedule.test.js +84 -0
  33. package/dist/utils/__tests__/ticketTierSchedule.test.js.map +1 -0
  34. package/dist/utils/featuredDiscoveryGeo.d.ts +30 -0
  35. package/dist/utils/featuredDiscoveryGeo.d.ts.map +1 -0
  36. package/dist/utils/featuredDiscoveryGeo.js +47 -0
  37. package/dist/utils/featuredDiscoveryGeo.js.map +1 -0
  38. package/dist/utils/flyerUtils.d.ts +5 -0
  39. package/dist/utils/flyerUtils.d.ts.map +1 -1
  40. package/dist/utils/flyerUtils.js +24 -0
  41. package/dist/utils/flyerUtils.js.map +1 -1
  42. package/dist/utils/guestSurface.d.ts +114 -0
  43. package/dist/utils/guestSurface.d.ts.map +1 -0
  44. package/dist/utils/guestSurface.js +164 -0
  45. package/dist/utils/guestSurface.js.map +1 -0
  46. package/dist/utils/promotionBlastUtils.d.ts +38 -0
  47. package/dist/utils/promotionBlastUtils.d.ts.map +1 -0
  48. package/dist/utils/promotionBlastUtils.js +88 -0
  49. package/dist/utils/promotionBlastUtils.js.map +1 -0
  50. package/dist/utils/service/__tests__/defaultOfferAmountUtils.test.d.ts +2 -0
  51. package/dist/utils/service/__tests__/defaultOfferAmountUtils.test.d.ts.map +1 -0
  52. package/dist/utils/service/__tests__/defaultOfferAmountUtils.test.js +65 -0
  53. package/dist/utils/service/__tests__/defaultOfferAmountUtils.test.js.map +1 -0
  54. package/dist/utils/service/defaultOfferAmountUtils.d.ts +32 -0
  55. package/dist/utils/service/defaultOfferAmountUtils.d.ts.map +1 -0
  56. package/dist/utils/service/defaultOfferAmountUtils.js +115 -0
  57. package/dist/utils/service/defaultOfferAmountUtils.js.map +1 -0
  58. package/dist/utils/service/serviceBookingExposureUtils.js +1 -1
  59. package/dist/utils/service/serviceBookingExposureUtils.js.map +1 -1
  60. package/dist/utils/ticketTierSchedule.d.ts +58 -0
  61. package/dist/utils/ticketTierSchedule.d.ts.map +1 -0
  62. package/dist/utils/ticketTierSchedule.js +279 -0
  63. package/dist/utils/ticketTierSchedule.js.map +1 -0
  64. package/package.json +5 -2
  65. package/prisma/schema.prisma +332 -1
  66. package/src/__tests__/ticketBnplPaymentMethods.cashapp.test.ts +45 -0
  67. package/src/__tests__/ticketBnplPaymentMethods.test.ts +66 -0
  68. package/src/definitions.ts +6 -1
  69. package/src/index.ts +6 -0
  70. package/src/ticketBnplPaymentMethods.ts +79 -0
  71. package/src/utils/__tests__/featuredDiscoveryGeo.test.ts +120 -0
  72. package/src/utils/__tests__/flyerUtils.test.ts +10 -0
  73. package/src/utils/__tests__/guestSurface.test.ts +128 -0
  74. package/src/utils/__tests__/ticketTierSchedule.test.ts +131 -0
  75. package/src/utils/featuredDiscoveryGeo.ts +75 -0
  76. package/src/utils/flyerUtils.ts +33 -0
  77. package/src/utils/guestSurface.ts +315 -0
  78. package/src/utils/promotionBlastUtils.ts +127 -0
  79. package/src/utils/service/__tests__/defaultOfferAmountUtils.test.ts +102 -0
  80. package/src/utils/service/defaultOfferAmountUtils.ts +170 -0
  81. package/src/utils/service/serviceBookingExposureUtils.ts +1 -1
  82. package/src/utils/ticketTierSchedule.ts +387 -0
@@ -0,0 +1,315 @@
1
+ /**
2
+ * Curated guest visibility — shared policy resolver for API + bash-app.
3
+ * Three layers compose: event teaser policy, per-guest visibility, viewer roster depth.
4
+ */
5
+
6
+ import type { PublicTeaserUser } from "../extendedSchemas.js";
7
+
8
+ export type AttendeeDisplayMode =
9
+ | "All"
10
+ | "CountOnly"
11
+ | "NetworkOnly"
12
+ | "Hidden";
13
+
14
+ export type AttendeeVisibility =
15
+ | "Visible"
16
+ | "Hidden"
17
+ | "FeaturedPending"
18
+ | "FeaturedInvited";
19
+
20
+ export type GuestListDepth = "None" | "Partial" | "Full";
21
+
22
+ export type { PublicTeaserUser };
23
+
24
+ export type NetworkOverlapPayload = {
25
+ connections: PublicTeaserUser[];
26
+ contacts: PublicTeaserUser[];
27
+ customLists: Array<{
28
+ listId: string;
29
+ listName: string;
30
+ users: PublicTeaserUser[];
31
+ }>;
32
+ };
33
+
34
+ export type GuestSurfacePayload = {
35
+ count: { totalTickets: number; visibleAttendees: number };
36
+ preview: PublicTeaserUser[];
37
+ featured: PublicTeaserUser[];
38
+ featuredHeadline: string | null;
39
+ network: NetworkOverlapPayload | null;
40
+ upsell: { hasDeeperTiers: boolean; cheapestTierName?: string } | null;
41
+ roster?: {
42
+ users: PublicTeaserUser[];
43
+ totalVisible: number;
44
+ hasMore: boolean;
45
+ cursor?: string;
46
+ };
47
+ viewer: { rosterDepth: GuestListDepth; isHost: boolean };
48
+ policy: { effectiveMode: AttendeeDisplayMode };
49
+ /** Host-only: user IDs awaiting spotlight approval (guest-initiated) */
50
+ pendingFeaturedUserIds?: string[];
51
+ /** Host-only: user IDs with host-initiated featured invite pending accept */
52
+ pendingFeaturedInviteUserIds?: string[];
53
+ /** Viewer-only: pending featured invite for the authenticated viewer */
54
+ viewerFeaturedInvite?: {
55
+ bashEventId: string;
56
+ bashTitle: string;
57
+ hostName?: string;
58
+ } | null;
59
+ /** Host-only: per-guest visibility for roster management */
60
+ attendeeVisibilityByUserId?: Record<string, AttendeeVisibility>;
61
+ /** Event has public guest list enabled on bash detail */
62
+ attendeeListPublic?: boolean;
63
+ };
64
+
65
+ export type AttendeeRosterMeta = {
66
+ depth: GuestListDepth;
67
+ totalVisible?: number;
68
+ hasMore?: boolean;
69
+ cursor?: string;
70
+ };
71
+
72
+ export type EventModeInput = {
73
+ attendeeDisplayMode?: string | null;
74
+ showAttendees?: boolean | null;
75
+ };
76
+
77
+ export type GuestSurfacePolicyInput = {
78
+ eventMode: AttendeeDisplayMode;
79
+ isHost: boolean;
80
+ isAuthenticated: boolean;
81
+ viewerRosterDepth: GuestListDepth;
82
+ };
83
+
84
+ export type GuestSurfacePolicy = {
85
+ showCount: boolean;
86
+ showPreviewAvatars: boolean;
87
+ showFeatured: boolean;
88
+ showNetworkSlice: boolean;
89
+ rosterDepth: GuestListDepth;
90
+ previewLimit: number;
91
+ apiPreviewLimit: number;
92
+ effectiveMode: AttendeeDisplayMode;
93
+ };
94
+
95
+ const DEPTH_RANK: Record<GuestListDepth, number> = {
96
+ None: 0,
97
+ Partial: 1,
98
+ Full: 2,
99
+ };
100
+
101
+ /** Legacy showAttendees=false → Hidden; otherwise honour attendeeDisplayMode string. */
102
+ export function resolveEffectiveEventMode(
103
+ event: EventModeInput
104
+ ): AttendeeDisplayMode {
105
+ const raw = event.attendeeDisplayMode;
106
+ if (raw === "CountOnly") return "CountOnly";
107
+ if (raw === "NetworkOnly") return "NetworkOnly";
108
+ if (raw === "Hidden") return "Hidden";
109
+ if (event.showAttendees === false) return "Hidden";
110
+ return "All";
111
+ }
112
+
113
+ export function compareGuestListDepth(
114
+ a: GuestListDepth,
115
+ b: GuestListDepth
116
+ ): number {
117
+ return DEPTH_RANK[a] - DEPTH_RANK[b];
118
+ }
119
+
120
+ export function maxGuestListDepth(
121
+ ...depths: GuestListDepth[]
122
+ ): GuestListDepth {
123
+ if (depths.length === 0) return "None";
124
+ return depths.reduce((max, d) =>
125
+ compareGuestListDepth(d, max) > 0 ? d : max
126
+ );
127
+ }
128
+
129
+ /** Cap viewer tier depth by event mode for non-hosts. */
130
+ export function capRosterDepthForEventMode(
131
+ viewerDepth: GuestListDepth,
132
+ eventMode: AttendeeDisplayMode,
133
+ isHost: boolean
134
+ ): GuestListDepth {
135
+ if (isHost) return "Full";
136
+ if (eventMode === "Hidden") return "None";
137
+ if (eventMode === "NetworkOnly") return "None";
138
+ return viewerDepth;
139
+ }
140
+
141
+ /** Whether the viewer may fetch the full host-style roster endpoint. */
142
+ export function viewerMayFetchFullRoster(input: {
143
+ isHost: boolean;
144
+ viewerRosterDepth: GuestListDepth;
145
+ }): boolean {
146
+ if (input.isHost) return true;
147
+ return compareGuestListDepth(input.viewerRosterDepth, "Partial") >= 0;
148
+ }
149
+
150
+ /** Public bash-detail teaser policy (Layer 1 + viewer depth cap). */
151
+ export function resolveGuestSurfacePolicy(
152
+ input: GuestSurfacePolicyInput
153
+ ): GuestSurfacePolicy {
154
+ const { eventMode, isHost, isAuthenticated, viewerRosterDepth } = input;
155
+ const effectiveDepth = capRosterDepthForEventMode(
156
+ viewerRosterDepth,
157
+ eventMode,
158
+ isHost
159
+ );
160
+
161
+ if (eventMode === "Hidden" && !isHost) {
162
+ return {
163
+ showCount: false,
164
+ showPreviewAvatars: false,
165
+ showFeatured: false,
166
+ showNetworkSlice: false,
167
+ rosterDepth: "None",
168
+ previewLimit: 4,
169
+ apiPreviewLimit: 10,
170
+ effectiveMode: eventMode,
171
+ };
172
+ }
173
+
174
+ if (eventMode === "CountOnly" && !isHost) {
175
+ return {
176
+ showCount: true,
177
+ showPreviewAvatars: false,
178
+ showFeatured: false,
179
+ showNetworkSlice: isAuthenticated,
180
+ rosterDepth: effectiveDepth,
181
+ previewLimit: 4,
182
+ apiPreviewLimit: 10,
183
+ effectiveMode: eventMode,
184
+ };
185
+ }
186
+
187
+ const showCount = true;
188
+ const showPreviewAvatars =
189
+ eventMode === "All" && !isHost
190
+ ? true
191
+ : eventMode === "All" && isHost
192
+ ? true
193
+ : false;
194
+ const showFeatured =
195
+ (eventMode === "All" || isHost) && eventMode !== "CountOnly";
196
+ const showNetworkSlice =
197
+ isAuthenticated &&
198
+ (eventMode === "All" ||
199
+ eventMode === "CountOnly" ||
200
+ eventMode === "NetworkOnly" ||
201
+ isHost);
202
+
203
+ return {
204
+ showCount,
205
+ showPreviewAvatars:
206
+ showPreviewAvatars &&
207
+ eventMode !== "CountOnly" &&
208
+ eventMode !== "NetworkOnly",
209
+ showFeatured: showFeatured && eventMode !== "NetworkOnly",
210
+ showNetworkSlice,
211
+ rosterDepth: effectiveDepth,
212
+ previewLimit: 4,
213
+ apiPreviewLimit: 10,
214
+ effectiveMode: eventMode,
215
+ };
216
+ }
217
+
218
+ export type PreviewAttendeeRow = {
219
+ userId: string;
220
+ paidOn?: Date | string | null;
221
+ checkedInAt?: Date | string | null;
222
+ };
223
+
224
+ /** Featured first, then most recent activity, then stable userId. */
225
+ export function orderPreviewAttendeeIds(
226
+ rows: PreviewAttendeeRow[],
227
+ featuredUserIds: string[],
228
+ limit: number
229
+ ): string[] {
230
+ const featuredSet = new Set(featuredUserIds);
231
+ const byUser = new Map<string, PreviewAttendeeRow>();
232
+ for (const row of rows) {
233
+ if (!row.userId) continue;
234
+ const existing = byUser.get(row.userId);
235
+ if (!existing) {
236
+ byUser.set(row.userId, row);
237
+ continue;
238
+ }
239
+ const existingTs = activityTimestamp(existing);
240
+ const rowTs = activityTimestamp(row);
241
+ if (rowTs > existingTs) byUser.set(row.userId, row);
242
+ }
243
+
244
+ const featuredOrdered = featuredUserIds.filter((id) => byUser.has(id));
245
+ const rest = [...byUser.entries()]
246
+ .filter(([id]) => !featuredSet.has(id))
247
+ .sort(([, a], [, b]) => {
248
+ const diff = activityTimestamp(b) - activityTimestamp(a);
249
+ if (diff !== 0) return diff;
250
+ return a.userId.localeCompare(b.userId);
251
+ })
252
+ .map(([id]) => id);
253
+
254
+ return [...featuredOrdered, ...rest].slice(0, limit);
255
+ }
256
+
257
+ function activityTimestamp(row: PreviewAttendeeRow): number {
258
+ const paid =
259
+ row.paidOn != null ? new Date(row.paidOn as string | Date).getTime() : 0;
260
+ const checked =
261
+ row.checkedInAt != null
262
+ ? new Date(row.checkedInAt as string | Date).getTime()
263
+ : 0;
264
+ return Math.max(paid, checked);
265
+ }
266
+
267
+ export function filterHiddenVisibilityUserIds(
268
+ userIds: string[],
269
+ visibilityByUserId: Map<string, AttendeeVisibility>,
270
+ isHost: boolean
271
+ ): string[] {
272
+ if (isHost) return userIds;
273
+ return userIds.filter((id) => {
274
+ const v = visibilityByUserId.get(id) ?? "Visible";
275
+ return v === "Visible" || v === "FeaturedPending";
276
+ // FeaturedInvited is hidden until the guest accepts
277
+ });
278
+ }
279
+
280
+ export function filterBlockedUserIds(
281
+ userIds: string[],
282
+ blockedIds: Set<string>
283
+ ): string[] {
284
+ return userIds.filter((id) => !blockedIds.has(id));
285
+ }
286
+
287
+ export function guestListDepthFromTierField(
288
+ value: string | null | undefined
289
+ ): GuestListDepth {
290
+ if (value === "Partial" || value === "Full") return value;
291
+ return "None";
292
+ }
293
+
294
+ export function computeUpsellFromTiers(
295
+ tiers: Array<{ guestListDepth?: string | null; title?: string | null; price?: number | null }>,
296
+ viewerDepth: GuestListDepth
297
+ ): GuestSurfacePayload["upsell"] {
298
+ const deeper = tiers.filter(
299
+ (t) =>
300
+ compareGuestListDepth(
301
+ guestListDepthFromTierField(t.guestListDepth),
302
+ "None"
303
+ ) > 0
304
+ );
305
+ if (deeper.length === 0 || compareGuestListDepth(viewerDepth, "Partial") >= 0) {
306
+ return null;
307
+ }
308
+ const sorted = [...deeper].sort(
309
+ (a, b) => (a.price ?? 0) - (b.price ?? 0)
310
+ );
311
+ return {
312
+ hasDeeperTiers: true,
313
+ cheapestTierName: sorted[0]?.title ?? undefined,
314
+ };
315
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Promotion Blast — on-site partner promos (time-boxed + geofence).
3
+ */
4
+
5
+ export type PromotionBlastPackageTierId = "starter" | "standard" | "premium";
6
+
7
+ export type PromotionBlastTriggerTypeId = "TIME_BOXED" | "GEOFENCE";
8
+
9
+ export interface PromotionBlastPackageTerms {
10
+ tierId: PromotionBlastPackageTierId;
11
+ maxBlasts: number;
12
+ maxPushRecipients?: number | null;
13
+ maxRadiusFeet: number;
14
+ allowedTriggerTypes: PromotionBlastTriggerTypeId[];
15
+ hostAccessFeeCents: number;
16
+ bashPlatformFeeCents: number;
17
+ templateId?: string | null;
18
+ }
19
+
20
+ /** Minutes before event start when partners may activate blasts. */
21
+ export const PROMOTION_BLAST_EVENT_WINDOW_BEFORE_MINUTES = 30;
22
+
23
+ /** Minutes after event end when partners may still activate. */
24
+ export const PROMOTION_BLAST_EVENT_WINDOW_AFTER_MINUTES = 30;
25
+
26
+ /** Max concurrently live partner promos per event. */
27
+ export const PROMOTION_BLAST_MAX_LIVE_PER_EVENT = 5;
28
+
29
+ /** Max promotional pushes per attendee per event (all partners). */
30
+ export const PROMOTION_BLAST_MAX_PUSHES_PER_USER_PER_EVENT = 3;
31
+
32
+ /** Min minutes between promo pushes to same user at same event. */
33
+ export const PROMOTION_BLAST_MIN_PUSH_INTERVAL_MINUTES = 15;
34
+
35
+ export const PROMOTION_BLAST_HEADLINE_MAX_LENGTH = 80;
36
+ export const PROMOTION_BLAST_BODY_MAX_LENGTH = 280;
37
+ export const PROMOTION_BLAST_CTA_MAX_LENGTH = 40;
38
+
39
+ export const PROMOTION_BLAST_DURATION_PRESETS_MINUTES = [15, 30, 60] as const;
40
+ export const PROMOTION_BLAST_RADIUS_PRESETS_FEET = [100, 200, 500] as const;
41
+
42
+ export const PROMOTION_BLAST_PACKAGES: Record<
43
+ PromotionBlastPackageTierId,
44
+ Omit<PromotionBlastPackageTerms, "hostAccessFeeCents" | "templateId">
45
+ > = {
46
+ starter: {
47
+ tierId: "starter",
48
+ maxBlasts: 1,
49
+ maxPushRecipients: null,
50
+ maxRadiusFeet: 200,
51
+ allowedTriggerTypes: ["TIME_BOXED"],
52
+ bashPlatformFeeCents: 500,
53
+ },
54
+ standard: {
55
+ tierId: "standard",
56
+ maxBlasts: 3,
57
+ maxPushRecipients: null,
58
+ maxRadiusFeet: 500,
59
+ allowedTriggerTypes: ["TIME_BOXED", "GEOFENCE"],
60
+ bashPlatformFeeCents: 1200,
61
+ },
62
+ premium: {
63
+ tierId: "premium",
64
+ maxBlasts: 999,
65
+ maxPushRecipients: null,
66
+ maxRadiusFeet: 500,
67
+ allowedTriggerTypes: ["TIME_BOXED", "GEOFENCE"],
68
+ bashPlatformFeeCents: 2500,
69
+ },
70
+ };
71
+
72
+ export function promotionBlastPackageForTier(
73
+ tierId: PromotionBlastPackageTierId,
74
+ hostAccessFeeCents: number,
75
+ templateId?: string | null
76
+ ): PromotionBlastPackageTerms {
77
+ const base = PROMOTION_BLAST_PACKAGES[tierId];
78
+ return {
79
+ ...base,
80
+ hostAccessFeeCents,
81
+ templateId: templateId ?? null,
82
+ };
83
+ }
84
+
85
+ export function promotionBlastPackageTotalCents(pkg: PromotionBlastPackageTerms): number {
86
+ return pkg.hostAccessFeeCents + pkg.bashPlatformFeeCents;
87
+ }
88
+
89
+ export function promotionBlastPackageLabel(tierId: PromotionBlastPackageTierId): string {
90
+ switch (tierId) {
91
+ case "starter":
92
+ return "Starter — 1 time-boxed blast";
93
+ case "standard":
94
+ return "Standard — 3 blasts + geofence";
95
+ case "premium":
96
+ return "Premium — unlimited blasts + geofence";
97
+ default:
98
+ return tierId;
99
+ }
100
+ }
101
+
102
+ export function isWithinPromotionBlastEventWindow(
103
+ eventStart: Date,
104
+ eventEnd: Date | null | undefined,
105
+ now: Date = new Date()
106
+ ): boolean {
107
+ const windowStart = new Date(eventStart);
108
+ windowStart.setMinutes(windowStart.getMinutes() - PROMOTION_BLAST_EVENT_WINDOW_BEFORE_MINUTES);
109
+ const windowEnd = eventEnd
110
+ ? new Date(eventEnd)
111
+ : new Date(eventStart.getTime() + 4 * 60 * 60 * 1000);
112
+ windowEnd.setMinutes(windowEnd.getMinutes() + PROMOTION_BLAST_EVENT_WINDOW_AFTER_MINUTES);
113
+ return now >= windowStart && now <= windowEnd;
114
+ }
115
+
116
+ export function generatePromotionBlastConfirmationCode(): string {
117
+ const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
118
+ let code = "";
119
+ for (let i = 0; i < 6; i++) {
120
+ code += chars[Math.floor(Math.random() * chars.length)];
121
+ }
122
+ return code;
123
+ }
124
+
125
+ export function promotionBlastDeepLinkPath(bashEventId: string, blastId: string): string {
126
+ return `/bash/${encodeURIComponent(bashEventId)}?tab=promotions&blast=${encodeURIComponent(blastId)}`;
127
+ }
@@ -0,0 +1,102 @@
1
+ import {
2
+ calcLegacyRateEstimateCents,
3
+ computeBashEventDurationHours,
4
+ getDefaultOfferAmountCents,
5
+ isBulkHostOfferEligible,
6
+ } from "../defaultOfferAmountUtils.js";
7
+
8
+ describe("calcLegacyRateEstimateCents", () => {
9
+ it("uses flat rate when set", () => {
10
+ expect(
11
+ calcLegacyRateEstimateCents({ flatRateCents: 50_000 }, 4)
12
+ ).toBe(50_000);
13
+ });
14
+
15
+ it("uses daily rate for 8+ hour events", () => {
16
+ expect(
17
+ calcLegacyRateEstimateCents(
18
+ { hourlyRateCents: 15_000, dailyRateCents: 50_000 },
19
+ 8
20
+ )
21
+ ).toBe(50_000);
22
+ });
23
+
24
+ it("uses hourly × duration for shorter events", () => {
25
+ expect(
26
+ calcLegacyRateEstimateCents(
27
+ { hourlyRateCents: 15_000, dailyRateCents: 50_000 },
28
+ 4
29
+ )
30
+ ).toBe(60_000);
31
+ });
32
+ });
33
+
34
+ describe("computeBashEventDurationHours", () => {
35
+ it("returns hours between start and end", () => {
36
+ expect(
37
+ computeBashEventDurationHours(
38
+ "2026-06-01T18:00:00.000Z",
39
+ "2026-06-01T22:00:00.000Z"
40
+ )
41
+ ).toBe(4);
42
+ });
43
+
44
+ it("defaults to 1 when dates missing", () => {
45
+ expect(computeBashEventDurationHours(null, null)).toBe(1);
46
+ });
47
+ });
48
+
49
+ describe("getDefaultOfferAmountCents", () => {
50
+ it("prefers unified HOURLY model with duration", () => {
51
+ expect(
52
+ getDefaultOfferAmountCents({
53
+ service: {
54
+ entertainmentService: {
55
+ pricingModel: "HOURLY",
56
+ baseRateCents: 20_000,
57
+ },
58
+ },
59
+ durationHours: 3,
60
+ })
61
+ ).toBe(60_000);
62
+ });
63
+
64
+ it("falls back to legacy general rates", () => {
65
+ expect(
66
+ getDefaultOfferAmountCents({
67
+ service: {
68
+ serviceRatesAssociation: {
69
+ serviceGeneralRates: { dailyRateCents: 75_000 },
70
+ },
71
+ },
72
+ durationHours: 10,
73
+ })
74
+ ).toBe(75_000);
75
+ });
76
+
77
+ it("computes PER_ATTENDEE from expected guests", () => {
78
+ expect(
79
+ getDefaultOfferAmountCents({
80
+ service: {
81
+ eventService: {
82
+ pricingModel: "PER_ATTENDEE",
83
+ baseRateCents: 500,
84
+ },
85
+ },
86
+ durationHours: 4,
87
+ expectedAttendees: 100,
88
+ })
89
+ ).toBe(50_000);
90
+ });
91
+ });
92
+
93
+ describe("isBulkHostOfferEligible", () => {
94
+ it("rejects participation-fee services", () => {
95
+ expect(isBulkHostOfferEligible(50_000, true)).toBe(false);
96
+ });
97
+
98
+ it("requires a positive amount", () => {
99
+ expect(isBulkHostOfferEligible(0, false)).toBe(false);
100
+ expect(isBulkHostOfferEligible(25_000, false)).toBe(true);
101
+ });
102
+ });
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Deterministic default offer amount for a service + bash context.
3
+ * Keeps service cards, budget bar, SendOfferSheet prefill, and bulk offers aligned.
4
+ */
5
+ import {
6
+ computeOfferComparisonBands,
7
+ type OfferComparisonInput,
8
+ } from "./offerComparisonUtils.js";
9
+ import {
10
+ resolveUnifiedPricingBlock,
11
+ type UnifiedPricingSourceCandidates,
12
+ } from "./unifiedPricingResolver.js";
13
+ import { SERVICE_DAILY_RATE_HOURS_MIN } from "./serviceRateUtils.js";
14
+
15
+ export { SERVICE_DAILY_RATE_HOURS_MIN };
16
+
17
+ export interface LegacyGeneralRatesShape {
18
+ flatRateCents?: number | null;
19
+ hourlyRateCents?: number | null;
20
+ dailyRateCents?: number | null;
21
+ }
22
+
23
+ export type DefaultOfferAmountServiceShape = UnifiedPricingSourceCandidates & {
24
+ serviceRatesAssociation?: {
25
+ serviceGeneralRates?: LegacyGeneralRatesShape | null;
26
+ } | null;
27
+ };
28
+
29
+ export interface DefaultOfferAmountParams {
30
+ service: DefaultOfferAmountServiceShape;
31
+ durationHours: number;
32
+ expectedAttendees?: number | null;
33
+ }
34
+
35
+ /** Event length in hours; defaults to 1 when dates are missing or invalid. */
36
+ export function computeBashEventDurationHours(
37
+ startDateTime: Date | string | null | undefined,
38
+ endDateTime: Date | string | null | undefined
39
+ ): number {
40
+ if (!startDateTime || !endDateTime) return 1;
41
+ const start = new Date(startDateTime).getTime();
42
+ const end = new Date(endDateTime).getTime();
43
+ if (!Number.isFinite(start) || !Number.isFinite(end)) return 1;
44
+ const hours = (end - start) / (1000 * 60 * 60);
45
+ return hours >= 0.25 ? hours : 1;
46
+ }
47
+
48
+ /** Legacy published-rate estimate (flat → daily if 8+ hrs → hourly × duration). */
49
+ export function calcLegacyRateEstimateCents(
50
+ rates: LegacyGeneralRatesShape | null | undefined,
51
+ durationHours: number
52
+ ): number {
53
+ if (!rates) return 0;
54
+ if ((rates.flatRateCents ?? 0) > 0) return rates.flatRateCents!;
55
+ const hours = durationHours >= 0.25 ? durationHours : 1;
56
+ if (
57
+ hours >= SERVICE_DAILY_RATE_HOURS_MIN &&
58
+ (rates.dailyRateCents ?? 0) > 0
59
+ ) {
60
+ return rates.dailyRateCents!;
61
+ }
62
+ if ((rates.hourlyRateCents ?? 0) > 0) {
63
+ return Math.round(rates.hourlyRateCents! * hours);
64
+ }
65
+ if ((rates.dailyRateCents ?? 0) > 0) return rates.dailyRateCents!;
66
+ return 0;
67
+ }
68
+
69
+ function unifiedModelEstimateCents(
70
+ pricingModel: string,
71
+ block: {
72
+ baseRateCents: number | null;
73
+ baseRateBashPoints: number | null;
74
+ minimumChargeCents: number | null;
75
+ minimumQuantity: number | null;
76
+ },
77
+ durationHours: number,
78
+ expectedAttendees: number | null | undefined
79
+ ): number {
80
+ const base = block.baseRateCents ?? 0;
81
+ const hours = durationHours >= 0.25 ? durationHours : 1;
82
+
83
+ switch (pricingModel) {
84
+ case "HOURLY":
85
+ return base > 0 ? Math.round(base * hours) : 0;
86
+ case "PER_DAY":
87
+ if (base <= 0) return 0;
88
+ if (hours >= SERVICE_DAILY_RATE_HOURS_MIN) return base;
89
+ return Math.round(base * (hours / SERVICE_DAILY_RATE_HOURS_MIN));
90
+ case "FLAT_FEE":
91
+ case "CUSTOM":
92
+ return base > 0 ? base : 0;
93
+ case "PER_ATTENDEE":
94
+ case "PER_PAID_ATTENDEE": {
95
+ const bands = computeOfferComparisonBands({
96
+ pricingModel,
97
+ baseRateCents: base,
98
+ minimumChargeCents: block.minimumChargeCents,
99
+ minimumQuantity: block.minimumQuantity,
100
+ expectedAttendees,
101
+ } satisfies OfferComparisonInput);
102
+ return bands.expectedTotalCents ?? 0;
103
+ }
104
+ case "PER_BASHPOINTS_ATTENDEE":
105
+ return 0;
106
+ case "TIERED": {
107
+ const bands = computeOfferComparisonBands({
108
+ pricingModel: "TIERED",
109
+ tieredExpectedTierCents: base > 0 ? base : null,
110
+ });
111
+ return bands.expectedTotalCents ?? 0;
112
+ }
113
+ default: {
114
+ const bands = computeOfferComparisonBands({
115
+ pricingModel,
116
+ baseRateCents: base,
117
+ flatRateCents: base > 0 ? base : null,
118
+ });
119
+ return bands.expectedTotalCents ?? 0;
120
+ }
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Returns the suggested opening offer in USD cents for this bash, or 0 when
126
+ * no computable rate exists (bulk send should skip those services).
127
+ */
128
+ export function getDefaultOfferAmountCents(
129
+ params: DefaultOfferAmountParams
130
+ ): number {
131
+ const { service, durationHours, expectedAttendees } = params;
132
+ const legacyRates = service.serviceRatesAssociation?.serviceGeneralRates;
133
+
134
+ const block = resolveUnifiedPricingBlock({
135
+ eventService: service.eventService,
136
+ vendor: service.vendor,
137
+ exhibitor: service.exhibitor,
138
+ sponsor: service.sponsor,
139
+ venue: service.venue,
140
+ entertainmentService: service.entertainmentService,
141
+ volunteerService: service.volunteerService,
142
+ legacyRate: null,
143
+ });
144
+
145
+ if (block.fromUnifiedBlock && block.pricingModel) {
146
+ const unified = unifiedModelEstimateCents(
147
+ block.pricingModel,
148
+ block,
149
+ durationHours,
150
+ expectedAttendees
151
+ );
152
+ if (unified > 0) return unified;
153
+ }
154
+
155
+ return calcLegacyRateEstimateCents(legacyRates, durationHours);
156
+ }
157
+
158
+ export function formatOfferAmountDollars(cents: number): string {
159
+ return `$${Math.round(cents / 100).toLocaleString("en-US")}`;
160
+ }
161
+
162
+ /** Host → provider bulk offers only when a positive USD default exists. */
163
+ export function isBulkHostOfferEligible(
164
+ amountCents: number,
165
+ isParticipationFeeService: boolean
166
+ ): boolean {
167
+ return !isParticipationFeeService && amountCents > 0;
168
+ }
169
+
170
+ export const BULK_OFFER_MAX_COUNT = 10;