@bash-app/bash-common 30.246.0 → 30.253.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 (180) hide show
  1. package/README.md +4 -0
  2. package/dist/__tests__/bashSectionTitleForBashType.test.js +32 -1
  3. package/dist/__tests__/bashSectionTitleForBashType.test.js.map +1 -1
  4. package/dist/__tests__/definitionsHelpers.test.d.ts +2 -0
  5. package/dist/__tests__/definitionsHelpers.test.d.ts.map +1 -0
  6. package/dist/__tests__/definitionsHelpers.test.js +85 -0
  7. package/dist/__tests__/definitionsHelpers.test.js.map +1 -0
  8. package/dist/__tests__/extendedSchemas.test.d.ts +2 -0
  9. package/dist/__tests__/extendedSchemas.test.d.ts.map +1 -0
  10. package/dist/__tests__/extendedSchemas.test.js +40 -0
  11. package/dist/__tests__/extendedSchemas.test.js.map +1 -0
  12. package/dist/__tests__/membershipDefinitions.test.d.ts +2 -0
  13. package/dist/__tests__/membershipDefinitions.test.d.ts.map +1 -0
  14. package/dist/__tests__/membershipDefinitions.test.js +95 -0
  15. package/dist/__tests__/membershipDefinitions.test.js.map +1 -0
  16. package/dist/__tests__/ticketAdmission.test.d.ts +5 -0
  17. package/dist/__tests__/ticketAdmission.test.d.ts.map +1 -0
  18. package/dist/__tests__/ticketAdmission.test.js +42 -0
  19. package/dist/__tests__/ticketAdmission.test.js.map +1 -0
  20. package/dist/__tests__/utmAttribution.test.d.ts +2 -0
  21. package/dist/__tests__/utmAttribution.test.d.ts.map +1 -0
  22. package/dist/__tests__/utmAttribution.test.js +92 -0
  23. package/dist/__tests__/utmAttribution.test.js.map +1 -0
  24. package/dist/definitions.d.ts +18 -5
  25. package/dist/definitions.d.ts.map +1 -1
  26. package/dist/definitions.js +84 -22
  27. package/dist/definitions.js.map +1 -1
  28. package/dist/extendedSchemas.d.ts +41 -1
  29. package/dist/extendedSchemas.d.ts.map +1 -1
  30. package/dist/extendedSchemas.js +27 -0
  31. package/dist/extendedSchemas.js.map +1 -1
  32. package/dist/index.d.ts +1 -0
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +1 -0
  35. package/dist/index.js.map +1 -1
  36. package/dist/sms/smsTemplates.d.ts +27 -0
  37. package/dist/sms/smsTemplates.d.ts.map +1 -0
  38. package/dist/sms/smsTemplates.js +11 -0
  39. package/dist/sms/smsTemplates.js.map +1 -0
  40. package/dist/utils/__tests__/addressUtils.test.d.ts +2 -0
  41. package/dist/utils/__tests__/addressUtils.test.d.ts.map +1 -0
  42. package/dist/utils/__tests__/addressUtils.test.js +138 -0
  43. package/dist/utils/__tests__/addressUtils.test.js.map +1 -0
  44. package/dist/utils/__tests__/apiUtils.test.d.ts +5 -0
  45. package/dist/utils/__tests__/apiUtils.test.d.ts.map +1 -0
  46. package/dist/utils/__tests__/apiUtils.test.js +161 -0
  47. package/dist/utils/__tests__/apiUtils.test.js.map +1 -0
  48. package/dist/utils/__tests__/arrayUtils.test.d.ts +2 -0
  49. package/dist/utils/__tests__/arrayUtils.test.d.ts.map +1 -0
  50. package/dist/utils/__tests__/arrayUtils.test.js +23 -0
  51. package/dist/utils/__tests__/arrayUtils.test.js.map +1 -0
  52. package/dist/utils/__tests__/blogUtils.test.d.ts +2 -0
  53. package/dist/utils/__tests__/blogUtils.test.d.ts.map +1 -0
  54. package/dist/utils/__tests__/blogUtils.test.js +102 -0
  55. package/dist/utils/__tests__/blogUtils.test.js.map +1 -0
  56. package/dist/utils/__tests__/dateTimeUtils.test.d.ts +2 -0
  57. package/dist/utils/__tests__/dateTimeUtils.test.d.ts.map +1 -0
  58. package/dist/utils/__tests__/dateTimeUtils.test.js +180 -0
  59. package/dist/utils/__tests__/dateTimeUtils.test.js.map +1 -0
  60. package/dist/utils/__tests__/discountEngine/discountCalculator.test.d.ts +2 -0
  61. package/dist/utils/__tests__/discountEngine/discountCalculator.test.d.ts.map +1 -0
  62. package/dist/utils/__tests__/discountEngine/discountCalculator.test.js +356 -0
  63. package/dist/utils/__tests__/discountEngine/discountCalculator.test.js.map +1 -0
  64. package/dist/utils/__tests__/generalDateTimeUtils.test.d.ts +2 -0
  65. package/dist/utils/__tests__/generalDateTimeUtils.test.d.ts.map +1 -0
  66. package/dist/utils/__tests__/generalDateTimeUtils.test.js +26 -0
  67. package/dist/utils/__tests__/generalDateTimeUtils.test.js.map +1 -0
  68. package/dist/utils/__tests__/luxonUtils.test.d.ts +2 -0
  69. package/dist/utils/__tests__/luxonUtils.test.d.ts.map +1 -0
  70. package/dist/utils/__tests__/luxonUtils.test.js +139 -0
  71. package/dist/utils/__tests__/luxonUtils.test.js.map +1 -0
  72. package/dist/utils/__tests__/mathUtils.test.d.ts +5 -0
  73. package/dist/utils/__tests__/mathUtils.test.d.ts.map +1 -0
  74. package/dist/utils/__tests__/mathUtils.test.js +26 -0
  75. package/dist/utils/__tests__/mathUtils.test.js.map +1 -0
  76. package/dist/utils/__tests__/objUtils.test.d.ts +5 -0
  77. package/dist/utils/__tests__/objUtils.test.d.ts.map +1 -0
  78. package/dist/utils/__tests__/objUtils.test.js +41 -0
  79. package/dist/utils/__tests__/objUtils.test.js.map +1 -0
  80. package/dist/utils/__tests__/paymentUtils.test.js +48 -1
  81. package/dist/utils/__tests__/paymentUtils.test.js.map +1 -1
  82. package/dist/utils/__tests__/qrCodeUtils.test.js +16 -1
  83. package/dist/utils/__tests__/qrCodeUtils.test.js.map +1 -1
  84. package/dist/utils/__tests__/reviewUtils.test.d.ts +2 -0
  85. package/dist/utils/__tests__/reviewUtils.test.d.ts.map +1 -0
  86. package/dist/utils/__tests__/reviewUtils.test.js +49 -0
  87. package/dist/utils/__tests__/reviewUtils.test.js.map +1 -0
  88. package/dist/utils/__tests__/slugUtils.test.d.ts +2 -0
  89. package/dist/utils/__tests__/slugUtils.test.d.ts.map +1 -0
  90. package/dist/utils/__tests__/slugUtils.test.js +103 -0
  91. package/dist/utils/__tests__/slugUtils.test.js.map +1 -0
  92. package/dist/utils/__tests__/sortUtils.test.d.ts +5 -0
  93. package/dist/utils/__tests__/sortUtils.test.d.ts.map +1 -0
  94. package/dist/utils/__tests__/sortUtils.test.js +28 -0
  95. package/dist/utils/__tests__/sortUtils.test.js.map +1 -0
  96. package/dist/utils/__tests__/stringUtils.test.d.ts +2 -0
  97. package/dist/utils/__tests__/stringUtils.test.d.ts.map +1 -0
  98. package/dist/utils/__tests__/stringUtils.test.js +27 -0
  99. package/dist/utils/__tests__/stringUtils.test.js.map +1 -0
  100. package/dist/utils/__tests__/ticketListUtils.test.d.ts +2 -0
  101. package/dist/utils/__tests__/ticketListUtils.test.d.ts.map +1 -0
  102. package/dist/utils/__tests__/ticketListUtils.test.js +41 -0
  103. package/dist/utils/__tests__/ticketListUtils.test.js.map +1 -0
  104. package/dist/utils/__tests__/typeUtils.test.d.ts +2 -0
  105. package/dist/utils/__tests__/typeUtils.test.d.ts.map +1 -0
  106. package/dist/utils/__tests__/typeUtils.test.js +11 -0
  107. package/dist/utils/__tests__/typeUtils.test.js.map +1 -0
  108. package/dist/utils/__tests__/userSubscriptionUtils.test.d.ts +5 -0
  109. package/dist/utils/__tests__/userSubscriptionUtils.test.d.ts.map +1 -0
  110. package/dist/utils/__tests__/userSubscriptionUtils.test.js +66 -0
  111. package/dist/utils/__tests__/userSubscriptionUtils.test.js.map +1 -0
  112. package/dist/utils/__tests__/userUtils.test.d.ts +2 -0
  113. package/dist/utils/__tests__/userUtils.test.d.ts.map +1 -0
  114. package/dist/utils/__tests__/userUtils.test.js +34 -0
  115. package/dist/utils/__tests__/userUtils.test.js.map +1 -0
  116. package/dist/utils/__tests__/venueTypeFormatter.test.d.ts +2 -0
  117. package/dist/utils/__tests__/venueTypeFormatter.test.d.ts.map +1 -0
  118. package/dist/utils/__tests__/venueTypeFormatter.test.js +25 -0
  119. package/dist/utils/__tests__/venueTypeFormatter.test.js.map +1 -0
  120. package/dist/utils/service/__tests__/regexUtils.test.d.ts +2 -0
  121. package/dist/utils/service/__tests__/regexUtils.test.d.ts.map +1 -0
  122. package/dist/utils/service/__tests__/regexUtils.test.js +51 -0
  123. package/dist/utils/service/__tests__/regexUtils.test.js.map +1 -0
  124. package/dist/utils/service/__tests__/serviceDBUtils.test.d.ts +2 -0
  125. package/dist/utils/service/__tests__/serviceDBUtils.test.d.ts.map +1 -0
  126. package/dist/utils/service/__tests__/serviceDBUtils.test.js +17 -0
  127. package/dist/utils/service/__tests__/serviceDBUtils.test.js.map +1 -0
  128. package/dist/utils/service/__tests__/serviceFeaturedSortUtils.test.d.ts +2 -0
  129. package/dist/utils/service/__tests__/serviceFeaturedSortUtils.test.d.ts.map +1 -0
  130. package/dist/utils/service/__tests__/serviceFeaturedSortUtils.test.js +60 -0
  131. package/dist/utils/service/__tests__/serviceFeaturedSortUtils.test.js.map +1 -0
  132. package/dist/utils/service/__tests__/serviceUtils.test.d.ts +2 -0
  133. package/dist/utils/service/__tests__/serviceUtils.test.d.ts.map +1 -0
  134. package/dist/utils/service/__tests__/serviceUtils.test.js +135 -0
  135. package/dist/utils/service/__tests__/serviceUtils.test.js.map +1 -0
  136. package/dist/utils/service/serviceFeaturedSortUtils.d.ts.map +1 -1
  137. package/dist/utils/service/serviceFeaturedSortUtils.js +5 -1
  138. package/dist/utils/service/serviceFeaturedSortUtils.js.map +1 -1
  139. package/dist/utils/ticketListUtils.d.ts.map +1 -1
  140. package/dist/utils/ticketListUtils.js +11 -7
  141. package/dist/utils/ticketListUtils.js.map +1 -1
  142. package/package.json +1 -1
  143. package/prisma/schema.prisma +47 -0
  144. package/src/__tests__/bashSectionTitleForBashType.test.ts +48 -1
  145. package/src/__tests__/definitionsHelpers.test.ts +136 -0
  146. package/src/__tests__/extendedSchemas.test.ts +50 -0
  147. package/src/__tests__/membershipDefinitions.test.ts +144 -0
  148. package/src/__tests__/ticketAdmission.test.ts +73 -0
  149. package/src/__tests__/utmAttribution.test.ts +118 -0
  150. package/src/definitions.ts +86 -23
  151. package/src/extendedSchemas.ts +35 -1
  152. package/src/index.ts +1 -0
  153. package/src/sms/smsTemplates.ts +20 -0
  154. package/src/utils/__tests__/addressUtils.test.ts +176 -0
  155. package/src/utils/__tests__/apiUtils.test.ts +219 -0
  156. package/src/utils/__tests__/arrayUtils.test.ts +26 -0
  157. package/src/utils/__tests__/blogUtils.test.ts +145 -0
  158. package/src/utils/__tests__/dateTimeUtils.test.ts +233 -0
  159. package/src/utils/__tests__/discountEngine/discountCalculator.test.ts +410 -0
  160. package/src/utils/__tests__/generalDateTimeUtils.test.ts +34 -0
  161. package/src/utils/__tests__/luxonUtils.test.ts +171 -0
  162. package/src/utils/__tests__/mathUtils.test.ts +30 -0
  163. package/src/utils/__tests__/objUtils.test.ts +48 -0
  164. package/src/utils/__tests__/paymentUtils.test.ts +59 -0
  165. package/src/utils/__tests__/qrCodeUtils.test.ts +28 -1
  166. package/src/utils/__tests__/reviewUtils.test.ts +65 -0
  167. package/src/utils/__tests__/slugUtils.test.ts +143 -0
  168. package/src/utils/__tests__/sortUtils.test.ts +33 -0
  169. package/src/utils/__tests__/stringUtils.test.ts +37 -0
  170. package/src/utils/__tests__/ticketListUtils.test.ts +47 -0
  171. package/src/utils/__tests__/typeUtils.test.ts +12 -0
  172. package/src/utils/__tests__/userSubscriptionUtils.test.ts +87 -0
  173. package/src/utils/__tests__/userUtils.test.ts +41 -0
  174. package/src/utils/__tests__/venueTypeFormatter.test.ts +31 -0
  175. package/src/utils/service/__tests__/regexUtils.test.ts +66 -0
  176. package/src/utils/service/__tests__/serviceDBUtils.test.ts +19 -0
  177. package/src/utils/service/__tests__/serviceFeaturedSortUtils.test.ts +67 -0
  178. package/src/utils/service/__tests__/serviceUtils.test.ts +190 -0
  179. package/src/utils/service/serviceFeaturedSortUtils.ts +5 -1
  180. package/src/utils/ticketListUtils.ts +13 -6
