@bash-app/bash-common 30.225.0 → 30.227.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 (142) hide show
  1. package/dist/__tests__/agePolicy.test.d.ts +2 -0
  2. package/dist/__tests__/agePolicy.test.d.ts.map +1 -0
  3. package/dist/__tests__/agePolicy.test.js +203 -0
  4. package/dist/__tests__/agePolicy.test.js.map +1 -0
  5. package/dist/__tests__/getDisplayGender.test.d.ts +2 -0
  6. package/dist/__tests__/getDisplayGender.test.d.ts.map +1 -0
  7. package/dist/__tests__/getDisplayGender.test.js +34 -0
  8. package/dist/__tests__/getDisplayGender.test.js.map +1 -0
  9. package/dist/__tests__/statusEnums.test.d.ts +2 -0
  10. package/dist/__tests__/statusEnums.test.d.ts.map +1 -0
  11. package/dist/__tests__/statusEnums.test.js +24 -0
  12. package/dist/__tests__/statusEnums.test.js.map +1 -0
  13. package/dist/aiApproval.d.ts +89 -0
  14. package/dist/aiApproval.d.ts.map +1 -0
  15. package/dist/aiApproval.js +82 -0
  16. package/dist/aiApproval.js.map +1 -0
  17. package/dist/definitions.d.ts +44 -2
  18. package/dist/definitions.d.ts.map +1 -1
  19. package/dist/definitions.js +106 -2
  20. package/dist/definitions.js.map +1 -1
  21. package/dist/extendedSchemas.d.ts +280 -105
  22. package/dist/extendedSchemas.d.ts.map +1 -1
  23. package/dist/extendedSchemas.js +34 -2
  24. package/dist/extendedSchemas.js.map +1 -1
  25. package/dist/index.d.ts +5 -2
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +4 -1
  28. package/dist/index.js.map +1 -1
  29. package/dist/legalTemplates.d.ts +1 -1
  30. package/dist/legalTemplates.js +1 -1
  31. package/dist/utils/__tests__/badgeUtils.test.d.ts +2 -0
  32. package/dist/utils/__tests__/badgeUtils.test.d.ts.map +1 -0
  33. package/dist/utils/__tests__/badgeUtils.test.js +157 -0
  34. package/dist/utils/__tests__/badgeUtils.test.js.map +1 -0
  35. package/dist/utils/__tests__/bashPointsPaymentUtils.test.d.ts +2 -0
  36. package/dist/utils/__tests__/bashPointsPaymentUtils.test.d.ts.map +1 -0
  37. package/dist/utils/__tests__/bashPointsPaymentUtils.test.js +114 -0
  38. package/dist/utils/__tests__/bashPointsPaymentUtils.test.js.map +1 -0
  39. package/dist/utils/__tests__/contentFilterUtils.test.d.ts +2 -0
  40. package/dist/utils/__tests__/contentFilterUtils.test.d.ts.map +1 -0
  41. package/dist/utils/__tests__/contentFilterUtils.test.js +443 -0
  42. package/dist/utils/__tests__/contentFilterUtils.test.js.map +1 -0
  43. package/dist/utils/__tests__/displayDateUtils.test.d.ts +2 -0
  44. package/dist/utils/__tests__/displayDateUtils.test.d.ts.map +1 -0
  45. package/dist/utils/__tests__/displayDateUtils.test.js +105 -0
  46. package/dist/utils/__tests__/displayDateUtils.test.js.map +1 -0
  47. package/dist/utils/__tests__/flyerUtils.test.d.ts +2 -0
  48. package/dist/utils/__tests__/flyerUtils.test.d.ts.map +1 -0
  49. package/dist/utils/__tests__/flyerUtils.test.js +49 -0
  50. package/dist/utils/__tests__/flyerUtils.test.js.map +1 -0
  51. package/dist/utils/__tests__/getBackendHost.test.d.ts +2 -0
  52. package/dist/utils/__tests__/getBackendHost.test.d.ts.map +1 -0
  53. package/dist/utils/__tests__/getBackendHost.test.js +104 -0
  54. package/dist/utils/__tests__/getBackendHost.test.js.map +1 -0
  55. package/dist/utils/__tests__/getFrontendHost.test.d.ts +2 -0
  56. package/dist/utils/__tests__/getFrontendHost.test.d.ts.map +1 -0
  57. package/dist/utils/__tests__/getFrontendHost.test.js +207 -0
  58. package/dist/utils/__tests__/getFrontendHost.test.js.map +1 -0
  59. package/dist/utils/__tests__/promoCodesUtils.test.d.ts +2 -0
  60. package/dist/utils/__tests__/promoCodesUtils.test.d.ts.map +1 -0
  61. package/dist/utils/__tests__/promoCodesUtils.test.js +183 -0
  62. package/dist/utils/__tests__/promoCodesUtils.test.js.map +1 -0
  63. package/dist/utils/__tests__/qrCodeUtils.test.d.ts +2 -0
  64. package/dist/utils/__tests__/qrCodeUtils.test.d.ts.map +1 -0
  65. package/dist/utils/__tests__/qrCodeUtils.test.js +99 -0
  66. package/dist/utils/__tests__/qrCodeUtils.test.js.map +1 -0
  67. package/dist/utils/__tests__/stripeAccountUtils.test.d.ts +2 -0
  68. package/dist/utils/__tests__/stripeAccountUtils.test.d.ts.map +1 -0
  69. package/dist/utils/__tests__/stripeAccountUtils.test.js +75 -0
  70. package/dist/utils/__tests__/stripeAccountUtils.test.js.map +1 -0
  71. package/dist/utils/blog/__tests__/getRelatedBlogPosts.test.d.ts +2 -0
  72. package/dist/utils/blog/__tests__/getRelatedBlogPosts.test.d.ts.map +1 -0
  73. package/dist/utils/blog/__tests__/getRelatedBlogPosts.test.js +233 -0
  74. package/dist/utils/blog/__tests__/getRelatedBlogPosts.test.js.map +1 -0
  75. package/dist/utils/blog/blogCommentDbUtils.d.ts +161 -0
  76. package/dist/utils/blog/blogCommentDbUtils.d.ts.map +1 -0
  77. package/dist/utils/blog/blogCommentDbUtils.js +111 -0
  78. package/dist/utils/blog/blogCommentDbUtils.js.map +1 -0
  79. package/dist/utils/blog/blogDbUtils.d.ts +10 -0
  80. package/dist/utils/blog/blogDbUtils.d.ts.map +1 -1
  81. package/dist/utils/blog/blogDbUtils.js +71 -0
  82. package/dist/utils/blog/blogDbUtils.js.map +1 -1
  83. package/dist/utils/flyerUtils.d.ts +42 -0
  84. package/dist/utils/flyerUtils.d.ts.map +1 -0
  85. package/dist/utils/flyerUtils.js +104 -0
  86. package/dist/utils/flyerUtils.js.map +1 -0
  87. package/dist/utils/urlUtils.d.ts +10 -0
  88. package/dist/utils/urlUtils.d.ts.map +1 -1
  89. package/dist/utils/urlUtils.js +24 -5
  90. package/dist/utils/urlUtils.js.map +1 -1
  91. package/dist/utils/userUtils.d.ts +32 -0
  92. package/dist/utils/userUtils.d.ts.map +1 -1
  93. package/dist/utils/userUtils.js +27 -0
  94. package/dist/utils/userUtils.js.map +1 -1
  95. package/package.json +1 -1
  96. package/prisma/schema.prisma +1196 -845
  97. package/src/__tests__/agePolicy.test.ts +257 -0
  98. package/src/__tests__/getDisplayGender.test.ts +50 -0
  99. package/src/__tests__/statusEnums.test.ts +36 -0
  100. package/src/aiApproval.ts +118 -0
  101. package/src/definitions.ts +138 -1
  102. package/src/extendedSchemas.ts +86 -3
  103. package/src/index.ts +15 -0
  104. package/src/legalTemplates.ts +1 -1
  105. package/src/utils/__tests__/badgeUtils.test.ts +191 -0
  106. package/src/utils/__tests__/bashPointsPaymentUtils.test.ts +153 -0
  107. package/src/utils/__tests__/contentFilterUtils.test.ts +547 -0
  108. package/src/utils/__tests__/displayDateUtils.test.ts +113 -0
  109. package/src/utils/__tests__/flyerUtils.test.ts +70 -0
  110. package/src/utils/__tests__/getBackendHost.test.ts +128 -0
  111. package/src/utils/__tests__/getFrontendHost.test.ts +232 -0
  112. package/src/utils/__tests__/promoCodesUtils.test.ts +237 -0
  113. package/src/utils/__tests__/qrCodeUtils.test.ts +143 -0
  114. package/src/utils/__tests__/stripeAccountUtils.test.ts +93 -0
  115. package/src/utils/blog/__tests__/getRelatedBlogPosts.test.ts +271 -0
  116. package/src/utils/blog/blogCommentDbUtils.ts +161 -0
  117. package/src/utils/blog/blogDbUtils.ts +95 -1
  118. package/src/utils/flyerUtils.ts +137 -0
  119. package/src/utils/urlUtils.ts +27 -5
  120. package/src/utils/userUtils.ts +40 -0
  121. package/dist/utils/__tests__/cancellationPolicyRefundResolver.test.d.ts +0 -6
  122. package/dist/utils/__tests__/cancellationPolicyRefundResolver.test.d.ts.map +0 -1
  123. package/dist/utils/__tests__/cancellationPolicyRefundResolver.test.js +0 -104
  124. package/dist/utils/__tests__/cancellationPolicyRefundResolver.test.js.map +0 -1
  125. package/dist/utils/service/serviceRateDBUtils.d.ts +0 -1
  126. package/dist/utils/service/serviceRateDBUtils.d.ts.map +0 -1
  127. package/dist/utils/service/serviceRateDBUtils.js +0 -159
  128. package/dist/utils/service/serviceRateDBUtils.js.map +0 -1
  129. package/prisma/migrations/add_bash_availability.sql +0 -53
  130. package/prisma/migrations/add_bashcash_pricing_to_ticket_tier.sql +0 -15
  131. package/prisma/migrations/add_bashpoints_purchase_tracking.sql +0 -33
  132. package/prisma/migrations/add_event_group_allow_auto_join.sql +0 -17
  133. package/prisma/migrations/add_group_member_status_not_going_maybe.sql +0 -5
  134. package/prisma/migrations/add_groups_and_momentum.sql +0 -135
  135. package/prisma/migrations/add_groups_momentum_phase0_4.sql +0 -200
  136. package/prisma/migrations/add_new_feature_tables.sql +0 -140
  137. package/prisma/migrations/add_pricing_type_enum.sql +0 -88
  138. package/prisma/migrations/add_tier_privacy_and_fee_handling.sql +0 -29
  139. package/prisma/migrations/diagnostic_bashcash_columns.sql +0 -157
  140. package/prisma/migrations/fix_bashcash_referral_code_schema_mismatch.sql +0 -81
  141. package/prisma/migrations/rename_bashcash_to_bashpoints.sql +0 -183
  142. package/prisma/migrations/rename_bashcredits_to_bashpoints.sql +0 -96
