@bash-app/bash-common 30.260.1 → 30.263.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 (89) hide show
  1. package/dist/__tests__/serviceCheckoutPaymentMethods.test.d.ts +2 -0
  2. package/dist/__tests__/serviceCheckoutPaymentMethods.test.d.ts.map +1 -0
  3. package/dist/__tests__/serviceCheckoutPaymentMethods.test.js +41 -0
  4. package/dist/__tests__/serviceCheckoutPaymentMethods.test.js.map +1 -0
  5. package/dist/__tests__/ticketBnplPaymentMethods.bankTransfer.test.d.ts +2 -0
  6. package/dist/__tests__/ticketBnplPaymentMethods.bankTransfer.test.d.ts.map +1 -0
  7. package/dist/__tests__/ticketBnplPaymentMethods.bankTransfer.test.js +30 -0
  8. package/dist/__tests__/ticketBnplPaymentMethods.bankTransfer.test.js.map +1 -0
  9. package/dist/__tests__/ticketBnplPaymentMethods.cashapp.test.d.ts +2 -0
  10. package/dist/__tests__/ticketBnplPaymentMethods.cashapp.test.d.ts.map +1 -0
  11. package/dist/__tests__/ticketBnplPaymentMethods.cashapp.test.js +33 -0
  12. package/dist/__tests__/ticketBnplPaymentMethods.cashapp.test.js.map +1 -0
  13. package/dist/__tests__/ticketBnplPaymentMethods.test.d.ts +2 -0
  14. package/dist/__tests__/ticketBnplPaymentMethods.test.d.ts.map +1 -0
  15. package/dist/__tests__/ticketBnplPaymentMethods.test.js +46 -0
  16. package/dist/__tests__/ticketBnplPaymentMethods.test.js.map +1 -0
  17. package/dist/definitions.d.ts +7 -1
  18. package/dist/definitions.d.ts.map +1 -1
  19. package/dist/definitions.js.map +1 -1
  20. package/dist/index.d.ts +6 -0
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +6 -0
  23. package/dist/index.js.map +1 -1
  24. package/dist/ticketBnplPaymentMethods.d.ts +61 -0
  25. package/dist/ticketBnplPaymentMethods.d.ts.map +1 -0
  26. package/dist/ticketBnplPaymentMethods.js +83 -0
  27. package/dist/ticketBnplPaymentMethods.js.map +1 -0
  28. package/dist/utils/__tests__/featuredDiscoveryGeo.test.d.ts +2 -0
  29. package/dist/utils/__tests__/featuredDiscoveryGeo.test.d.ts.map +1 -0
  30. package/dist/utils/__tests__/featuredDiscoveryGeo.test.js +98 -0
  31. package/dist/utils/__tests__/featuredDiscoveryGeo.test.js.map +1 -0
  32. package/dist/utils/__tests__/flyerUtils.test.js +7 -1
  33. package/dist/utils/__tests__/flyerUtils.test.js.map +1 -1
  34. package/dist/utils/__tests__/guestSurface.test.d.ts +2 -0
  35. package/dist/utils/__tests__/guestSurface.test.d.ts.map +1 -0
  36. package/dist/utils/__tests__/guestSurface.test.js +94 -0
  37. package/dist/utils/__tests__/guestSurface.test.js.map +1 -0
  38. package/dist/utils/__tests__/ticketTierSchedule.test.d.ts +2 -0
  39. package/dist/utils/__tests__/ticketTierSchedule.test.d.ts.map +1 -0
  40. package/dist/utils/__tests__/ticketTierSchedule.test.js +84 -0
  41. package/dist/utils/__tests__/ticketTierSchedule.test.js.map +1 -0
  42. package/dist/utils/featuredDiscoveryGeo.d.ts +30 -0
  43. package/dist/utils/featuredDiscoveryGeo.d.ts.map +1 -0
  44. package/dist/utils/featuredDiscoveryGeo.js +47 -0
  45. package/dist/utils/featuredDiscoveryGeo.js.map +1 -0
  46. package/dist/utils/flyerUtils.d.ts +5 -0
  47. package/dist/utils/flyerUtils.d.ts.map +1 -1
  48. package/dist/utils/flyerUtils.js +24 -0
  49. package/dist/utils/flyerUtils.js.map +1 -1
  50. package/dist/utils/guestSurface.d.ts +114 -0
  51. package/dist/utils/guestSurface.d.ts.map +1 -0
  52. package/dist/utils/guestSurface.js +164 -0
  53. package/dist/utils/guestSurface.js.map +1 -0
  54. package/dist/utils/promotionBlastUtils.d.ts +38 -0
  55. package/dist/utils/promotionBlastUtils.d.ts.map +1 -0
  56. package/dist/utils/promotionBlastUtils.js +88 -0
  57. package/dist/utils/promotionBlastUtils.js.map +1 -0
  58. package/dist/utils/service/__tests__/defaultOfferAmountUtils.test.d.ts +2 -0
  59. package/dist/utils/service/__tests__/defaultOfferAmountUtils.test.d.ts.map +1 -0
  60. package/dist/utils/service/__tests__/defaultOfferAmountUtils.test.js +65 -0
  61. package/dist/utils/service/__tests__/defaultOfferAmountUtils.test.js.map +1 -0
  62. package/dist/utils/service/defaultOfferAmountUtils.d.ts +32 -0
  63. package/dist/utils/service/defaultOfferAmountUtils.d.ts.map +1 -0
  64. package/dist/utils/service/defaultOfferAmountUtils.js +115 -0
  65. package/dist/utils/service/defaultOfferAmountUtils.js.map +1 -0
  66. package/dist/utils/ticketTierSchedule.d.ts +58 -0
  67. package/dist/utils/ticketTierSchedule.d.ts.map +1 -0
  68. package/dist/utils/ticketTierSchedule.js +279 -0
  69. package/dist/utils/ticketTierSchedule.js.map +1 -0
  70. package/package.json +5 -2
  71. package/prisma/schema.prisma +340 -1
  72. package/src/__tests__/serviceCheckoutPaymentMethods.test.ts +59 -0
  73. package/src/__tests__/ticketBnplPaymentMethods.bankTransfer.test.ts +42 -0
  74. package/src/__tests__/ticketBnplPaymentMethods.cashapp.test.ts +45 -0
  75. package/src/__tests__/ticketBnplPaymentMethods.test.ts +66 -0
  76. package/src/definitions.ts +7 -1
  77. package/src/index.ts +6 -0
  78. package/src/ticketBnplPaymentMethods.ts +177 -0
  79. package/src/utils/__tests__/featuredDiscoveryGeo.test.ts +120 -0
  80. package/src/utils/__tests__/flyerUtils.test.ts +10 -0
  81. package/src/utils/__tests__/guestSurface.test.ts +128 -0
  82. package/src/utils/__tests__/ticketTierSchedule.test.ts +131 -0
  83. package/src/utils/featuredDiscoveryGeo.ts +75 -0
  84. package/src/utils/flyerUtils.ts +33 -0
  85. package/src/utils/guestSurface.ts +315 -0
  86. package/src/utils/promotionBlastUtils.ts +127 -0
  87. package/src/utils/service/__tests__/defaultOfferAmountUtils.test.ts +102 -0
  88. package/src/utils/service/defaultOfferAmountUtils.ts +170 -0
  89. package/src/utils/ticketTierSchedule.ts +387 -0
