@bash-app/bash-common 30.247.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 (172) hide show
  1. package/README.md +2 -0
  2. package/dist/__tests__/definitionsHelpers.test.d.ts +2 -0
  3. package/dist/__tests__/definitionsHelpers.test.d.ts.map +1 -0
  4. package/dist/__tests__/definitionsHelpers.test.js +85 -0
  5. package/dist/__tests__/definitionsHelpers.test.js.map +1 -0
  6. package/dist/__tests__/extendedSchemas.test.d.ts +2 -0
  7. package/dist/__tests__/extendedSchemas.test.d.ts.map +1 -0
  8. package/dist/__tests__/extendedSchemas.test.js +40 -0
  9. package/dist/__tests__/extendedSchemas.test.js.map +1 -0
  10. package/dist/__tests__/membershipDefinitions.test.d.ts +2 -0
  11. package/dist/__tests__/membershipDefinitions.test.d.ts.map +1 -0
  12. package/dist/__tests__/membershipDefinitions.test.js +95 -0
  13. package/dist/__tests__/membershipDefinitions.test.js.map +1 -0
  14. package/dist/__tests__/ticketAdmission.test.d.ts +5 -0
  15. package/dist/__tests__/ticketAdmission.test.d.ts.map +1 -0
  16. package/dist/__tests__/ticketAdmission.test.js +42 -0
  17. package/dist/__tests__/ticketAdmission.test.js.map +1 -0
  18. package/dist/__tests__/utmAttribution.test.d.ts +2 -0
  19. package/dist/__tests__/utmAttribution.test.d.ts.map +1 -0
  20. package/dist/__tests__/utmAttribution.test.js +92 -0
  21. package/dist/__tests__/utmAttribution.test.js.map +1 -0
  22. package/dist/extendedSchemas.d.ts +2 -0
  23. package/dist/extendedSchemas.d.ts.map +1 -1
  24. package/dist/extendedSchemas.js +2 -0
  25. package/dist/extendedSchemas.js.map +1 -1
  26. package/dist/index.d.ts +1 -0
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +1 -0
  29. package/dist/index.js.map +1 -1
  30. package/dist/sms/smsTemplates.d.ts +27 -0
  31. package/dist/sms/smsTemplates.d.ts.map +1 -0
  32. package/dist/sms/smsTemplates.js +11 -0
  33. package/dist/sms/smsTemplates.js.map +1 -0
  34. package/dist/utils/__tests__/addressUtils.test.d.ts +2 -0
  35. package/dist/utils/__tests__/addressUtils.test.d.ts.map +1 -0
  36. package/dist/utils/__tests__/addressUtils.test.js +138 -0
  37. package/dist/utils/__tests__/addressUtils.test.js.map +1 -0
  38. package/dist/utils/__tests__/apiUtils.test.d.ts +5 -0
  39. package/dist/utils/__tests__/apiUtils.test.d.ts.map +1 -0
  40. package/dist/utils/__tests__/apiUtils.test.js +161 -0
  41. package/dist/utils/__tests__/apiUtils.test.js.map +1 -0
  42. package/dist/utils/__tests__/arrayUtils.test.d.ts +2 -0
  43. package/dist/utils/__tests__/arrayUtils.test.d.ts.map +1 -0
  44. package/dist/utils/__tests__/arrayUtils.test.js +23 -0
  45. package/dist/utils/__tests__/arrayUtils.test.js.map +1 -0
  46. package/dist/utils/__tests__/blogUtils.test.d.ts +2 -0
  47. package/dist/utils/__tests__/blogUtils.test.d.ts.map +1 -0
  48. package/dist/utils/__tests__/blogUtils.test.js +102 -0
  49. package/dist/utils/__tests__/blogUtils.test.js.map +1 -0
  50. package/dist/utils/__tests__/dateTimeUtils.test.d.ts +2 -0
  51. package/dist/utils/__tests__/dateTimeUtils.test.d.ts.map +1 -0
  52. package/dist/utils/__tests__/dateTimeUtils.test.js +180 -0
  53. package/dist/utils/__tests__/dateTimeUtils.test.js.map +1 -0
  54. package/dist/utils/__tests__/discountEngine/discountCalculator.test.d.ts +2 -0
  55. package/dist/utils/__tests__/discountEngine/discountCalculator.test.d.ts.map +1 -0
  56. package/dist/utils/__tests__/discountEngine/discountCalculator.test.js +356 -0
  57. package/dist/utils/__tests__/discountEngine/discountCalculator.test.js.map +1 -0
  58. package/dist/utils/__tests__/generalDateTimeUtils.test.d.ts +2 -0
  59. package/dist/utils/__tests__/generalDateTimeUtils.test.d.ts.map +1 -0
  60. package/dist/utils/__tests__/generalDateTimeUtils.test.js +26 -0
  61. package/dist/utils/__tests__/generalDateTimeUtils.test.js.map +1 -0
  62. package/dist/utils/__tests__/luxonUtils.test.d.ts +2 -0
  63. package/dist/utils/__tests__/luxonUtils.test.d.ts.map +1 -0
  64. package/dist/utils/__tests__/luxonUtils.test.js +139 -0
  65. package/dist/utils/__tests__/luxonUtils.test.js.map +1 -0
  66. package/dist/utils/__tests__/mathUtils.test.d.ts +5 -0
  67. package/dist/utils/__tests__/mathUtils.test.d.ts.map +1 -0
  68. package/dist/utils/__tests__/mathUtils.test.js +26 -0
  69. package/dist/utils/__tests__/mathUtils.test.js.map +1 -0
  70. package/dist/utils/__tests__/objUtils.test.d.ts +5 -0
  71. package/dist/utils/__tests__/objUtils.test.d.ts.map +1 -0
  72. package/dist/utils/__tests__/objUtils.test.js +41 -0
  73. package/dist/utils/__tests__/objUtils.test.js.map +1 -0
  74. package/dist/utils/__tests__/paymentUtils.test.js +48 -1
  75. package/dist/utils/__tests__/paymentUtils.test.js.map +1 -1
  76. package/dist/utils/__tests__/qrCodeUtils.test.js +16 -1
  77. package/dist/utils/__tests__/qrCodeUtils.test.js.map +1 -1
  78. package/dist/utils/__tests__/reviewUtils.test.d.ts +2 -0
  79. package/dist/utils/__tests__/reviewUtils.test.d.ts.map +1 -0
  80. package/dist/utils/__tests__/reviewUtils.test.js +49 -0
  81. package/dist/utils/__tests__/reviewUtils.test.js.map +1 -0
  82. package/dist/utils/__tests__/slugUtils.test.d.ts +2 -0
  83. package/dist/utils/__tests__/slugUtils.test.d.ts.map +1 -0
  84. package/dist/utils/__tests__/slugUtils.test.js +103 -0
  85. package/dist/utils/__tests__/slugUtils.test.js.map +1 -0
  86. package/dist/utils/__tests__/sortUtils.test.d.ts +5 -0
  87. package/dist/utils/__tests__/sortUtils.test.d.ts.map +1 -0
  88. package/dist/utils/__tests__/sortUtils.test.js +28 -0
  89. package/dist/utils/__tests__/sortUtils.test.js.map +1 -0
  90. package/dist/utils/__tests__/stringUtils.test.d.ts +2 -0
  91. package/dist/utils/__tests__/stringUtils.test.d.ts.map +1 -0
  92. package/dist/utils/__tests__/stringUtils.test.js +27 -0
  93. package/dist/utils/__tests__/stringUtils.test.js.map +1 -0
  94. package/dist/utils/__tests__/ticketListUtils.test.d.ts +2 -0
  95. package/dist/utils/__tests__/ticketListUtils.test.d.ts.map +1 -0
  96. package/dist/utils/__tests__/ticketListUtils.test.js +41 -0
  97. package/dist/utils/__tests__/ticketListUtils.test.js.map +1 -0
  98. package/dist/utils/__tests__/typeUtils.test.d.ts +2 -0
  99. package/dist/utils/__tests__/typeUtils.test.d.ts.map +1 -0
  100. package/dist/utils/__tests__/typeUtils.test.js +11 -0
  101. package/dist/utils/__tests__/typeUtils.test.js.map +1 -0
  102. package/dist/utils/__tests__/userSubscriptionUtils.test.d.ts +5 -0
  103. package/dist/utils/__tests__/userSubscriptionUtils.test.d.ts.map +1 -0
  104. package/dist/utils/__tests__/userSubscriptionUtils.test.js +66 -0
  105. package/dist/utils/__tests__/userSubscriptionUtils.test.js.map +1 -0
  106. package/dist/utils/__tests__/userUtils.test.d.ts +2 -0
  107. package/dist/utils/__tests__/userUtils.test.d.ts.map +1 -0
  108. package/dist/utils/__tests__/userUtils.test.js +34 -0
  109. package/dist/utils/__tests__/userUtils.test.js.map +1 -0
  110. package/dist/utils/__tests__/venueTypeFormatter.test.d.ts +2 -0
  111. package/dist/utils/__tests__/venueTypeFormatter.test.d.ts.map +1 -0
  112. package/dist/utils/__tests__/venueTypeFormatter.test.js +25 -0
  113. package/dist/utils/__tests__/venueTypeFormatter.test.js.map +1 -0
  114. package/dist/utils/service/__tests__/regexUtils.test.d.ts +2 -0
  115. package/dist/utils/service/__tests__/regexUtils.test.d.ts.map +1 -0
  116. package/dist/utils/service/__tests__/regexUtils.test.js +51 -0
  117. package/dist/utils/service/__tests__/regexUtils.test.js.map +1 -0
  118. package/dist/utils/service/__tests__/serviceDBUtils.test.d.ts +2 -0
  119. package/dist/utils/service/__tests__/serviceDBUtils.test.d.ts.map +1 -0
  120. package/dist/utils/service/__tests__/serviceDBUtils.test.js +17 -0
  121. package/dist/utils/service/__tests__/serviceDBUtils.test.js.map +1 -0
  122. package/dist/utils/service/__tests__/serviceFeaturedSortUtils.test.d.ts +2 -0
  123. package/dist/utils/service/__tests__/serviceFeaturedSortUtils.test.d.ts.map +1 -0
  124. package/dist/utils/service/__tests__/serviceFeaturedSortUtils.test.js +60 -0
  125. package/dist/utils/service/__tests__/serviceFeaturedSortUtils.test.js.map +1 -0
  126. package/dist/utils/service/__tests__/serviceUtils.test.d.ts +2 -0
  127. package/dist/utils/service/__tests__/serviceUtils.test.d.ts.map +1 -0
  128. package/dist/utils/service/__tests__/serviceUtils.test.js +135 -0
  129. package/dist/utils/service/__tests__/serviceUtils.test.js.map +1 -0
  130. package/dist/utils/service/serviceFeaturedSortUtils.d.ts.map +1 -1
  131. package/dist/utils/service/serviceFeaturedSortUtils.js +5 -1
  132. package/dist/utils/service/serviceFeaturedSortUtils.js.map +1 -1
  133. package/dist/utils/ticketListUtils.d.ts.map +1 -1
  134. package/dist/utils/ticketListUtils.js +11 -7
  135. package/dist/utils/ticketListUtils.js.map +1 -1
  136. package/package.json +1 -1
  137. package/prisma/schema.prisma +47 -0
  138. package/src/__tests__/definitionsHelpers.test.ts +136 -0
  139. package/src/__tests__/extendedSchemas.test.ts +50 -0
  140. package/src/__tests__/membershipDefinitions.test.ts +144 -0
  141. package/src/__tests__/ticketAdmission.test.ts +73 -0
  142. package/src/__tests__/utmAttribution.test.ts +118 -0
  143. package/src/extendedSchemas.ts +2 -0
  144. package/src/index.ts +1 -0
  145. package/src/sms/smsTemplates.ts +20 -0
  146. package/src/utils/__tests__/addressUtils.test.ts +176 -0
  147. package/src/utils/__tests__/apiUtils.test.ts +219 -0
  148. package/src/utils/__tests__/arrayUtils.test.ts +26 -0
  149. package/src/utils/__tests__/blogUtils.test.ts +145 -0
  150. package/src/utils/__tests__/dateTimeUtils.test.ts +233 -0
  151. package/src/utils/__tests__/discountEngine/discountCalculator.test.ts +410 -0
  152. package/src/utils/__tests__/generalDateTimeUtils.test.ts +34 -0
  153. package/src/utils/__tests__/luxonUtils.test.ts +171 -0
  154. package/src/utils/__tests__/mathUtils.test.ts +30 -0
  155. package/src/utils/__tests__/objUtils.test.ts +48 -0
  156. package/src/utils/__tests__/paymentUtils.test.ts +59 -0
  157. package/src/utils/__tests__/qrCodeUtils.test.ts +28 -1
  158. package/src/utils/__tests__/reviewUtils.test.ts +65 -0
  159. package/src/utils/__tests__/slugUtils.test.ts +143 -0
  160. package/src/utils/__tests__/sortUtils.test.ts +33 -0
  161. package/src/utils/__tests__/stringUtils.test.ts +37 -0
  162. package/src/utils/__tests__/ticketListUtils.test.ts +47 -0
  163. package/src/utils/__tests__/typeUtils.test.ts +12 -0
  164. package/src/utils/__tests__/userSubscriptionUtils.test.ts +87 -0
  165. package/src/utils/__tests__/userUtils.test.ts +41 -0
  166. package/src/utils/__tests__/venueTypeFormatter.test.ts +31 -0
  167. package/src/utils/service/__tests__/regexUtils.test.ts +66 -0
  168. package/src/utils/service/__tests__/serviceDBUtils.test.ts +19 -0
  169. package/src/utils/service/__tests__/serviceFeaturedSortUtils.test.ts +67 -0
  170. package/src/utils/service/__tests__/serviceUtils.test.ts +190 -0
  171. package/src/utils/service/serviceFeaturedSortUtils.ts +5 -1
  172. package/src/utils/ticketListUtils.ts +13 -6