@@ -79,7 +79,13 @@ import {
79
79
  VendorPreferences,
80
80
  VendorSocialMediaExpectation,
81
81
  Venue,
82
- VolunteerService
82
+ VolunteerService,
83
+ FlyerCampaign,
84
+ FlyerMailingRoute,
85
+ FlyerArtwork,
86
+ FlyerCampaignSponsor,
87
+ FlyerConversion,
88
+ PostalCarrierRoute,
83
89
  } from "@prisma/client";
84
90
  import { BlogStatus, SERVICE_LINK_DATA_TO_INCLUDE } from "./definitions.js";
85
91
  import {
@@ -143,8 +149,14 @@ export const FRONT_END_USER_DATA_TO_SELECT = {
143
149
  state: true,
144
150
  createdOn: true,
145
151
  dob: true,
146
- gender: true,
147
- sex: true,
152
+ // Raw `gender`, `genderIdentity`, `sex` are intentionally NOT in the public select.
153
+ // The public profile API exposes only the computed `genderDisplay` (gated by
154
+ // `showGenderOnProfile`). Self-account selects re-add the raw fields below
155
+ // (see PRIVATE_USER_ACCOUNT_TO_SELECT) so the user can edit their own value.
156
+ showAgeOnProfile: true,
157
+ showGenderOnProfile: true,
158
+ showVerifiedBadgesOnProfile: true,
159
+ schoolName: true,
148
160
  documentIDId: true,
149
161
  // documentID: true,
150
162
  /** Set when Stripe Identity verification completes (hosted flow). */
@@ -186,6 +198,10 @@ export const FRONT_END_USER_DATA_TO_SELECT = {
186
198
 
187
199
  export const PRIVATE_USER_ACCOUNT_TO_SELECT = {
188
200
  ...FRONT_END_USER_DATA_TO_SELECT,
201
+ // Self-only sensitive demographics — re-added so /me can populate edit forms.
202
+ gender: true,
203
+ genderIdentity: true,
204
+ sex: true,
189
205
  street: true,
190
206
  city: true,
191
207
  state: true,
@@ -282,6 +298,8 @@ export interface BashEventExt extends Omit<
282
298
  merchandiseItems: BashEventMerchCatalog | null;
283
299
  // Prisma relation fields (not included in the flat BashEvent model type)
284
300
  coordinates?: Coordinates[];
301
+ /** Linked venue (when bashEvent.venueId is set and `venue` is requested in include params) */
302
+ venue?: Venue | null;
285
303
  targetAudience?: TargetAudience;
286
304
  amountOfGuests?: AmountOfGuests;
287
305
  recurrence?: Recurrence;
@@ -1125,6 +1143,8 @@ export const INVITATION_DATA_TO_INCLUDE = {
1125
1143
  export interface InvitationExtraData extends Invitation {
1126
1144
  isFreeGuest: boolean;
1127
1145
  isOrganizer?: boolean;
1146
+ isCoHost?: boolean;
1147
+ organizerTitle?: string;
1128
1148
  isOwnershipTransfer?: boolean;
1129
1149
  includeQRCode?: boolean; // Controls whether QR code should be included in the invitation email
1130
1150
  }
@@ -1230,6 +1250,31 @@ export interface UserExt extends User {
1230
1250
  */
1231
1251
  birthdayDisplay?: string;
1232
1252
 
1253
+ /**
1254
+ * Computed by public user API when `showAgeOnProfile` is true and `dob` is set.
1255
+ * Example: "32".
1256
+ */
1257
+ ageDisplay?: string;
1258
+
1259
+ /**
1260
+ * Computed by public user API when `showGenderOnProfile` is true.
1261
+ * Resolves `genderIdentity` (free-text self-describe) ahead of the enum `gender`.
1262
+ */
1263
+ genderDisplay?: string;
1264
+
1265
+ /**
1266
+ * Computed trust badges. Server gates the entire object on
1267
+ * `showVerifiedBadgesOnProfile` and strips the underlying raw fields
1268
+ * (`paymentVerified`, `stripeAccountId`, `studentIdImageS3Key`, `schoolName`)
1269
+ * from the public response.
1270
+ */
1271
+ badges?: {
1272
+ idVerified: boolean;
1273
+ paymentVerified: boolean;
1274
+ verifiedHost: boolean;
1275
+ studentAt: string | null;
1276
+ };
1277
+
1233
1278
  /** Included when the user query selects the `UserAvailability` relation (not on default Prisma `User`). */
1234
1279
  bashAvailability?: BashAvailability | null;
1235
1280
 
@@ -1424,3 +1469,41 @@ export interface BlogTagFormData {
1424
1469
  slug?: string;
1425
1470
  color?: string;
1426
1471
  }
1472
+
1473
+ // --- Flyer Blast (EDDM campaigns) ---
1474
+ export const FLYER_CAMPAIGN_WITH_RELATIONS_INCLUDE = {
1475
+ bashEvent: {
1476
+ select: {
1477
+ id: true,
1478
+ title: true,
1479
+ slug: true,
1480
+ coverPhoto: true,
1481
+ themeColor: true,
1482
+ startDateTime: true,
1483
+ endDateTime: true,
1484
+ location: true,
1485
+ street: true,
1486
+ city: true,
1487
+ state: true,
1488
+ zipCode: true,
1489
+ },
1490
+ },
1491
+ routes: true,
1492
+ sponsors: true,
1493
+ artworkVersions: { orderBy: { version: "desc" as const }, take: 3 },
1494
+ } satisfies Prisma.FlyerCampaignInclude;
1495
+
1496
+ export type FlyerCampaignExt = Prisma.FlyerCampaignGetPayload<{
1497
+ include: typeof FLYER_CAMPAIGN_WITH_RELATIONS_INCLUDE;
1498
+ }>;
1499
+
1500
+ export interface FlyerAnalyticsSummary {
1501
+ campaignId: string;
1502
+ qrScans: number;
1503
+ sponsorQrScans: number;
1504
+ rsvps: number;
1505
+ ticketPurchases: number;
1506
+ byRoute: { crrtCode: string; zipCode: string; scans: number }[];
1507
+ }
1508
+
1509
+ export type PostalCarrierRouteExt = PostalCarrierRoute;
package/src/index.ts CHANGED
@@ -7,6 +7,7 @@ export * from "./extendedSchemas.js";
7
7
  export * from "./partnerStoreTypes.js";
8
8
  export * from "./bashFeedTypes.js";
9
9
  export * from "./membershipDefinitions.js";
10
+ export * from "./aiApproval.js";
10
11
 
11
12
  // Re-export ALL Prisma enums as values (usable as runtime constants, e.g. ServiceTypes.Entertainment)
12
13
  // Excludes enums already re-exported by definitions.ts and membershipDefinitions.ts:
@@ -16,6 +17,7 @@ export * from "./membershipDefinitions.js";
16
17
  export {
17
18
  $Enums,
18
19
  AISpecialistFormat,
20
+ AiApprovalVerdict,
19
21
  AVTechnicianFormat,
20
22
  AgeRange,
21
23
  AmbashadorApplicationStatus,
@@ -84,6 +86,11 @@ export {
84
86
  EventTemplateCategory,
85
87
  ExhibitorBookingStatus,
86
88
  FinancingSubType,
89
+ FlyerCampaignStatus,
90
+ FlyerConversionType,
91
+ FlyerFulfillmentType,
92
+ FlyerSponsorSlot,
93
+ FlyerTargetingMode,
87
94
  FloralFormat,
88
95
  FoodAndBeverageSubType,
89
96
  FoodCartStandFormat,
@@ -222,7 +229,13 @@ export type {
222
229
  DocumentID,
223
230
  EventTask,
224
231
  Exhibitor,
232
+ FlyerArtwork,
233
+ FlyerCampaign,
234
+ FlyerCampaignSponsor,
235
+ FlyerConversion,
236
+ FlyerMailingRoute,
225
237
  GoogleReview,
238
+ PostalCarrierRoute,
226
239
  Invitation,
227
240
  Link,
228
241
  Media,
@@ -293,8 +306,10 @@ export * from "./utils/urlUtils.js";
293
306
  export * from "./utils/userPromoCodeUtils.js";
294
307
  export * from "./utils/userSubscriptionUtils.js";
295
308
  export * from "./utils/blog/blogDbUtils.js";
309
+ export * from "./utils/blog/blogCommentDbUtils.js";
296
310
  export * from "./utils/blogUtils.js";
297
311
  export * from "./utils/entityUtils.js";
312
+ export * from "./utils/flyerUtils.js";
298
313
  export * from "./utils/generalDateTimeUtils.js";
299
314
  export * from "./utils/luxonUtils.js";
300
315
  export * from "./utils/mediaClientDefaults.js";
@@ -8,7 +8,7 @@ export const DEFAULT_PHOTO_RELEASE_TEXT = `By attending this event, you understa
8
8
  If you do not wish to appear in promotional materials, contact the event organizer before or during the event. For minors, a parent or guardian must agree to this consent.`;
9
9
 
10
10
  /**
11
- * Effective photo release text for display (wizard preview, event page, checkout).
11
+ * Effective photo release text for display (wizard preview, bash detail page, checkout).
12
12
  */
13
13
  export function getPhotoReleaseDisplayText(
14
14
  photoReleaseText: string | null | undefined
@@ -0,0 +1,191 @@
1
+ /**
2
+ * badgeUtils — host level / temp-badge helpers.
3
+ *
4
+ * The level-badge calculation is the public reputation signal on every host
5
+ * profile. A bug here either over- or under-credits a host (= unfair / loss
6
+ * of trust). We pin the actual ladder defined in DEFAULT_LEVEL_BADGES:
7
+ *
8
+ * novice → 1+ events
9
+ * regular → 5+ events, rating ≥ 4.0
10
+ * maverick → 15+ events, rating ≥ 4.5
11
+ * legend → 30+ events, rating ≥ 4.8
12
+ *
13
+ * We also pin temp-badge expiry semantics: missing expiresOn = never expires.
14
+ */
15
+ import {
16
+ getLevelBadgeById,
17
+ getTemporaryBadgeById,
18
+ isTemporaryBadgeExpired,
19
+ filterExpiredTemporaryBadges,
20
+ determineLevelBadge,
21
+ } from "../badgeUtils.js";
22
+ import type { UserTemporaryBadge } from "../../definitions.js";
23
+
24
+ // ============================================================================
25
+ // getLevelBadgeById / getTemporaryBadgeById — registry lookups
26
+ // ============================================================================
27
+
28
+ describe("getLevelBadgeById", () => {
29
+ it("returns the canonical badge object for a known id", () => {
30
+ const b = getLevelBadgeById("legend");
31
+ expect(b).toBeDefined();
32
+ expect(b?.id).toBe("legend");
33
+ expect(b?.requiredEvents).toBe(30);
34
+ expect(b?.requiredRating).toBe(4.8);
35
+ });
36
+
37
+ it("returns undefined for an unknown id", () => {
38
+ expect(getLevelBadgeById("definitely-not-a-badge")).toBeUndefined();
39
+ });
40
+
41
+ it("returns undefined for empty string", () => {
42
+ expect(getLevelBadgeById("")).toBeUndefined();
43
+ });
44
+ });
45
+
46
+ describe("getTemporaryBadgeById", () => {
47
+ it("returns a known temp badge", () => {
48
+ expect(getTemporaryBadgeById("trendingStar")?.id).toBe("trendingStar");
49
+ });
50
+
51
+ it("returns undefined for an unknown id", () => {
52
+ expect(getTemporaryBadgeById("nope")).toBeUndefined();
53
+ });
54
+ });
55
+
56
+ // ============================================================================
57
+ // isTemporaryBadgeExpired
58
+ // ============================================================================
59
+
60
+ describe("isTemporaryBadgeExpired", () => {
61
+ it("returns false when expiresOn is missing (badge never expires)", () => {
62
+ const badge: UserTemporaryBadge = {
63
+ badgeId: "earlyAdopter",
64
+ earnedOn: "2020-01-01T00:00:00Z",
65
+ };
66
+ expect(isTemporaryBadgeExpired(badge)).toBe(false);
67
+ });
68
+
69
+ it("returns true for a clearly past expiresOn", () => {
70
+ const badge: UserTemporaryBadge = {
71
+ badgeId: "trendingStar",
72
+ earnedOn: "2020-01-01T00:00:00Z",
73
+ expiresOn: "2020-02-01T00:00:00Z",
74
+ };
75
+ expect(isTemporaryBadgeExpired(badge)).toBe(true);
76
+ });
77
+
78
+ it("returns false for a clearly future expiresOn", () => {
79
+ const future = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000)
80
+ .toISOString();
81
+ const badge: UserTemporaryBadge = {
82
+ badgeId: "topHost",
83
+ earnedOn: new Date().toISOString(),
84
+ expiresOn: future,
85
+ };
86
+ expect(isTemporaryBadgeExpired(badge)).toBe(false);
87
+ });
88
+
89
+ it("returns true when expiresOn is RIGHT NOW (boundary: not strictly in the future)", () => {
90
+ // Source uses `expiryDate < currentDate`. A simultaneous tick = false (not expired),
91
+ // but Date.now() inside the function is sampled later than ours — which is enough
92
+ // to flip <. We pin the practical observable: "earlier than now → expired".
93
+ const past = new Date(Date.now() - 1).toISOString();
94
+ const badge: UserTemporaryBadge = {
95
+ badgeId: "topHost",
96
+ earnedOn: "2020-01-01T00:00:00Z",
97
+ expiresOn: past,
98
+ };
99
+ expect(isTemporaryBadgeExpired(badge)).toBe(true);
100
+ });
101
+ });
102
+
103
+ // ============================================================================
104
+ // filterExpiredTemporaryBadges
105
+ // ============================================================================
106
+
107
+ describe("filterExpiredTemporaryBadges", () => {
108
+ it("returns [] for [] (no surprise allocation)", () => {
109
+ expect(filterExpiredTemporaryBadges([])).toEqual([]);
110
+ });
111
+
112
+ it("keeps non-expiring badges", () => {
113
+ const badges: UserTemporaryBadge[] = [
114
+ { badgeId: "earlyAdopter", earnedOn: "2020-01-01T00:00:00Z" },
115
+ ];
116
+ expect(filterExpiredTemporaryBadges(badges)).toHaveLength(1);
117
+ });
118
+
119
+ it("drops expired badges and keeps the rest", () => {
120
+ const future = new Date(Date.now() + 86_400_000).toISOString();
121
+ const past = new Date(Date.now() - 86_400_000).toISOString();
122
+ const badges: UserTemporaryBadge[] = [
123
+ { badgeId: "topHost", earnedOn: "2020-01-01T00:00:00Z", expiresOn: past },
124
+ { badgeId: "trendingStar", earnedOn: "2020-01-01T00:00:00Z", expiresOn: future },
125
+ { badgeId: "earlyAdopter", earnedOn: "2020-01-01T00:00:00Z" },
126
+ ];
127
+ const result = filterExpiredTemporaryBadges(badges);
128
+ expect(result.map((b) => b.badgeId)).toEqual(["trendingStar", "earlyAdopter"]);
129
+ });
130
+ });
131
+
132
+ // ============================================================================
133
+ // determineLevelBadge — the reputation ladder
134
+ // ============================================================================
135
+
136
+ describe("determineLevelBadge — host reputation ladder", () => {
137
+ it("returns null when the host has no events", () => {
138
+ expect(determineLevelBadge(0, 5)).toBeNull();
139
+ });
140
+
141
+ it("returns null when events are below the lowest threshold (1) even with high rating", () => {
142
+ expect(determineLevelBadge(0, 5)).toBeNull();
143
+ });
144
+
145
+ it("awards 'novice' at 1 event with no rating threshold", () => {
146
+ expect(determineLevelBadge(1, 0)).toBe("novice");
147
+ });
148
+
149
+ it("stays at 'novice' between 1 and 4 events", () => {
150
+ expect(determineLevelBadge(2, 4.9)).toBe("novice");
151
+ expect(determineLevelBadge(4, 4.9)).toBe("novice");
152
+ });
153
+
154
+ it("awards 'regular' at 5 events when rating ≥ 4.0", () => {
155
+ expect(determineLevelBadge(5, 4.0)).toBe("regular");
156
+ expect(determineLevelBadge(10, 4.49)).toBe("regular");
157
+ });
158
+
159
+ it("falls BACK to 'novice' when 5+ events but rating < 4.0 (rating gate failed)", () => {
160
+ // Rating gate failed for 'regular', so the next-best the host qualifies for is 'novice'.
161
+ expect(determineLevelBadge(5, 3.9)).toBe("novice");
162
+ expect(determineLevelBadge(20, 3.0)).toBe("novice");
163
+ });
164
+
165
+ it("awards 'maverick' at 15 events when rating ≥ 4.5", () => {
166
+ expect(determineLevelBadge(15, 4.5)).toBe("maverick");
167
+ expect(determineLevelBadge(29, 4.79)).toBe("maverick");
168
+ });
169
+
170
+ it("falls back to 'regular' at 15+ events when rating is between 4.0 and 4.5", () => {
171
+ expect(determineLevelBadge(15, 4.49)).toBe("regular");
172
+ });
173
+
174
+ it("awards 'legend' at 30 events when rating ≥ 4.8", () => {
175
+ expect(determineLevelBadge(30, 4.8)).toBe("legend");
176
+ expect(determineLevelBadge(1000, 5)).toBe("legend");
177
+ });
178
+
179
+ it("falls back to 'maverick' at 30+ events when rating drops below 4.8", () => {
180
+ expect(determineLevelBadge(30, 4.7)).toBe("maverick");
181
+ });
182
+
183
+ it("falls all the way back to 'novice' for a host with many events but a poor rating", () => {
184
+ // 1000 events but rating 1.0 → only the no-rating-gate 'novice' qualifies.
185
+ expect(determineLevelBadge(1000, 1.0)).toBe("novice");
186
+ });
187
+
188
+ it("treats fractional event counts the same as integer (>=)", () => {
189
+ expect(determineLevelBadge(5.5, 4.0)).toBe("regular");
190
+ });
191
+ });
@@ -0,0 +1,153 @@
1
+ /**
2
+ * bashPointsPaymentUtils — earning math + display + deprecated stubs.
3
+ *
4
+ * BashPoints are loyalty cashback. The active code paths are:
5
+ * - calculateBashPointsCashback(amountCents, rate) → integer points
6
+ * - formatBashPoints(n) → "1,000 points"
7
+ *
8
+ * The "service booking" overloads were intentionally deprecated because
9
+ * BashPoints have no fixed dollar value and cannot pay for services. We pin
10
+ * the deprecated stubs here so they stay benign (return safe defaults, log a
11
+ * warning) — accidentally re-enabling them would cause real money loss.
12
+ */
13
+ import {
14
+ formatBashPoints,
15
+ calculateBashPointsCashback,
16
+ centsToBashPoints,
17
+ bashPointsToCents,
18
+ calculateMaxBashPointsForBooking,
19
+ calculateSplitPayment,
20
+ validateBashPointsPayment,
21
+ } from "../bashPointsPaymentUtils.js";
22
+
23
+ describe("formatBashPoints", () => {
24
+ it("uses singular 'point' for exactly 1", () => {
25
+ expect(formatBashPoints(1)).toBe("1 point");
26
+ });
27
+
28
+ it("uses plural 'points' for 0", () => {
29
+ expect(formatBashPoints(0)).toBe("0 points");
30
+ });
31
+
32
+ it("uses plural for >1", () => {
33
+ expect(formatBashPoints(2)).toBe("2 points");
34
+ });
35
+
36
+ it("inserts en-US thousands separators", () => {
37
+ expect(formatBashPoints(1000)).toBe("1,000 points");
38
+ expect(formatBashPoints(28750)).toBe("28,750 points");
39
+ expect(formatBashPoints(1_000_000)).toBe("1,000,000 points");
40
+ });
41
+
42
+ it("formats negative values (defensive — should never happen but no throw)", () => {
43
+ // -1 !== 1 so it gets the plural suffix
44
+ expect(formatBashPoints(-5)).toBe("-5 points");
45
+ });
46
+ });
47
+
48
+ describe("calculateBashPointsCashback", () => {
49
+ it("awards 5% on a $10.00 (1000¢) purchase → 50 points", () => {
50
+ expect(calculateBashPointsCashback(1000, 0.05)).toBe(50);
51
+ });
52
+
53
+ it("awards 0 points on a $0 purchase", () => {
54
+ expect(calculateBashPointsCashback(0, 0.05)).toBe(0);
55
+ });
56
+
57
+ it("awards 0 points when the rate is 0", () => {
58
+ expect(calculateBashPointsCashback(10_000, 0)).toBe(0);
59
+ });
60
+
61
+ it("FLOORS fractional results (never over-awards)", () => {
62
+ // 333 * 0.05 = 16.65 → floor → 16
63
+ expect(calculateBashPointsCashback(333, 0.05)).toBe(16);
64
+ });
65
+
66
+ it("handles a 100% rebate rate", () => {
67
+ expect(calculateBashPointsCashback(2500, 1)).toBe(2500);
68
+ });
69
+
70
+ it("rate > 1 still floors (host can configure bonuses; trust input)", () => {
71
+ expect(calculateBashPointsCashback(100, 1.5)).toBe(150);
72
+ });
73
+
74
+ it("returns 0 (not negative) for negative amounts after floor", () => {
75
+ // Math.floor(-1) = -1; we don't bound the result to >=0 in source,
76
+ // so this pins the actual contract: negative inputs flow through as-is.
77
+ expect(calculateBashPointsCashback(-100, 0.05)).toBe(-5);
78
+ });
79
+ });
80
+
81
+ describe("DEPRECATED stubs — must remain safe defaults", () => {
82
+ let warnSpy: jest.SpyInstance;
83
+
84
+ beforeEach(() => {
85
+ warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
86
+ });
87
+
88
+ afterEach(() => {
89
+ warnSpy.mockRestore();
90
+ });
91
+
92
+ describe("centsToBashPoints", () => {
93
+ it("returns the input as-is and warns", () => {
94
+ expect(centsToBashPoints(1234)).toBe(1234);
95
+ expect(warnSpy).toHaveBeenCalledWith(
96
+ expect.stringContaining("DEPRECATED: centsToBashPoints")
97
+ );
98
+ });
99
+ });
100
+
101
+ describe("bashPointsToCents", () => {
102
+ it("returns the input as-is and warns", () => {
103
+ expect(bashPointsToCents(500)).toBe(500);
104
+ expect(warnSpy).toHaveBeenCalledWith(
105
+ expect.stringContaining("DEPRECATED: bashPointsToCents")
106
+ );
107
+ });
108
+ });
109
+
110
+ describe("calculateMaxBashPointsForBooking", () => {
111
+ it("returns 0 (no BashPoints can be applied to a service booking) and warns", () => {
112
+ expect(calculateMaxBashPointsForBooking(10_000, 5_000)).toBe(0);
113
+ expect(warnSpy).toHaveBeenCalledWith(
114
+ expect.stringContaining("DEPRECATED: calculateMaxBashPointsForBooking")
115
+ );
116
+ });
117
+ });
118
+
119
+ describe("calculateSplitPayment", () => {
120
+ it("forces the full amount onto Stripe (BashPoints contribution = 0) and warns", () => {
121
+ const result = calculateSplitPayment(10_000, 9_999);
122
+ expect(result).toEqual({
123
+ bashPointsCents: 0,
124
+ stripeCents: 10_000,
125
+ bashPointsCredits: 0,
126
+ });
127
+ expect(warnSpy).toHaveBeenCalledWith(
128
+ expect.stringContaining("DEPRECATED: calculateSplitPayment")
129
+ );
130
+ });
131
+
132
+ it("preserves the original total on stripeCents even when called with 0", () => {
133
+ const result = calculateSplitPayment(0, 100);
134
+ expect(result.stripeCents).toBe(0);
135
+ });
136
+ });
137
+
138
+ describe("validateBashPointsPayment", () => {
139
+ it("ALWAYS returns invalid with a clear redirect-to-Stripe message", () => {
140
+ const result = validateBashPointsPayment(100, 1_000, 500);
141
+ expect(result.valid).toBe(false);
142
+ expect(result.errorMessage).toMatch(/credit card/i);
143
+ expect(result.errorMessage).toMatch(/BashPoints-priced tickets/i);
144
+ });
145
+
146
+ it("rejects even when the user has enough balance (no special-case bypass)", () => {
147
+ // 1_000_000 balance, 100 needed → still rejected because services can't take BashPoints.
148
+ expect(
149
+ validateBashPointsPayment(100, 1_000_000, 100).valid
150
+ ).toBe(false);
151
+ });
152
+ });
153
+ });