@@ -0,0 +1,66 @@
1
+ import {
2
+ resolveTicketCheckoutPaymentMethodTypes,
3
+ ticketCheckoutOffersBnpl,
4
+ TICKET_BNPL_MAX_CENTS_USD,
5
+ TICKET_BNPL_MIN_CENTS_USD,
6
+ } from "../ticketBnplPaymentMethods.js";
7
+
8
+ describe("ticketBnplPaymentMethods", () => {
9
+ const eligible = {
10
+ installmentsEnabled: true,
11
+ totalChargedCents: 12_000,
12
+ currency: "usd",
13
+ };
14
+
15
+ it("returns card+link only when installments disabled", () => {
16
+ expect(
17
+ resolveTicketCheckoutPaymentMethodTypes({
18
+ ...eligible,
19
+ installmentsEnabled: false,
20
+ })
21
+ ).toEqual(["card", "link"]);
22
+ });
23
+
24
+ it("adds klarna and afterpay_clearpay when eligible", () => {
25
+ expect(resolveTicketCheckoutPaymentMethodTypes(eligible)).toEqual([
26
+ "card",
27
+ "link",
28
+ "klarna",
29
+ "afterpay_clearpay",
30
+ ]);
31
+ expect(ticketCheckoutOffersBnpl(eligible)).toBe(true);
32
+ });
33
+
34
+ it("skips BNPL below minimum or above maximum", () => {
35
+ expect(
36
+ resolveTicketCheckoutPaymentMethodTypes({
37
+ ...eligible,
38
+ totalChargedCents: TICKET_BNPL_MIN_CENTS_USD - 1,
39
+ })
40
+ ).toEqual(["card", "link"]);
41
+ expect(
42
+ resolveTicketCheckoutPaymentMethodTypes({
43
+ ...eligible,
44
+ totalChargedCents: TICKET_BNPL_MAX_CENTS_USD + 1,
45
+ })
46
+ ).toEqual(["card", "link"]);
47
+ });
48
+
49
+ it("skips BNPL for non-USD", () => {
50
+ expect(
51
+ resolveTicketCheckoutPaymentMethodTypes({
52
+ ...eligible,
53
+ currency: "eur",
54
+ })
55
+ ).toEqual(["card", "link"]);
56
+ });
57
+
58
+ it("respects global kill switch", () => {
59
+ expect(
60
+ resolveTicketCheckoutPaymentMethodTypes({
61
+ ...eligible,
62
+ bnplGloballyEnabled: false,
63
+ })
64
+ ).toEqual(["card", "link"]);
65
+ });
66
+ });
@@ -751,7 +751,7 @@ export interface NumberOfTicketsForDate {
751
751
 
752
752
  export interface TicketTierAvailability {
753
753
  available: boolean;
754
- reason?: "OnDate" | "EngagedUsersOnly" | "UnlockWhenInterestReached" | "UnlockWhenTicketsSold";
754
+ reason?: "OnDate" | "EngagedUsersOnly" | "UnlockWhenInterestReached" | "UnlockWhenTicketsSold" | "SoldOut";
755
755
  availableAt?: string; // ISO string, OnDate
756
756
  interestCount?: number; // current count, UnlockWhenInterestReached
757
757
  interestThreshold?: number; // target, UnlockWhenInterestReached
@@ -1017,6 +1017,12 @@ export type StripeLinkingStatus = {
1017
1017
  hasOutstandingRequirements: boolean;
1018
1018
  paymentsEnabled: boolean;
1019
1019
  taxMonitoringEnabled: boolean;
1020
+ /** Host account missing business_type or MCC — blocks Cash App eligibility. */
1021
+ missingBusinessProfileForCashApp?: boolean;
1022
+ cardPaymentsActive?: boolean;
1023
+ klarnaPaymentsActive?: boolean;
1024
+ cashappPaymentsActive?: boolean;
1025
+ usBankTransferPaymentsActive?: boolean;
1020
1026
  };
1021
1027
 
1022
1028
  export type StripeBusinessDataPublic = {
package/src/index.ts CHANGED
@@ -9,9 +9,11 @@ export * from "./partnerStoreTypes.js";
9
9
  export * from "./bashFeedTypes.js";
10
10
  export * from "./membershipDefinitions.js";
11
11
  export * from "./onSaleCapabilityRecommendations.js";
12
+ export * from "./ticketBnplPaymentMethods.js";
12
13
  export * from "./aiApproval.js";
13
14
  export * from "./userReportTypes.js";
14
15
  export * from "./mirroredPrismaEnums.js";
16
+ export * from "./utils/featuredDiscoveryGeo.js";
15
17
 
16
18
  // Re-export ALL Prisma enums as values (usable as runtime constants, e.g. ServiceTypes.Entertainment)
17
19
  // Excludes enums already re-exported by definitions.ts, mirroredPrismaEnums.ts, and membershipDefinitions.ts:
@@ -314,6 +316,7 @@ export * from "./utils/service/venueSettlementUtils.js";
314
316
  export * from "./utils/service/offerAllowListUtils.js";
315
317
  export * from "./utils/service/offerComparisonUtils.js";
316
318
  export * from "./utils/service/unifiedPricingResolver.js";
319
+ export * from "./utils/service/defaultOfferAmountUtils.js";
317
320
  export * from "./utils/service/volunteerCompensationUtils.js";
318
321
  export * from "./utils/service/cancellationPolicyRefundResolver.js";
319
322
  export * from "./utils/venueLoyalty/eligibilityUtils.js";
@@ -331,6 +334,9 @@ export * from "./utils/blog/blogCommentDbUtils.js";
331
334
  export * from "./utils/blogUtils.js";
332
335
  export * from "./utils/entityUtils.js";
333
336
  export * from "./utils/flyerUtils.js";
337
+ export * from "./utils/promotionBlastUtils.js";
338
+ export * from "./utils/guestSurface.js";
339
+ export * from "./utils/ticketTierSchedule.js";
334
340
  export * from "./utils/lobFlyerUtils.js";
335
341
  export * from "./utils/generalDateTimeUtils.js";
336
342
  export * from "./utils/locationVisibility.js";
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Stripe Checkout BNPL (Klarna, Afterpay/Clearpay) for paid ticket orders.
3
+ * Pure functions — safe for SSR/tests; no IO.
4
+ *
5
+ * @see ADR-011, api/docs/STRIPE_PAYMENT_LINKS_INSTALLMENTS.md
6
+ */
7
+
8
+ export type TicketCheckoutPaymentMethodType =
9
+ | "card"
10
+ | "link"
11
+ | "klarna"
12
+ | "afterpay_clearpay"
13
+ | "cashapp"
14
+ | "customer_balance"
15
+ | "us_bank_account";
16
+
17
+ /** Service booking Checkout — card, link, and optional async bank methods. */
18
+ export type ServiceCheckoutPaymentMethodType =
19
+ | "card"
20
+ | "link"
21
+ | "customer_balance"
22
+ | "us_bank_account";
23
+
24
+ /** USD floors/ceilings aligned with typical Stripe BNPL eligibility (conservative). */
25
+ export const TICKET_BNPL_MIN_CENTS_USD = 3_500; // $35
26
+ export const TICKET_BNPL_MAX_CENTS_USD = 4_000_00; // $4,000 (Afterpay US Pay in 4 band)
27
+
28
+ export interface TicketBnplPaymentMethodInput {
29
+ /** Per-event host toggle (`BashEvent.installmentsEnabled`). */
30
+ installmentsEnabled: boolean;
31
+ /** Per-event host toggle (`BashEvent.cashAppEnabled`). */
32
+ cashAppEnabled?: boolean;
33
+ /** Per-event host toggle (`BashEvent.bankTransferEnabled`). */
34
+ bankTransferEnabled?: boolean;
35
+ /** Buyer total charged at checkout (tickets + guest-visible fees), in cents. */
36
+ totalChargedCents: number;
37
+ /** ISO currency lowercase, e.g. "usd". */
38
+ currency: string;
39
+ /** Platform kill switch (API env `TICKET_BNPL_ENABLED`). Default true in tests. */
40
+ bnplGloballyEnabled?: boolean;
41
+ /** Platform kill switch (API env `TICKET_CASHAPP_ENABLED`). Default true in tests. */
42
+ cashAppGloballyEnabled?: boolean;
43
+ /** Platform kill switch (API env `TICKET_BANK_TRANSFER_ENABLED`). Default true in tests. */
44
+ bankTransferGloballyEnabled?: boolean;
45
+ }
46
+
47
+ /**
48
+ * Resolve `payment_method_types` for Stripe Checkout ticket sessions.
49
+ * BNPL is offered only when the host enabled installments and the order is in range.
50
+ */
51
+ export function resolveTicketCheckoutPaymentMethodTypes(
52
+ input: TicketBnplPaymentMethodInput
53
+ ): TicketCheckoutPaymentMethodType[] {
54
+ const base: TicketCheckoutPaymentMethodType[] = ["card", "link"];
55
+ const methods = new Set<TicketCheckoutPaymentMethodType>(base);
56
+
57
+ const globallyBnplOn = input.bnplGloballyEnabled !== false;
58
+ const globallyCashAppOn = input.cashAppGloballyEnabled !== false;
59
+ const globallyBankTransferOn = input.bankTransferGloballyEnabled !== false;
60
+ const isUsd = input.currency.toLowerCase() === "usd";
61
+ const cents = input.totalChargedCents;
62
+
63
+ if (
64
+ globallyBnplOn &&
65
+ input.installmentsEnabled &&
66
+ isUsd &&
67
+ cents >= TICKET_BNPL_MIN_CENTS_USD &&
68
+ cents <= TICKET_BNPL_MAX_CENTS_USD
69
+ ) {
70
+ methods.add("klarna");
71
+ methods.add("afterpay_clearpay");
72
+ }
73
+
74
+ if (globallyCashAppOn && input.cashAppEnabled && isUsd && cents > 0) {
75
+ methods.add("cashapp");
76
+ }
77
+
78
+ if (
79
+ globallyBankTransferOn &&
80
+ input.bankTransferEnabled &&
81
+ isUsd &&
82
+ cents > 0
83
+ ) {
84
+ methods.add("customer_balance");
85
+ }
86
+
87
+ return [...methods];
88
+ }
89
+
90
+ export function ticketCheckoutOffersBnpl(
91
+ input: TicketBnplPaymentMethodInput
92
+ ): boolean {
93
+ const methods = resolveTicketCheckoutPaymentMethodTypes(input);
94
+ return methods.includes("klarna");
95
+ }
96
+
97
+ export function ticketCheckoutOffersCashApp(
98
+ input: TicketBnplPaymentMethodInput
99
+ ): boolean {
100
+ const methods = resolveTicketCheckoutPaymentMethodTypes(input);
101
+ return methods.includes("cashapp");
102
+ }
103
+
104
+ export function ticketCheckoutOffersBankTransfer(
105
+ input: TicketBnplPaymentMethodInput
106
+ ): boolean {
107
+ const methods = resolveTicketCheckoutPaymentMethodTypes(input);
108
+ return methods.includes("customer_balance");
109
+ }
110
+
111
+ export interface ServiceCheckoutPaymentMethodInput {
112
+ /** Per-service host toggle (`Service.bankTransferEnabled`). */
113
+ bankTransferEnabled?: boolean;
114
+ /** Per-service host toggle (`Service.achDebitEnabled`). */
115
+ achDebitEnabled?: boolean;
116
+ /** Buyer total charged at checkout, in cents. */
117
+ totalChargedCents: number;
118
+ /** ISO currency lowercase, e.g. "usd". */
119
+ currency: string;
120
+ /** Platform kill switch (API env `SERVICE_BANK_TRANSFER_ENABLED`). Default true in tests. */
121
+ bankTransferGloballyEnabled?: boolean;
122
+ /** Platform kill switch (API env `SERVICE_ACH_DEBIT_ENABLED`). Default true in tests. */
123
+ achDebitGloballyEnabled?: boolean;
124
+ }
125
+
126
+ /**
127
+ * Resolve `payment_method_types` for Stripe Checkout service booking sessions.
128
+ * Bank transfer and ACH are async — fulfillment waits for `checkout.session.async_payment_succeeded`.
129
+ */
130
+ export function resolveServiceCheckoutPaymentMethodTypes(
131
+ input: ServiceCheckoutPaymentMethodInput
132
+ ): ServiceCheckoutPaymentMethodType[] {
133
+ const methods = new Set<ServiceCheckoutPaymentMethodType>(["card", "link"]);
134
+ const globallyBankTransferOn = input.bankTransferGloballyEnabled !== false;
135
+ const globallyAchOn = input.achDebitGloballyEnabled !== false;
136
+ const isUsd = input.currency.toLowerCase() === "usd";
137
+ const cents = input.totalChargedCents;
138
+
139
+ if (
140
+ globallyBankTransferOn &&
141
+ input.bankTransferEnabled &&
142
+ isUsd &&
143
+ cents > 0
144
+ ) {
145
+ methods.add("customer_balance");
146
+ }
147
+
148
+ if (globallyAchOn && input.achDebitEnabled && isUsd && cents > 0) {
149
+ methods.add("us_bank_account");
150
+ }
151
+
152
+ return [...methods];
153
+ }
154
+
155
+ export function serviceCheckoutOffersBankTransfer(
156
+ input: ServiceCheckoutPaymentMethodInput
157
+ ): boolean {
158
+ return resolveServiceCheckoutPaymentMethodTypes(input).includes(
159
+ "customer_balance"
160
+ );
161
+ }
162
+
163
+ export function serviceCheckoutOffersAchDebit(
164
+ input: ServiceCheckoutPaymentMethodInput
165
+ ): boolean {
166
+ return resolveServiceCheckoutPaymentMethodTypes(input).includes(
167
+ "us_bank_account"
168
+ );
169
+ }
170
+
171
+ export function checkoutUsesAsyncBankPayment(
172
+ methods: ReadonlyArray<
173
+ TicketCheckoutPaymentMethodType | ServiceCheckoutPaymentMethodType
174
+ >
175
+ ): boolean {
176
+ return methods.includes("customer_balance") || methods.includes("us_bank_account");
177
+ }
@@ -0,0 +1,120 @@
1
+ import {
2
+ haversineMiles,
3
+ effectiveFeaturedForGeoScope,
4
+ sortPublicEventsForFeaturedDiscovery,
5
+ } from "../featuredDiscoveryGeo.js";
6
+
7
+ describe("haversineMiles", () => {
8
+ it("returns zero for identical coordinates", () => {
9
+ expect(haversineMiles(40.7128, -74.006, 40.7128, -74.006)).toBeCloseTo(0, 5);
10
+ });
11
+
12
+ it("computes a plausible distance between NYC and Philadelphia", () => {
13
+ const miles = haversineMiles(40.7128, -74.006, 39.9526, -75.1652);
14
+ expect(miles).toBeGreaterThan(75);
15
+ expect(miles).toBeLessThan(100);
16
+ });
17
+ });
18
+
19
+ describe("effectiveFeaturedForGeoScope", () => {
20
+ const viewer = { lat: 40.7128, lng: -74.006, radiusMiles: 10 };
21
+
22
+ it("returns false when event is not featured", () => {
23
+ expect(effectiveFeaturedForGeoScope(false, [{ lat: 40.71, lng: -74.0 }], viewer)).toBe(false);
24
+ });
25
+
26
+ it("returns true for featured events when no viewer geo is provided", () => {
27
+ expect(effectiveFeaturedForGeoScope(true, [{ lat: 40.71, lng: -74.0 }], undefined)).toBe(true);
28
+ });
29
+
30
+ it("returns false when featured event has no coordinates", () => {
31
+ expect(effectiveFeaturedForGeoScope(true, [{ lat: null, lng: null }], viewer)).toBe(false);
32
+ expect(effectiveFeaturedForGeoScope(true, [], viewer)).toBe(false);
33
+ });
34
+
35
+ it("returns true when featured event is within viewer radius", () => {
36
+ expect(
37
+ effectiveFeaturedForGeoScope(true, [{ lat: 40.72, lng: -74.01 }], viewer)
38
+ ).toBe(true);
39
+ });
40
+
41
+ it("returns false when featured event is outside viewer radius", () => {
42
+ expect(
43
+ effectiveFeaturedForGeoScope(true, [{ lat: 34.0522, lng: -118.2437 }], viewer)
44
+ ).toBe(false);
45
+ });
46
+ });
47
+
48
+ describe("sortPublicEventsForFeaturedDiscovery", () => {
49
+ const viewer = { lat: 40.7128, lng: -74.006, radiusMiles: 10 };
50
+
51
+ it("sorts effective featured events before non-featured", () => {
52
+ const events = [
53
+ {
54
+ id: "b",
55
+ isFeatured: false,
56
+ featuredUntil: null,
57
+ startDateTime: "2026-06-02T00:00:00Z",
58
+ coordinates: [{ lat: 40.72, lng: -74.01 }],
59
+ },
60
+ {
61
+ id: "a",
62
+ isFeatured: true,
63
+ featuredUntil: null,
64
+ startDateTime: "2026-06-01T00:00:00Z",
65
+ coordinates: [{ lat: 40.72, lng: -74.01 }],
66
+ },
67
+ ];
68
+
69
+ const sorted = sortPublicEventsForFeaturedDiscovery(events, viewer);
70
+ expect(sorted.map((e) => e.id)).toEqual(["a", "b"]);
71
+ });
72
+
73
+ it("sorts by startDateTime within the same featured bucket", () => {
74
+ const events = [
75
+ {
76
+ id: "late",
77
+ isFeatured: true,
78
+ featuredUntil: null,
79
+ startDateTime: "2026-06-03T00:00:00Z",
80
+ coordinates: [{ lat: 40.72, lng: -74.01 }],
81
+ },
82
+ {
83
+ id: "early",
84
+ isFeatured: true,
85
+ featuredUntil: null,
86
+ startDateTime: "2026-06-01T00:00:00Z",
87
+ coordinates: [{ lat: 40.72, lng: -74.01 }],
88
+ },
89
+ ];
90
+
91
+ expect(sortPublicEventsForFeaturedDiscovery(events, viewer).map((e) => e.id)).toEqual([
92
+ "early",
93
+ "late",
94
+ ]);
95
+ });
96
+
97
+ it("treats out-of-scope featured events as non-featured for ordering", () => {
98
+ const events = [
99
+ {
100
+ id: "far-featured",
101
+ isFeatured: true,
102
+ featuredUntil: null,
103
+ startDateTime: "2026-06-01T00:00:00Z",
104
+ coordinates: [{ lat: 34.05, lng: -118.24 }],
105
+ },
106
+ {
107
+ id: "local-regular",
108
+ isFeatured: false,
109
+ featuredUntil: null,
110
+ startDateTime: "2026-06-02T00:00:00Z",
111
+ coordinates: [{ lat: 40.72, lng: -74.01 }],
112
+ },
113
+ ];
114
+
115
+ expect(sortPublicEventsForFeaturedDiscovery(events, viewer).map((e) => e.id)).toEqual([
116
+ "far-featured",
117
+ "local-regular",
118
+ ]);
119
+ });
120
+ });
@@ -18,6 +18,9 @@ import {
18
18
  FLYER_SPONSOR_LOGO_ALT_MAX_LENGTH,
19
19
  FLYER_SPONSOR_LOGO_MAX_BYTES,
20
20
  validateFlyerSponsorCreative,
21
+ flyerSponsorSlotPriceCents,
22
+ flyerSponsorSlotHostPct,
23
+ flyerSponsorSlotLabel,
21
24
  } from "../flyerUtils.js";
22
25
 
23
26
  describe("flyerUtils", () => {
@@ -89,6 +92,13 @@ describe("flyerUtils", () => {
89
92
  ]);
90
93
  });
91
94
 
95
+ it("flyerSponsorSlotPriceCents and host pct follow slot type", () => {
96
+ expect(flyerSponsorSlotPriceCents("FEATURED")).toBe(10000);
97
+ expect(flyerSponsorSlotPriceCents("SUPPORTING_1")).toBe(5000);
98
+ expect(flyerSponsorSlotHostPct("FEATURED")).toBe(0.6);
99
+ expect(flyerSponsorSlotLabel("FEATURED")).toBe("Featured");
100
+ });
101
+
92
102
  it("validates sponsor creative text limits and trims fields before checking", () => {
93
103
  expect(
94
104
  validateFlyerSponsorCreative({
@@ -0,0 +1,128 @@
1
+ import {
2
+ capRosterDepthForEventMode,
3
+ filterHiddenVisibilityUserIds,
4
+ orderPreviewAttendeeIds,
5
+ resolveEffectiveEventMode,
6
+ resolveGuestSurfacePolicy,
7
+ viewerMayFetchFullRoster,
8
+ maxGuestListDepth,
9
+ } from "../guestSurface.js";
10
+
11
+ describe("resolveEffectiveEventMode", () => {
12
+ it("maps legacy showAttendees false to Hidden", () => {
13
+ expect(resolveEffectiveEventMode({ showAttendees: false })).toBe("Hidden");
14
+ });
15
+
16
+ it("honours CountOnly", () => {
17
+ expect(
18
+ resolveEffectiveEventMode({ attendeeDisplayMode: "CountOnly" })
19
+ ).toBe("CountOnly");
20
+ });
21
+ });
22
+
23
+ describe("resolveGuestSurfacePolicy", () => {
24
+ it("Hidden mode hides everything for public viewers", () => {
25
+ const policy = resolveGuestSurfacePolicy({
26
+ eventMode: "Hidden",
27
+ isHost: false,
28
+ isAuthenticated: true,
29
+ viewerRosterDepth: "Full",
30
+ });
31
+ expect(policy.showCount).toBe(false);
32
+ expect(policy.showPreviewAvatars).toBe(false);
33
+ });
34
+
35
+ it("CountOnly shows count but not avatars", () => {
36
+ const policy = resolveGuestSurfacePolicy({
37
+ eventMode: "CountOnly",
38
+ isHost: false,
39
+ isAuthenticated: false,
40
+ viewerRosterDepth: "None",
41
+ });
42
+ expect(policy.showCount).toBe(true);
43
+ expect(policy.showPreviewAvatars).toBe(false);
44
+ });
45
+
46
+ it("All mode shows curated preview", () => {
47
+ const policy = resolveGuestSurfacePolicy({
48
+ eventMode: "All",
49
+ isHost: false,
50
+ isAuthenticated: true,
51
+ viewerRosterDepth: "None",
52
+ });
53
+ expect(policy.showPreviewAvatars).toBe(true);
54
+ expect(policy.showNetworkSlice).toBe(true);
55
+ });
56
+ });
57
+
58
+ describe("viewerMayFetchFullRoster", () => {
59
+ it("denies non-host with None depth", () => {
60
+ expect(
61
+ viewerMayFetchFullRoster({ isHost: false, viewerRosterDepth: "None" })
62
+ ).toBe(false);
63
+ });
64
+
65
+ it("allows host", () => {
66
+ expect(
67
+ viewerMayFetchFullRoster({ isHost: true, viewerRosterDepth: "None" })
68
+ ).toBe(true);
69
+ });
70
+
71
+ it("allows VIP Partial depth", () => {
72
+ expect(
73
+ viewerMayFetchFullRoster({ isHost: false, viewerRosterDepth: "Partial" })
74
+ ).toBe(true);
75
+ });
76
+ });
77
+
78
+ describe("orderPreviewAttendeeIds", () => {
79
+ it("puts featured first then by recency", () => {
80
+ const ids = orderPreviewAttendeeIds(
81
+ [
82
+ { userId: "a", paidOn: "2026-01-01" },
83
+ { userId: "b", paidOn: "2026-06-01" },
84
+ { userId: "c", paidOn: "2026-03-01" },
85
+ ],
86
+ ["a"],
87
+ 10
88
+ );
89
+ expect(ids[0]).toBe("a");
90
+ expect(ids[1]).toBe("b");
91
+ });
92
+ });
93
+
94
+ describe("maxGuestListDepth", () => {
95
+ it("picks highest depth", () => {
96
+ expect(maxGuestListDepth("None", "Partial", "Full")).toBe("Full");
97
+ });
98
+ });
99
+
100
+ describe("capRosterDepthForEventMode", () => {
101
+ it("caps VIP to None when event is NetworkOnly", () => {
102
+ expect(
103
+ capRosterDepthForEventMode("Full", "NetworkOnly", false)
104
+ ).toBe("None");
105
+ });
106
+ });
107
+
108
+ describe("filterHiddenVisibilityUserIds", () => {
109
+ it("hides FeaturedInvited from public preview", () => {
110
+ const map = new Map([
111
+ ["a", "Visible" as const],
112
+ ["b", "FeaturedInvited" as const],
113
+ ["c", "FeaturedPending" as const],
114
+ ]);
115
+ expect(filterHiddenVisibilityUserIds(["a", "b", "c"], map, false)).toEqual([
116
+ "a",
117
+ "c",
118
+ ]);
119
+ });
120
+
121
+ it("shows all ids to host including FeaturedInvited", () => {
122
+ const map = new Map([["b", "FeaturedInvited" as const]]);
123
+ expect(filterHiddenVisibilityUserIds(["a", "b"], map, true)).toEqual([
124
+ "a",
125
+ "b",
126
+ ]);
127
+ });
128
+ });
@@ -0,0 +1,131 @@
1
+ import {
2
+ isTicketTierGuestListVisible,
3
+ resolveTicketTierScheduleState,
4
+ scheduleValidationHasErrors,
5
+ validateTicketTierScheduleInput,
6
+ } from "../ticketTierSchedule.js";
7
+
8
+ describe("resolveTicketTierScheduleState", () => {
9
+ const future = new Date("2030-06-01T18:00:00.000Z");
10
+ const past = new Date("2020-01-01T12:00:00.000Z");
11
+ const now = new Date("2030-05-01T12:00:00.000Z");
12
+
13
+ it("returns Live for immediate tier with inventory", () => {
14
+ const state = resolveTicketTierScheduleState(
15
+ { availabilityType: "Immediate" },
16
+ { now, availableInventory: 10 }
17
+ );
18
+ expect(state.phase).toBe("Live");
19
+ expect(state.canPurchase).toBe(true);
20
+ });
21
+
22
+ it("does not treat missing inventory as sold out", () => {
23
+ const state = resolveTicketTierScheduleState(
24
+ { availabilityType: "Immediate" },
25
+ { now }
26
+ );
27
+ expect(state.phase).toBe("Live");
28
+ expect(state.soldOut).toBe(false);
29
+ expect(state.hostPreviewLabel).toBe("Live");
30
+ });
31
+
32
+ it("returns TeasePreRelease before OnDate", () => {
33
+ const state = resolveTicketTierScheduleState(
34
+ {
35
+ availabilityType: "OnDate",
36
+ availableAt: future,
37
+ preReleaseDisplay: "Tease",
38
+ },
39
+ { now, availableInventory: 10 }
40
+ );
41
+ expect(state.phase).toBe("TeasePreRelease");
42
+ expect(state.canPurchase).toBe(false);
43
+ expect(state.availability.reason).toBe("OnDate");
44
+ expect(isTicketTierGuestListVisible(state)).toBe(true);
45
+ });
46
+
47
+ it("returns HiddenPreRelease when preReleaseDisplay is Hidden", () => {
48
+ const state = resolveTicketTierScheduleState(
49
+ {
50
+ availabilityType: "OnDate",
51
+ availableAt: future,
52
+ preReleaseDisplay: "Hidden",
53
+ },
54
+ { now, availableInventory: 10 }
55
+ );
56
+ expect(state.phase).toBe("HiddenPreRelease");
57
+ expect(isTicketTierGuestListVisible(state)).toBe(false);
58
+ });
59
+
60
+ it("returns Expired after expiresAt", () => {
61
+ const state = resolveTicketTierScheduleState(
62
+ { expiresAt: past },
63
+ { now, availableInventory: 10 }
64
+ );
65
+ expect(state.phase).toBe("Expired");
66
+ expect(state.canPurchase).toBe(false);
67
+ });
68
+
69
+ it("returns SoldOut when manually sold out", () => {
70
+ const state = resolveTicketTierScheduleState(
71
+ { isManuallySoldOut: true },
72
+ { now, availableInventory: 50 }
73
+ );
74
+ expect(state.phase).toBe("SoldOut");
75
+ expect(state.showWaitlistCta).toBe(false);
76
+ });
77
+
78
+ it("shows waitlist CTA when sold out and waitlist tier", () => {
79
+ const state = resolveTicketTierScheduleState(
80
+ { isWaitlistTier: true },
81
+ { now, availableInventory: 0 }
82
+ );
83
+ expect(state.phase).toBe("SoldOut");
84
+ expect(state.showWaitlistCta).toBe(true);
85
+ });
86
+
87
+ it("blocks EngagedUsersOnly when viewer not engaged", () => {
88
+ const state = resolveTicketTierScheduleState(
89
+ { availabilityType: "EngagedUsersOnly" },
90
+ { now, availableInventory: 5, isViewerEngaged: false }
91
+ );
92
+ expect(state.canPurchase).toBe(false);
93
+ expect(state.availability.reason).toBe("EngagedUsersOnly");
94
+ });
95
+
96
+ it("UnlockWhenTicketsSold unlocks at threshold", () => {
97
+ const locked = resolveTicketTierScheduleState(
98
+ {
99
+ availabilityType: "UnlockWhenTicketsSold",
100
+ unlockWhenTicketsSold: 50,
101
+ },
102
+ { now, availableInventory: 10, ticketsSoldCount: 10 }
103
+ );
104
+ expect(locked.canPurchase).toBe(false);
105
+
106
+ const open = resolveTicketTierScheduleState(
107
+ {
108
+ availabilityType: "UnlockWhenTicketsSold",
109
+ unlockWhenTicketsSold: 50,
110
+ },
111
+ { now, availableInventory: 10, ticketsSoldCount: 50 }
112
+ );
113
+ expect(open.canPurchase).toBe(true);
114
+ });
115
+ });
116
+
117
+ describe("validateTicketTierScheduleInput", () => {
118
+ it("requires availableAt for OnDate", () => {
119
+ const issues = validateTicketTierScheduleInput({ availabilityType: "OnDate" });
120
+ expect(scheduleValidationHasErrors(issues)).toBe(true);
121
+ });
122
+
123
+ it("rejects expires before release", () => {
124
+ const issues = validateTicketTierScheduleInput({
125
+ availabilityType: "OnDate",
126
+ availableAt: new Date("2030-06-02T12:00:00Z"),
127
+ expiresAt: new Date("2030-06-01T12:00:00Z"),
128
+ });
129
+ expect(scheduleValidationHasErrors(issues)).toBe(true);
130
+ });
131
+ });