@@ -0,0 +1,233 @@
1
+ /**
2
+ * dateTimeUtils — parsing, comparison, and normalization helpers.
3
+ * Pin UTC normalization and comparison semantics with fixed instants.
4
+ */
5
+ import { DateTime } from "luxon";
6
+ import { describe, expect, test, beforeEach, afterEach, jest } from "@jest/globals";
7
+ import {
8
+ getLocalTimezoneName,
9
+ ensureIsDateTime,
10
+ normalizeDate,
11
+ normalizeDates,
12
+ dateWithinDateRange,
13
+ findLatestDateTime,
14
+ isDateTimeLaterThanNow,
15
+ areDateTimesEqual,
16
+ compareDateTime,
17
+ parseTimeString,
18
+ timestampMinutesFromNow,
19
+ } from "../dateTimeUtils.js";
20
+
21
+ describe("getLocalTimezoneName", () => {
22
+ test("returns a non-empty timezone string", () => {
23
+ const tz = getLocalTimezoneName();
24
+ expect(typeof tz).toBe("string");
25
+ expect(tz.length).toBeGreaterThan(0);
26
+ });
27
+ });
28
+
29
+ describe("ensureIsDateTime", () => {
30
+ test("returns undefined for null/undefined", () => {
31
+ expect(ensureIsDateTime(null)).toBeUndefined();
32
+ expect(ensureIsDateTime(undefined)).toBeUndefined();
33
+ });
34
+
35
+ test("returns Date for Date input", () => {
36
+ const d = new Date("2026-03-07T12:00:00Z");
37
+ expect(ensureIsDateTime(d)).toBe(d);
38
+ });
39
+
40
+ test("parses ISO string to Date", () => {
41
+ const result = ensureIsDateTime("2026-03-07T12:00:00Z");
42
+ expect(result).toBeInstanceOf(Date);
43
+ expect(result?.toISOString()).toBe("2026-03-07T12:00:00.000Z");
44
+ });
45
+ });
46
+
47
+ describe("normalizeDate", () => {
48
+ test("returns undefined for null/undefined", () => {
49
+ expect(normalizeDate(null)).toBeUndefined();
50
+ expect(normalizeDate(undefined)).toBeUndefined();
51
+ });
52
+
53
+ test("returns undefined for invalid date", () => {
54
+ const spy = jest.spyOn(console, "error").mockImplementation(() => {});
55
+ expect(normalizeDate("not-a-date")).toBeUndefined();
56
+ expect(spy).toHaveBeenCalled();
57
+ spy.mockRestore();
58
+ });
59
+
60
+ test("normalizes to UTC start of minute", () => {
61
+ const input = new Date("2026-03-07T12:34:56.789Z");
62
+ const result = normalizeDate(input);
63
+ expect(result).toEqual(
64
+ DateTime.fromJSDate(input).toUTC().startOf("minute").toJSDate()
65
+ );
66
+ });
67
+
68
+ test("accepts ISO string input", () => {
69
+ const result = normalizeDate("2026-03-07T12:34:56.789Z");
70
+ expect(result?.getUTCSeconds()).toBe(0);
71
+ expect(result?.getUTCMilliseconds()).toBe(0);
72
+ });
73
+ });
74
+
75
+ describe("normalizeDates", () => {
76
+ test("filters out invalid dates", () => {
77
+ const spy = jest.spyOn(console, "error").mockImplementation(() => {});
78
+ const results = normalizeDates([
79
+ "2026-03-07T12:00:00Z",
80
+ null,
81
+ "bad",
82
+ "2026-03-08T12:00:00Z",
83
+ ]);
84
+ expect(results).toHaveLength(2);
85
+ spy.mockRestore();
86
+ });
87
+ });
88
+
89
+ describe("compareDateTime", () => {
90
+ test("returns 0 when both dates are null/undefined", () => {
91
+ expect(compareDateTime(null, null)).toBe(0);
92
+ expect(compareDateTime(undefined, undefined)).toBe(0);
93
+ });
94
+
95
+ test("null date1 is treated as earlier", () => {
96
+ const d = new Date("2026-03-07T12:00:00Z");
97
+ expect(compareDateTime(null, d)).toBe(-1);
98
+ });
99
+
100
+ test("null date2 is treated as earlier than date1", () => {
101
+ const d = new Date("2026-03-07T12:00:00Z");
102
+ expect(compareDateTime(d, null)).toBe(1);
103
+ });
104
+
105
+ test("compares Date objects", () => {
106
+ const earlier = new Date("2026-03-07T10:00:00Z");
107
+ const later = new Date("2026-03-07T12:00:00Z");
108
+ expect(compareDateTime(later, earlier)).toBe(1);
109
+ expect(compareDateTime(earlier, later)).toBe(-1);
110
+ expect(compareDateTime(earlier, earlier)).toBe(0);
111
+ });
112
+
113
+ test("compares ISO strings", () => {
114
+ expect(compareDateTime("2026-03-07T12:00:00Z", "2026-03-07T10:00:00Z")).toBe(1);
115
+ expect(compareDateTime("2026-03-07T10:00:00Z", "2026-03-07T12:00:00Z")).toBe(-1);
116
+ });
117
+ });
118
+
119
+ describe("areDateTimesEqual", () => {
120
+ test("returns true for equal instants", () => {
121
+ const d = new Date("2026-03-07T12:00:00Z");
122
+ expect(areDateTimesEqual(d, d)).toBe(true);
123
+ expect(areDateTimesEqual("2026-03-07T12:00:00Z", d)).toBe(true);
124
+ });
125
+
126
+ test("returns false for different instants", () => {
127
+ expect(
128
+ areDateTimesEqual("2026-03-07T12:00:00Z", "2026-03-07T10:00:00Z")
129
+ ).toBe(false);
130
+ });
131
+ });
132
+
133
+ describe("findLatestDateTime", () => {
134
+ test("returns the later date", () => {
135
+ const earlier = "2026-03-07T10:00:00Z";
136
+ const later = "2026-03-07T12:00:00Z";
137
+ expect(findLatestDateTime(earlier, later)?.toISOString()).toBe(
138
+ new Date(later).toISOString()
139
+ );
140
+ expect(findLatestDateTime(later, earlier)?.toISOString()).toBe(
141
+ new Date(later).toISOString()
142
+ );
143
+ });
144
+ });
145
+
146
+ describe("isDateTimeLaterThanNow", () => {
147
+ beforeEach(() => {
148
+ jest.useFakeTimers();
149
+ jest.setSystemTime(new Date("2026-03-07T12:00:00Z"));
150
+ });
151
+
152
+ afterEach(() => {
153
+ jest.useRealTimers();
154
+ });
155
+
156
+ test("returns true for future dates", () => {
157
+ expect(isDateTimeLaterThanNow("2026-03-07T13:00:00Z")).toBe(true);
158
+ });
159
+
160
+ test("returns false for past dates", () => {
161
+ expect(isDateTimeLaterThanNow("2026-03-07T11:00:00Z")).toBe(false);
162
+ });
163
+ });
164
+
165
+ describe("dateWithinDateRange", () => {
166
+ test("returns true when date is within inclusive start, exclusive end", () => {
167
+ const inRange = dateWithinDateRange("2026-03-07T15:00:00Z", {
168
+ startDate: new Date("2026-03-07T00:00:00Z"),
169
+ endDate: new Date("2026-03-08T00:00:00Z"),
170
+ });
171
+ expect(inRange).toBe(true);
172
+ });
173
+
174
+ test("returns false when date is on or after end day", () => {
175
+ const onEndDay = dateWithinDateRange("2026-03-08T12:00:00Z", {
176
+ startDate: new Date("2026-03-07T00:00:00Z"),
177
+ endDate: new Date("2026-03-08T00:00:00Z"),
178
+ });
179
+ expect(onEndDay).toBe(false);
180
+ });
181
+
182
+ test("defaults to today when range is undefined", () => {
183
+ jest.useFakeTimers();
184
+ jest.setSystemTime(new Date("2026-03-07T15:00:00Z"));
185
+ expect(dateWithinDateRange("2026-03-07T16:00:00Z", undefined)).toBe(true);
186
+ expect(dateWithinDateRange("2026-03-09T12:00:00Z", undefined)).toBe(false);
187
+ jest.useRealTimers();
188
+ });
189
+ });
190
+
191
+ describe("parseTimeString", () => {
192
+ test("parses 10:30 AM", () => {
193
+ expect(parseTimeString("10:30 AM")).toEqual({ hours: 10, minutes: 30, ampm: "AM" });
194
+ });
195
+
196
+ test("parses 10:30 PM as 24h hours", () => {
197
+ expect(parseTimeString("10:30 PM")).toEqual({ hours: 22, minutes: 30, ampm: "PM" });
198
+ });
199
+
200
+ test("parses 12:00 PM", () => {
201
+ expect(parseTimeString("12:00 PM")).toEqual({ hours: 12, minutes: 0, ampm: "PM" });
202
+ });
203
+
204
+ test("parses 1:05 AM", () => {
205
+ expect(parseTimeString("1:05 AM")).toEqual({ hours: 1, minutes: 5, ampm: "AM" });
206
+ });
207
+
208
+ test("returns null for invalid formats", () => {
209
+ expect(parseTimeString("")).toBeNull();
210
+ expect(parseTimeString("25:00 AM")).toBeNull();
211
+ expect(parseTimeString("10:60 AM")).toBeNull();
212
+ expect(parseTimeString("10:30")).toBeNull();
213
+ expect(parseTimeString("noon")).toBeNull();
214
+ });
215
+ });
216
+
217
+ describe("timestampMinutesFromNow", () => {
218
+ beforeEach(() => {
219
+ jest.useFakeTimers();
220
+ jest.setSystemTime(new Date("2026-03-07T12:00:00Z"));
221
+ });
222
+
223
+ afterEach(() => {
224
+ jest.useRealTimers();
225
+ });
226
+
227
+ test("returns unix seconds floored for N minutes ahead", () => {
228
+ const expected = Math.floor(
229
+ DateTime.fromJSDate(new Date("2026-03-07T12:00:00Z")).plus({ minutes: 30 }).toSeconds()
230
+ );
231
+ expect(timestampMinutesFromNow(30)).toBe(expected);
232
+ });
233
+ });
@@ -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
+ });