@@ -0,0 +1,410 @@
1
+ import {
2
+ OfferDiscountType,
3
+ SpecialOffer,
4
+ SpecialOfferType,
5
+ } from "@prisma/client";
6
+ import { describe, expect, test } from "@jest/globals";
7
+ import {
8
+ calculatePromoCodeDiscount,
9
+ calculateSpecialOfferDiscount,
10
+ calculateBOGODiscount,
11
+ calculateGroupDiscount,
12
+ calculateVolumeDiscount,
13
+ calculateEarlyBirdDiscount,
14
+ calculateBundleDiscount,
15
+ formatDiscountDescription,
16
+ type BashEventPromoCodeWithPromoter,
17
+ } from "../../discountEngine/discountCalculator.js";
18
+
19
+ const NOW = new Date("2026-01-01T00:00:00Z");
20
+
21
+ function basePromoCode(
22
+ overrides: Partial<BashEventPromoCodeWithPromoter> &
23
+ Pick<BashEventPromoCodeWithPromoter, "id" | "code">
24
+ ): BashEventPromoCodeWithPromoter {
25
+ return {
26
+ ticketTierId: "tier-1",
27
+ discountAmountInCents: null,
28
+ discountAmountPercentage: null,
29
+ maxRedemptions: null,
30
+ redeemBy: null,
31
+ promoterId: null,
32
+ stripeCouponId: null,
33
+ meta: null,
34
+ bashPointsRewardPerTicket: null,
35
+ ...overrides,
36
+ };
37
+ }
38
+
39
+ function baseSpecialOffer(
40
+ overrides: Partial<SpecialOffer> & Pick<SpecialOffer, "id" | "title" | "offerType">
41
+ ): SpecialOffer {
42
+ return {
43
+ createdAt: NOW,
44
+ updatedAt: NOW,
45
+ ticketTierId: "tier-1",
46
+ description: null,
47
+ discountType: OfferDiscountType.PERCENTAGE,
48
+ discountValue: 10,
49
+ isActive: true,
50
+ displayBadge: true,
51
+ badgeText: null,
52
+ buyQuantity: null,
53
+ getQuantity: null,
54
+ getDiscountPercent: null,
55
+ minGroupSize: null,
56
+ maxGroupSize: null,
57
+ tieredDiscounts: null,
58
+ startDate: null,
59
+ endDate: null,
60
+ availableUntil: null,
61
+ maxRedemptions: null,
62
+ maxPerUser: null,
63
+ limitedQuantity: null,
64
+ currentRedemptions: 0,
65
+ viewCount: 0,
66
+ clickCount: 0,
67
+ lastRedemptionAt: null,
68
+ priceIncreasesAt: null,
69
+ newPriceAfterIncrease: null,
70
+ countdownEnabled: false,
71
+ bundledItems: null,
72
+ bundleSavings: null,
73
+ requiresAttribution: false,
74
+ referralDiscountForBuyer: null,
75
+ referralDiscountForFriend: null,
76
+ commissionRate: null,
77
+ requiresEventAttendance: null,
78
+ hostSpecificLoyalty: false,
79
+ canStackWithPromoCodes: true,
80
+ canStackWithOtherOffers: false,
81
+ priority: 0,
82
+ urgencyMessage: null,
83
+ showSocialProof: false,
84
+ highlightColor: null,
85
+ sortOrder: 0,
86
+ minPriceFloor: null,
87
+ maxDiscountPercent: null,
88
+ ...overrides,
89
+ };
90
+ }
91
+
92
+ describe("calculatePromoCodeDiscount", () => {
93
+ test("applies percentage discount", () => {
94
+ const promo = basePromoCode({
95
+ id: "promo-1",
96
+ code: "SAVE20",
97
+ discountAmountPercentage: 20,
98
+ });
99
+ const result = calculatePromoCodeDiscount(promo, 5000, 2);
100
+ expect(result).toMatchObject({
101
+ sourceType: "PROMO_CODE",
102
+ discountType: "PERCENTAGE",
103
+ discountValue: 20,
104
+ originalPrice: 10000,
105
+ amountDiscounted: 2000,
106
+ finalPrice: 8000,
107
+ });
108
+ });
109
+
110
+ test("applies fixed amount discount capped at order total", () => {
111
+ const promo = basePromoCode({
112
+ id: "promo-2",
113
+ code: "FLAT10",
114
+ discountAmountInCents: 1500,
115
+ });
116
+ const result = calculatePromoCodeDiscount(promo, 1000, 1);
117
+ expect(result.amountDiscounted).toBe(1000);
118
+ expect(result.finalPrice).toBe(0);
119
+ });
120
+
121
+ test("includes attribution when promoter has userId", () => {
122
+ const promo = basePromoCode({
123
+ id: "promo-3",
124
+ code: "PROMO",
125
+ discountAmountPercentage: 10,
126
+ promoterId: "promoter-row-1",
127
+ promoter: { userId: "user-abc" },
128
+ });
129
+ const result = calculatePromoCodeDiscount(promo, 5000, 1);
130
+ expect(result.attribution).toEqual({
131
+ userId: "user-abc",
132
+ commissionRate: 5.0,
133
+ commissionAmount: 25,
134
+ });
135
+ });
136
+
137
+ test("omits attribution when promoter userId is missing", () => {
138
+ const promo = basePromoCode({
139
+ id: "promo-4",
140
+ code: "PROMO",
141
+ discountAmountPercentage: 10,
142
+ promoterId: "promoter-row-1",
143
+ promoter: { userId: null },
144
+ });
145
+ expect(calculatePromoCodeDiscount(promo, 5000, 1).attribution).toBeUndefined();
146
+ });
147
+ });
148
+
149
+ describe("calculateBOGODiscount", () => {
150
+ test("buy 2 get 1 free (100% off)", () => {
151
+ const offer = baseSpecialOffer({
152
+ id: "bogo-1",
153
+ title: "BOGO",
154
+ offerType: SpecialOfferType.BOGO,
155
+ buyQuantity: 2,
156
+ getQuantity: 1,
157
+ getDiscountPercent: 100,
158
+ });
159
+ // 3 tickets → 1 set → 1 free ticket at $50
160
+ expect(calculateBOGODiscount(offer, 3, 5000)).toBe(5000);
161
+ expect(calculateBOGODiscount(offer, 2, 5000)).toBe(0);
162
+ });
163
+
164
+ test("partial discount on free tickets", () => {
165
+ const offer = baseSpecialOffer({
166
+ id: "bogo-2",
167
+ title: "Half off extra",
168
+ offerType: SpecialOfferType.BOGO,
169
+ buyQuantity: 1,
170
+ getQuantity: 1,
171
+ getDiscountPercent: 50,
172
+ });
173
+ expect(calculateBOGODiscount(offer, 2, 4000)).toBe(2000);
174
+ });
175
+ });
176
+
177
+ describe("calculateGroupDiscount", () => {
178
+ test("returns 0 when below min group size", () => {
179
+ const offer = baseSpecialOffer({
180
+ id: "group-1",
181
+ title: "Group",
182
+ offerType: SpecialOfferType.GROUP_DISCOUNT,
183
+ minGroupSize: 5,
184
+ discountValue: 20,
185
+ });
186
+ expect(calculateGroupDiscount(offer, 3, 5000)).toBe(0);
187
+ });
188
+
189
+ test("returns 0 when above max group size", () => {
190
+ const offer = baseSpecialOffer({
191
+ id: "group-2",
192
+ title: "Group",
193
+ offerType: SpecialOfferType.GROUP_DISCOUNT,
194
+ maxGroupSize: 10,
195
+ discountValue: 20,
196
+ });
197
+ expect(calculateGroupDiscount(offer, 11, 5000)).toBe(0);
198
+ });
199
+
200
+ test("uses highest applicable tiered discount", () => {
201
+ const offer = baseSpecialOffer({
202
+ id: "group-3",
203
+ title: "Tiered",
204
+ offerType: SpecialOfferType.GROUP_DISCOUNT,
205
+ tieredDiscounts: [
206
+ { qty: 5, discount: 10 },
207
+ { qty: 10, discount: 20 },
208
+ ],
209
+ });
210
+ expect(calculateGroupDiscount(offer, 10, 5000)).toBe(10000);
211
+ expect(calculateGroupDiscount(offer, 5, 5000)).toBe(2500);
212
+ });
213
+
214
+ test("falls back to percentage discount", () => {
215
+ const offer = baseSpecialOffer({
216
+ id: "group-4",
217
+ title: "Simple group",
218
+ offerType: SpecialOfferType.GROUP_DISCOUNT,
219
+ discountType: OfferDiscountType.PERCENTAGE,
220
+ discountValue: 15,
221
+ });
222
+ expect(calculateGroupDiscount(offer, 4, 5000)).toBe(3000);
223
+ });
224
+
225
+ test("falls back to fixed amount per ticket", () => {
226
+ const offer = baseSpecialOffer({
227
+ id: "group-5",
228
+ title: "Fixed group",
229
+ offerType: SpecialOfferType.GROUP_DISCOUNT,
230
+ discountType: OfferDiscountType.FIXED_AMOUNT,
231
+ discountValue: 500,
232
+ });
233
+ expect(calculateGroupDiscount(offer, 3, 5000)).toBe(1500);
234
+ });
235
+ });
236
+
237
+ describe("calculateVolumeDiscount", () => {
238
+ test("delegates to group discount logic", () => {
239
+ const offer = baseSpecialOffer({
240
+ id: "vol-1",
241
+ title: "Volume",
242
+ offerType: SpecialOfferType.VOLUME_DISCOUNT,
243
+ discountType: OfferDiscountType.PERCENTAGE,
244
+ discountValue: 10,
245
+ });
246
+ expect(calculateVolumeDiscount(offer, 2, 5000)).toBe(
247
+ calculateGroupDiscount(offer, 2, 5000)
248
+ );
249
+ });
250
+ });
251
+
252
+ describe("calculateEarlyBirdDiscount", () => {
253
+ test("returns 0 after availableUntil", () => {
254
+ const offer = baseSpecialOffer({
255
+ id: "eb-1",
256
+ title: "Early bird",
257
+ offerType: SpecialOfferType.EARLY_BIRD,
258
+ availableUntil: new Date("2026-01-01T00:00:00Z"),
259
+ discountValue: 20,
260
+ });
261
+ expect(
262
+ calculateEarlyBirdDiscount(offer, 5000, new Date("2026-02-01T00:00:00Z"))
263
+ ).toBe(0);
264
+ });
265
+
266
+ test("applies percentage before cutoff", () => {
267
+ const offer = baseSpecialOffer({
268
+ id: "eb-2",
269
+ title: "Early bird",
270
+ offerType: SpecialOfferType.EARLY_BIRD,
271
+ availableUntil: new Date("2026-06-01T00:00:00Z"),
272
+ discountType: OfferDiscountType.PERCENTAGE,
273
+ discountValue: 20,
274
+ });
275
+ expect(
276
+ calculateEarlyBirdDiscount(offer, 5000, new Date("2026-03-01T00:00:00Z"))
277
+ ).toBe(1000);
278
+ });
279
+
280
+ test("applies fixed amount capped at ticket price", () => {
281
+ const offer = baseSpecialOffer({
282
+ id: "eb-3",
283
+ title: "Early bird flat",
284
+ offerType: SpecialOfferType.EARLY_BIRD,
285
+ discountType: OfferDiscountType.FIXED_AMOUNT,
286
+ discountValue: 8000,
287
+ });
288
+ expect(
289
+ calculateEarlyBirdDiscount(offer, 5000, new Date("2026-03-01T00:00:00Z"))
290
+ ).toBe(5000);
291
+ });
292
+ });
293
+
294
+ describe("calculateBundleDiscount", () => {
295
+ test("returns bundleSavings from offer", () => {
296
+ const offer = baseSpecialOffer({
297
+ id: "bundle-1",
298
+ title: "Bundle",
299
+ offerType: SpecialOfferType.BUNDLE,
300
+ bundleSavings: 2500,
301
+ });
302
+ expect(
303
+ calculateBundleDiscount(offer, [{ type: "ticket", price: 5000 }])
304
+ ).toBe(2500);
305
+ });
306
+
307
+ test("returns 0 when bundleSavings is null", () => {
308
+ const offer = baseSpecialOffer({
309
+ id: "bundle-2",
310
+ title: "Bundle",
311
+ offerType: SpecialOfferType.BUNDLE,
312
+ bundleSavings: null,
313
+ });
314
+ expect(calculateBundleDiscount(offer, [])).toBe(0);
315
+ });
316
+ });
317
+
318
+ describe("calculateSpecialOfferDiscount", () => {
319
+ test("EARLY_BIRD percentage via switch", () => {
320
+ const offer = baseSpecialOffer({
321
+ id: "so-1",
322
+ title: "Early Bird",
323
+ offerType: SpecialOfferType.EARLY_BIRD,
324
+ discountType: OfferDiscountType.PERCENTAGE,
325
+ discountValue: 25,
326
+ });
327
+ const result = calculateSpecialOfferDiscount(offer, 4000, 2);
328
+ expect(result).toMatchObject({
329
+ sourceType: "SPECIAL_OFFER",
330
+ discountType: "PERCENTAGE",
331
+ amountDiscounted: 2000,
332
+ originalPrice: 8000,
333
+ finalPrice: 6000,
334
+ });
335
+ });
336
+
337
+ test("BUNDLE uses pre-calculated savings", () => {
338
+ const offer = baseSpecialOffer({
339
+ id: "so-2",
340
+ title: "Bundle Deal",
341
+ offerType: SpecialOfferType.BUNDLE,
342
+ bundleSavings: 1000,
343
+ });
344
+ const result = calculateSpecialOfferDiscount(offer, 5000, 1);
345
+ expect(result.discountType).toBe("FIXED_AMOUNT");
346
+ expect(result.amountDiscounted).toBe(1000);
347
+ });
348
+
349
+ test("REFERRAL uses buyer discount", () => {
350
+ const offer = baseSpecialOffer({
351
+ id: "so-3",
352
+ title: "Referral",
353
+ offerType: SpecialOfferType.REFERRAL,
354
+ referralDiscountForBuyer: 500,
355
+ requiresAttribution: true,
356
+ commissionRate: 10,
357
+ });
358
+ const result = calculateSpecialOfferDiscount(offer, 5000, 1, "referrer-user");
359
+ expect(result.amountDiscounted).toBe(500);
360
+ expect(result.attribution).toEqual({
361
+ userId: "referrer-user",
362
+ commissionRate: 10,
363
+ commissionAmount: 50,
364
+ });
365
+ });
366
+
367
+ test("BOGO sets FREE_ITEMS discount type", () => {
368
+ const offer = baseSpecialOffer({
369
+ id: "so-4",
370
+ title: "BOGO",
371
+ offerType: SpecialOfferType.BOGO,
372
+ buyQuantity: 1,
373
+ getQuantity: 1,
374
+ getDiscountPercent: 100,
375
+ });
376
+ const result = calculateSpecialOfferDiscount(offer, 5000, 2);
377
+ expect(result.discountType).toBe("FREE_ITEMS");
378
+ expect(result.amountDiscounted).toBe(5000);
379
+ });
380
+ });
381
+
382
+ describe("formatDiscountDescription", () => {
383
+ test("formats percentage discount", () => {
384
+ const text = formatDiscountDescription({
385
+ sourceType: "PROMO_CODE",
386
+ sourceId: "1",
387
+ sourceName: "SAVE20",
388
+ discountType: "PERCENTAGE",
389
+ discountValue: 20,
390
+ amountDiscounted: 2000,
391
+ originalPrice: 10000,
392
+ finalPrice: 8000,
393
+ });
394
+ expect(text).toBe("SAVE20: -20% ($20.00)");
395
+ });
396
+
397
+ test("formats fixed amount discount", () => {
398
+ const text = formatDiscountDescription({
399
+ sourceType: "SPECIAL_OFFER",
400
+ sourceId: "2",
401
+ sourceName: "Flat Off",
402
+ discountType: "FIXED_AMOUNT",
403
+ discountValue: 500,
404
+ amountDiscounted: 500,
405
+ originalPrice: 5000,
406
+ finalPrice: 4500,
407
+ });
408
+ expect(text).toBe("Flat Off: -$5.00");
409
+ });
410
+ });
@@ -0,0 +1,34 @@
1
+ import { DayOfWeek } from "@prisma/client";
2
+ import { describe, expect, test } from "@jest/globals";
3
+ import {
4
+ dayOfWeekIdxToDay,
5
+ dayOfWeekIdxToDayOfWeek,
6
+ dayOfWeekToIdx,
7
+ dayOfWeekToString,
8
+ } from "../generalDateTimeUtils.js";
9
+
10
+ describe("generalDateTimeUtils", () => {
11
+ test("dayOfWeekToString maps each weekday to a short label", () => {
12
+ expect(dayOfWeekToString[DayOfWeek.Monday]).toBe("M");
13
+ expect(dayOfWeekToString[DayOfWeek.Thursday]).toBe("TR");
14
+ expect(dayOfWeekToString[DayOfWeek.Saturday]).toBe("S");
15
+ });
16
+
17
+ test("dayOfWeekToIdx assigns Monday=1 through Sunday=7", () => {
18
+ expect(dayOfWeekToIdx[DayOfWeek.Monday]).toBe(1);
19
+ expect(dayOfWeekToIdx[DayOfWeek.Sunday]).toBe(7);
20
+ });
21
+
22
+ test("dayOfWeekIdxToDayOfWeek round-trips with dayOfWeekToIdx", () => {
23
+ for (const day of Object.values(DayOfWeek)) {
24
+ const idx = dayOfWeekToIdx[day];
25
+ expect(dayOfWeekIdxToDayOfWeek[idx]).toBe(day);
26
+ }
27
+ });
28
+
29
+ test("dayOfWeekIdxToDay rotates Sunday to index 1", () => {
30
+ expect(dayOfWeekIdxToDay[7]).toBe(1);
31
+ expect(dayOfWeekIdxToDay[1]).toBe(2);
32
+ expect(dayOfWeekIdxToDay[6]).toBe(7);
33
+ });
34
+ });
@@ -0,0 +1,171 @@
1
+ import { DayOfWeek } from "@prisma/client";
2
+ import { DateTime } from "luxon";
3
+ import { describe, expect, test } from "@jest/globals";
4
+ import {
5
+ dateRangeDurationMatch,
6
+ dateTimeDiffHours,
7
+ dateTimeFormat,
8
+ dateTimeFormatDateRange,
9
+ dateTimeFromDate,
10
+ dateTimeFromDayTime,
11
+ dateTimeRangeCrossesMidnight,
12
+ dateTimeToTimeComponents,
13
+ dateTimeWithinRange,
14
+ dayOfWeekFromIdx,
15
+ dayOfWeekGetIdx,
16
+ filterConflictingIntervals,
17
+ luxonDateRangeSort,
18
+ mergeCrossDayHours,
19
+ utcOffsetMinutesToTimezone,
20
+ } from "../luxonUtils.js";
21
+
22
+ describe("luxonUtils", () => {
23
+ describe("day of week helpers", () => {
24
+ test("dayOfWeekGetIdx accepts enum and numeric index", () => {
25
+ expect(dayOfWeekGetIdx(DayOfWeek.Wednesday)).toBe(3);
26
+ expect(dayOfWeekGetIdx(5)).toBe(5);
27
+ });
28
+
29
+ test("dayOfWeekFromIdx accepts index and enum passthrough", () => {
30
+ expect(dayOfWeekFromIdx(4)).toBe(DayOfWeek.Thursday);
31
+ expect(dayOfWeekFromIdx(DayOfWeek.Friday)).toBe(DayOfWeek.Friday);
32
+ });
33
+
34
+ test("dateTimeFromDayTime builds weekday time in zone", () => {
35
+ const dt = dateTimeFromDayTime(DayOfWeek.Monday, 14, 30, "UTC");
36
+ expect(dt.weekday).toBe(1);
37
+ expect(dt.hour).toBe(14);
38
+ expect(dt.minute).toBe(30);
39
+ });
40
+ });
41
+
42
+ describe("dateTimeFromDate and formatting", () => {
43
+ test("dateTimeFromDate parses ISO string", () => {
44
+ const dt = dateTimeFromDate("2024-03-15T18:00:00.000Z", "UTC");
45
+ expect(dt.year).toBe(2024);
46
+ expect(dt.month).toBe(3);
47
+ expect(dt.day).toBe(15);
48
+ });
49
+
50
+ test("dateTimeToTimeComponents extracts hour and minute", () => {
51
+ const dt = DateTime.fromObject({ hour: 9, minute: 15 }, { zone: "UTC" });
52
+ expect(dateTimeToTimeComponents(dt)).toEqual({ hour: 9, minute: 15 });
53
+ });
54
+
55
+ test("dateTimeFormat returns empty string for nullish", () => {
56
+ expect(dateTimeFormat(null)).toBe("");
57
+ expect(dateTimeFormat(undefined)).toBe("");
58
+ });
59
+ });
60
+
61
+ describe("range helpers", () => {
62
+ const start = DateTime.fromISO("2024-01-01T10:00:00", { zone: "UTC" });
63
+ const end = DateTime.fromISO("2024-01-01T18:00:00", { zone: "UTC" });
64
+ const range = { start, end };
65
+
66
+ test("dateTimeWithinRange detects containment", () => {
67
+ const inside = DateTime.fromISO("2024-01-01T12:00:00", { zone: "UTC" });
68
+ const outside = DateTime.fromISO("2024-01-01T19:00:00", { zone: "UTC" });
69
+ expect(dateTimeWithinRange(inside, range)).toBe(true);
70
+ expect(dateTimeWithinRange(outside, range)).toBe(false);
71
+ });
72
+
73
+ test("dateRangeDurationMatch compares durations within tolerance", () => {
74
+ const lhs = {
75
+ start: DateTime.fromISO("2024-01-01T10:00:00", { zone: "UTC" }),
76
+ end: DateTime.fromISO("2024-01-01T12:00:00", { zone: "UTC" }),
77
+ };
78
+ const rhs = {
79
+ start: DateTime.fromISO("2024-02-01T10:00:00", { zone: "UTC" }),
80
+ end: DateTime.fromISO("2024-02-01T12:00:30", { zone: "UTC" }),
81
+ };
82
+ expect(dateRangeDurationMatch(lhs, rhs, 60)).toBe(true);
83
+ });
84
+
85
+ test("dateTimeFormatDateRange uses time-only end on same day", () => {
86
+ const sameDayStart = DateTime.fromISO("2024-06-01T18:00:00", { zone: "UTC" });
87
+ const sameDayEnd = DateTime.fromISO("2024-06-01T22:00:00", { zone: "UTC" });
88
+ const formatted = dateTimeFormatDateRange(sameDayStart, sameDayEnd);
89
+ expect(formatted).toContain(" to ");
90
+ expect(formatted.split(" to ")[1]).toMatch(/\d/);
91
+ });
92
+
93
+ test("dateTimeRangeCrossesMidnight detects overnight ranges", () => {
94
+ const overnight = {
95
+ start: DateTime.fromObject({ hour: 22, minute: 0 }, { zone: "UTC" }),
96
+ end: DateTime.fromObject({ hour: 2, minute: 0 }, { zone: "UTC" }),
97
+ };
98
+ const sameDay = {
99
+ start: DateTime.fromObject({ hour: 9, minute: 0 }, { zone: "UTC" }),
100
+ end: DateTime.fromObject({ hour: 17, minute: 0 }, { zone: "UTC" }),
101
+ };
102
+ expect(dateTimeRangeCrossesMidnight(overnight)).toBe(true);
103
+ expect(dateTimeRangeCrossesMidnight(sameDay)).toBe(false);
104
+ });
105
+ });
106
+
107
+ describe("mergeCrossDayHours", () => {
108
+ test("merges ranges separated by one minute or less", () => {
109
+ const base = DateTime.fromObject({ hour: 9 }, { zone: "UTC" });
110
+ const merged = mergeCrossDayHours([
111
+ { start: base, end: base.plus({ hours: 2 }) },
112
+ { start: base.plus({ hours: 2, minutes: 1 }), end: base.plus({ hours: 4 }) },
113
+ ]);
114
+ expect(merged).toHaveLength(1);
115
+ expect(merged[0].end.hour).toBe(13);
116
+ });
117
+
118
+ test("keeps separate ranges when gap exceeds one minute", () => {
119
+ const base = DateTime.fromObject({ hour: 9 }, { zone: "UTC" });
120
+ const merged = mergeCrossDayHours([
121
+ { start: base, end: base.plus({ hours: 1 }) },
122
+ { start: base.plus({ hours: 2 }), end: base.plus({ hours: 3 }) },
123
+ ]);
124
+ expect(merged).toHaveLength(2);
125
+ });
126
+ });
127
+
128
+ describe("filterConflictingIntervals", () => {
129
+ test("subtracts blocked hours from available hours", () => {
130
+ const day = DateTime.fromObject({ hour: 8 }, { zone: "UTC" });
131
+ const available = [{ start: day, end: day.plus({ hours: 8 }) }];
132
+ const blocked = [
133
+ {
134
+ start: day.plus({ hours: 2 }),
135
+ end: day.plus({ hours: 3 }),
136
+ },
137
+ ];
138
+ const remaining = filterConflictingIntervals(available, blocked);
139
+ expect(remaining.length).toBeGreaterThan(0);
140
+ expect(remaining.every((r) => r.end > r.start)).toBe(true);
141
+ });
142
+ });
143
+
144
+ describe("utcOffsetMinutesToTimezone", () => {
145
+ test("formats positive and negative offsets", () => {
146
+ expect(utcOffsetMinutesToTimezone(300)).toBe("UTC+05:00");
147
+ expect(utcOffsetMinutesToTimezone(-480)).toBe("UTC-08:00");
148
+ });
149
+ });
150
+
151
+ describe("dateTimeDiffHours", () => {
152
+ test("returns 0 when start or end missing", () => {
153
+ expect(dateTimeDiffHours(null, DateTime.now())).toBe(0);
154
+ expect(dateTimeDiffHours(DateTime.now(), null)).toBe(0);
155
+ });
156
+ });
157
+
158
+ describe("luxonDateRangeSort", () => {
159
+ test("sorts ranges by start time ascending", () => {
160
+ const later = {
161
+ start: DateTime.fromISO("2024-01-02T10:00:00", { zone: "UTC" }),
162
+ end: DateTime.fromISO("2024-01-02T12:00:00", { zone: "UTC" }),
163
+ };
164
+ const earlier = {
165
+ start: DateTime.fromISO("2024-01-01T10:00:00", { zone: "UTC" }),
166
+ end: DateTime.fromISO("2024-01-01T12:00:00", { zone: "UTC" }),
167
+ };
168
+ expect(luxonDateRangeSort([later, earlier])).toEqual([earlier, later]);
169
+ });
170
+ });
171
+ });
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Tests for numeric rounding helpers.
3
+ */
4
+
5
+ import { describe, expect, test } from "@jest/globals";
6
+ import { roundToNearestDivisor } from "../mathUtils.js";
7
+
8
+ describe("mathUtils", () => {
9
+ describe("roundToNearestDivisor", () => {
10
+ test("rounds to nearest cent (divisor 100)", () => {
11
+ expect(roundToNearestDivisor(12.345, 100)).toBe(12.35);
12
+ expect(roundToNearestDivisor(12.344, 100)).toBe(12.34);
13
+ });
14
+
15
+ test("rounds to nearest tenth (divisor 10)", () => {
16
+ expect(roundToNearestDivisor(3.14, 10)).toBe(3.1);
17
+ expect(roundToNearestDivisor(3.16, 10)).toBe(3.2);
18
+ });
19
+
20
+ test("returns whole numbers unchanged when divisor is 1", () => {
21
+ expect(roundToNearestDivisor(42, 1)).toBe(42);
22
+ expect(roundToNearestDivisor(42.6, 1)).toBe(43);
23
+ });
24
+
25
+ test("handles zero and negative values", () => {
26
+ expect(roundToNearestDivisor(0, 100)).toBe(0);
27
+ expect(roundToNearestDivisor(-1.235, 100)).toBe(-1.24);
28
+ });
29
+ });
30
+ });
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Tests for object type guards and deep merge.
3
+ */
4
+
5
+ import { describe, expect, test } from "@jest/globals";
6
+ import { isObject, deepMerge } from "../objUtils.js";
7
+
8
+ describe("objUtils", () => {
9
+ describe("isObject", () => {
10
+ test("returns true for plain objects", () => {
11
+ expect(isObject({ a: 1 })).toBe(true);
12
+ expect(isObject(Object.create(null))).toBe(true);
13
+ });
14
+
15
+ test("returns false for null, arrays, dates, and primitives", () => {
16
+ expect(isObject(null)).toBe(false);
17
+ expect(isObject([1, 2])).toBe(false);
18
+ expect(isObject(new Date())).toBe(false);
19
+ expect(isObject("string")).toBe(false);
20
+ expect(isObject(42)).toBe(false);
21
+ });
22
+ });
23
+
24
+ describe("deepMerge", () => {
25
+ test("overwrites scalar values from source", () => {
26
+ expect(deepMerge({ a: 1, b: 2 }, { b: 99 })).toEqual({ a: 1, b: 99 });
27
+ });
28
+
29
+ test("recursively merges nested objects", () => {
30
+ expect(deepMerge({ nested: { x: 1, y: 2 } }, { nested: { y: 3, z: 4 } })).toEqual({
31
+ nested: { x: 1, y: 3, z: 4 },
32
+ });
33
+ });
34
+
35
+ test("concatenates arrays when mergeArrays is true", () => {
36
+ expect(deepMerge({ tags: ["a"] }, { tags: ["b"] }, true)).toEqual({ tags: ["a", "b"] });
37
+ });
38
+
39
+ test("replaces arrays by default", () => {
40
+ expect(deepMerge({ tags: ["a"] }, { tags: ["b"] })).toEqual({ tags: ["b"] });
41
+ });
42
+
43
+ test("handles null/undefined target or source", () => {
44
+ expect(deepMerge(null, { a: 1 })).toEqual({ a: 1 });
45
+ expect(deepMerge({ a: 1 }, null)).toEqual({ a: 1 });
46
+ });
47
+ });
48
+ });