@bash-app/bash-common 30.112.0 → 30.114.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 (40) hide show
  1. package/dist/definitions.d.ts +2 -2
  2. package/dist/definitions.d.ts.map +1 -1
  3. package/dist/definitions.js +2 -2
  4. package/dist/definitions.js.map +1 -1
  5. package/dist/index.d.ts +2 -0
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +2 -0
  8. package/dist/index.js.map +1 -1
  9. package/dist/utils/__tests__/recurrenceUtils.test.d.ts +2 -0
  10. package/dist/utils/__tests__/recurrenceUtils.test.d.ts.map +1 -0
  11. package/dist/utils/__tests__/recurrenceUtils.test.js +193 -0
  12. package/dist/utils/__tests__/recurrenceUtils.test.js.map +1 -0
  13. package/dist/utils/discountEngine/bestPriceResolver.d.ts +31 -0
  14. package/dist/utils/discountEngine/bestPriceResolver.d.ts.map +1 -0
  15. package/dist/utils/discountEngine/bestPriceResolver.js +147 -0
  16. package/dist/utils/discountEngine/bestPriceResolver.js.map +1 -0
  17. package/dist/utils/discountEngine/discountCalculator.d.ts +56 -0
  18. package/dist/utils/discountEngine/discountCalculator.d.ts.map +1 -0
  19. package/dist/utils/discountEngine/discountCalculator.js +219 -0
  20. package/dist/utils/discountEngine/discountCalculator.js.map +1 -0
  21. package/dist/utils/discountEngine/eligibilityValidator.d.ts +36 -0
  22. package/dist/utils/discountEngine/eligibilityValidator.d.ts.map +1 -0
  23. package/dist/utils/discountEngine/eligibilityValidator.js +189 -0
  24. package/dist/utils/discountEngine/eligibilityValidator.js.map +1 -0
  25. package/dist/utils/discountEngine/index.d.ts +4 -0
  26. package/dist/utils/discountEngine/index.d.ts.map +1 -0
  27. package/dist/utils/discountEngine/index.js +5 -0
  28. package/dist/utils/discountEngine/index.js.map +1 -0
  29. package/dist/utils/recurrenceUtils.d.ts.map +1 -1
  30. package/dist/utils/recurrenceUtils.js +17 -4
  31. package/dist/utils/recurrenceUtils.js.map +1 -1
  32. package/package.json +1 -1
  33. package/prisma/schema.prisma +448 -118
  34. package/src/definitions.ts +2 -1
  35. package/src/index.ts +2 -0
  36. package/src/utils/discountEngine/bestPriceResolver.ts +212 -0
  37. package/src/utils/discountEngine/discountCalculator.ts +281 -0
  38. package/src/utils/discountEngine/eligibilityValidator.ts +256 -0
  39. package/src/utils/discountEngine/index.ts +5 -0
  40. package/src/utils/recurrenceUtils.ts +20 -4
@@ -2,6 +2,7 @@ import {
2
2
  BashEventDressTags,
3
3
  BashEventType,
4
4
  BashEventVibeTags,
5
+ BracketType,
5
6
  CompetitionType,
6
7
  Contact,
7
8
  EntertainmentServiceType,
@@ -879,7 +880,7 @@ export const YearsOfExperienceToString: { [key in YearsOfExperience]: string } =
879
880
  };
880
881
 
881
882
  // Export Prisma enums for use in frontend
882
- export { YearsOfExperience, EntertainmentServiceType, MusicGenreType, ServiceTypes, CompetitionType, JudgingType };
883
+ export { YearsOfExperience, EntertainmentServiceType, MusicGenreType, ServiceTypes, CompetitionType, JudgingType, BracketType };
883
884
 
884
885
  export type BashEventTypeToStringType = {
885
886
  [key in BashEventType]: string;
package/src/index.ts CHANGED
@@ -8,6 +8,8 @@ export * from "./utils/awsS3Utils";
8
8
  export * from "./utils/bashCashPaymentUtils";
9
9
  export * from "./utils/contentFilterUtils";
10
10
  export * from "./utils/dateTimeUtils";
11
+ export * from "./utils/discountEngine/bestPriceResolver";
12
+ export * from "./utils/discountEngine/eligibilityValidator";
11
13
  export * from "./utils/objUtils";
12
14
  export * from "./utils/paymentUtils";
13
15
  export * from "./utils/promoCodesUtils";
@@ -0,0 +1,212 @@
1
+ import { BashEventPromoCode, SpecialOffer } from "@prisma/client";
2
+ import {
3
+ calculatePromoCodeDiscount,
4
+ calculateSpecialOfferDiscount,
5
+ DiscountResult,
6
+ formatDiscountDescription,
7
+ } from "./discountCalculator";
8
+
9
+ export interface BestPriceOptions {
10
+ ticketPrice: number; // In cents
11
+ quantity: number;
12
+ userId?: string;
13
+ promoCodes: BashEventPromoCode[];
14
+ specialOffers: SpecialOffer[];
15
+ allowStacking: boolean; // Global setting
16
+ }
17
+
18
+ export interface BestPriceResult {
19
+ appliedDiscounts: DiscountResult[];
20
+ originalTotal: number; // In cents
21
+ finalTotal: number; // In cents
22
+ totalSavings: number; // In cents
23
+ explanation: string; // "Early Bird -$10 + Code SUMMER20 -$5"
24
+ }
25
+
26
+ /**
27
+ * Find the best price by evaluating all possible discount combinations
28
+ * Implements "best price wins" logic with stacking rules
29
+ */
30
+ export const findBestPrice = (options: BestPriceOptions): BestPriceResult => {
31
+ const { ticketPrice, quantity, userId, promoCodes, specialOffers, allowStacking } = options;
32
+ const originalTotal = ticketPrice * quantity;
33
+
34
+ // Early return if no discounts available
35
+ if (promoCodes.length === 0 && specialOffers.length === 0) {
36
+ return {
37
+ appliedDiscounts: [],
38
+ originalTotal,
39
+ finalTotal: originalTotal,
40
+ totalSavings: 0,
41
+ explanation: "No discounts applied",
42
+ };
43
+ }
44
+
45
+ // Calculate all possible discount results
46
+ const promoCodeResults = promoCodes.map(code =>
47
+ calculatePromoCodeDiscount(code, ticketPrice, quantity)
48
+ );
49
+
50
+ const offerResults = specialOffers.map(offer =>
51
+ calculateSpecialOfferDiscount(offer, ticketPrice, quantity, userId)
52
+ );
53
+
54
+ // Generate all valid combinations
55
+ const combinations: DiscountResult[][] = [];
56
+
57
+ // Single promo code only
58
+ promoCodeResults.forEach(promoResult => {
59
+ combinations.push([promoResult]);
60
+ });
61
+
62
+ // Single special offer only
63
+ offerResults.forEach(offerResult => {
64
+ combinations.push([offerResult]);
65
+ });
66
+
67
+ // If stacking is allowed, try combinations
68
+ if (allowStacking) {
69
+ promoCodeResults.forEach(promoResult => {
70
+ offerResults.forEach(offerResult => {
71
+ // Find the original offer to check stacking rules
72
+ const originalOffer = specialOffers.find(o => o.id === offerResult.sourceId);
73
+
74
+ if (originalOffer && originalOffer.canStackWithPromoCodes) {
75
+ // Create a combination of promo code + special offer
76
+ const stackedDiscounts = stackDiscounts(
77
+ [promoResult, offerResult],
78
+ originalTotal
79
+ );
80
+ combinations.push(stackedDiscounts);
81
+ }
82
+ });
83
+ });
84
+
85
+ // Check if any offers can stack with each other
86
+ for (let i = 0; i < offerResults.length; i++) {
87
+ for (let j = i + 1; j < offerResults.length; j++) {
88
+ const offer1 = specialOffers.find(o => o.id === offerResults[i].sourceId);
89
+ const offer2 = specialOffers.find(o => o.id === offerResults[j].sourceId);
90
+
91
+ if (offer1?.canStackWithOtherOffers && offer2?.canStackWithOtherOffers) {
92
+ const stackedDiscounts = stackDiscounts(
93
+ [offerResults[i], offerResults[j]],
94
+ originalTotal
95
+ );
96
+ combinations.push(stackedDiscounts);
97
+ }
98
+ }
99
+ }
100
+ }
101
+
102
+ // Find the combination with the lowest final price
103
+ let bestCombination: DiscountResult[] = [];
104
+ let lowestPrice = originalTotal;
105
+
106
+ combinations.forEach(combo => {
107
+ const totalDiscount = combo.reduce((sum, d) => sum + d.amountDiscounted, 0);
108
+ const finalPrice = Math.max(0, originalTotal - totalDiscount);
109
+
110
+ if (finalPrice < lowestPrice) {
111
+ lowestPrice = finalPrice;
112
+ bestCombination = combo;
113
+ }
114
+ });
115
+
116
+ // Calculate total savings
117
+ const totalSavings = bestCombination.reduce((sum, d) => sum + d.amountDiscounted, 0);
118
+
119
+ // Create explanation
120
+ const explanation = bestCombination.length > 0
121
+ ? bestCombination.map(formatDiscountDescription).join(" + ")
122
+ : "No discounts applied";
123
+
124
+ return {
125
+ appliedDiscounts: bestCombination,
126
+ originalTotal,
127
+ finalTotal: lowestPrice,
128
+ totalSavings,
129
+ explanation,
130
+ };
131
+ };
132
+
133
+ /**
134
+ * Stack multiple discounts, applying guardrails to ensure they work correctly together
135
+ * Discounts are applied sequentially, not compounded
136
+ */
137
+ const stackDiscounts = (
138
+ discounts: DiscountResult[],
139
+ originalTotal: number
140
+ ): DiscountResult[] => {
141
+ // Sort by priority (percentage discounts first, then fixed amounts)
142
+ const sorted = [...discounts].sort((a, b) => {
143
+ if (a.discountType === 'PERCENTAGE' && b.discountType !== 'PERCENTAGE') return -1;
144
+ if (a.discountType !== 'PERCENTAGE' && b.discountType === 'PERCENTAGE') return 1;
145
+ return 0;
146
+ });
147
+
148
+ // Apply discounts sequentially
149
+ let runningTotal = originalTotal;
150
+ const stackedResults: DiscountResult[] = [];
151
+
152
+ sorted.forEach(discount => {
153
+ // Recalculate discount based on current running total
154
+ let actualDiscount = discount.amountDiscounted;
155
+
156
+ if (discount.discountType === 'PERCENTAGE') {
157
+ // Apply percentage to current running total
158
+ actualDiscount = Math.floor((runningTotal * discount.discountValue) / 100);
159
+ } else {
160
+ // Fixed amount, but don't exceed running total
161
+ actualDiscount = Math.min(discount.amountDiscounted, runningTotal);
162
+ }
163
+
164
+ runningTotal = Math.max(0, runningTotal - actualDiscount);
165
+
166
+ stackedResults.push({
167
+ ...discount,
168
+ amountDiscounted: actualDiscount,
169
+ finalPrice: runningTotal,
170
+ });
171
+ });
172
+
173
+ return stackedResults;
174
+ };
175
+
176
+ /**
177
+ * Apply guardrails to ensure discount doesn't violate business rules
178
+ */
179
+ export const applyGuardrails = (
180
+ result: DiscountResult,
181
+ minFloor?: number,
182
+ maxDiscountPercent?: number
183
+ ): DiscountResult => {
184
+ let adjustedResult = { ...result };
185
+
186
+ // Enforce minimum price floor
187
+ if (minFloor && adjustedResult.finalPrice < minFloor) {
188
+ const maxAllowedDiscount = adjustedResult.originalPrice - minFloor;
189
+ adjustedResult.amountDiscounted = Math.min(adjustedResult.amountDiscounted, maxAllowedDiscount);
190
+ adjustedResult.finalPrice = adjustedResult.originalPrice - adjustedResult.amountDiscounted;
191
+ }
192
+
193
+ // Enforce maximum discount percentage
194
+ if (maxDiscountPercent) {
195
+ const maxAllowedDiscount = Math.floor((adjustedResult.originalPrice * maxDiscountPercent) / 100);
196
+ if (adjustedResult.amountDiscounted > maxAllowedDiscount) {
197
+ adjustedResult.amountDiscounted = maxAllowedDiscount;
198
+ adjustedResult.finalPrice = adjustedResult.originalPrice - maxAllowedDiscount;
199
+ }
200
+ }
201
+
202
+ return adjustedResult;
203
+ };
204
+
205
+ /**
206
+ * Calculate the effective discount rate as a percentage
207
+ */
208
+ export const calculateEffectiveRate = (result: BestPriceResult): number => {
209
+ if (result.originalTotal === 0) return 0;
210
+ return Math.floor((result.totalSavings / result.originalTotal) * 100);
211
+ };
212
+
@@ -0,0 +1,281 @@
1
+ import { BashEventPromoCode, SpecialOffer, SpecialOfferType, OfferDiscountType } from "@prisma/client";
2
+ import { convertCentsToDollars } from "../paymentUtils";
3
+
4
+ export interface DiscountResult {
5
+ sourceType: 'PROMO_CODE' | 'SPECIAL_OFFER';
6
+ sourceId: string;
7
+ sourceName: string;
8
+ discountType: 'PERCENTAGE' | 'FIXED_AMOUNT';
9
+ discountValue: number;
10
+ amountDiscounted: number; // In cents
11
+ originalPrice: number; // In cents
12
+ finalPrice: number; // In cents
13
+ attribution?: {
14
+ userId: string;
15
+ commissionRate: number;
16
+ commissionAmount: number; // In cents
17
+ };
18
+ }
19
+
20
+ /**
21
+ * Calculate discount from a promo code
22
+ */
23
+ export const calculatePromoCodeDiscount = (
24
+ promoCode: BashEventPromoCode,
25
+ ticketPrice: number, // In cents
26
+ quantity: number
27
+ ): DiscountResult => {
28
+ const originalTotal = ticketPrice * quantity;
29
+ let amountDiscounted = 0;
30
+ let discountType: 'PERCENTAGE' | 'FIXED_AMOUNT' = 'PERCENTAGE';
31
+ let discountValue = 0;
32
+
33
+ if (promoCode.discountAmountPercentage) {
34
+ // Percentage discount
35
+ discountType = 'PERCENTAGE';
36
+ discountValue = promoCode.discountAmountPercentage;
37
+ amountDiscounted = Math.floor((originalTotal * promoCode.discountAmountPercentage) / 100);
38
+ } else if (promoCode.discountAmountInCents) {
39
+ // Fixed amount discount (per order, not per ticket)
40
+ discountType = 'FIXED_AMOUNT';
41
+ discountValue = promoCode.discountAmountInCents;
42
+ amountDiscounted = Math.min(promoCode.discountAmountInCents, originalTotal);
43
+ }
44
+
45
+ const finalPrice = Math.max(0, originalTotal - amountDiscounted);
46
+
47
+ return {
48
+ sourceType: 'PROMO_CODE',
49
+ sourceId: promoCode.id,
50
+ sourceName: promoCode.code,
51
+ discountType,
52
+ discountValue,
53
+ amountDiscounted,
54
+ originalPrice: originalTotal,
55
+ finalPrice,
56
+ // Promo codes always have attribution if there's a promoter
57
+ ...(promoCode.promoterId && {
58
+ attribution: {
59
+ userId: promoCode.promoterId,
60
+ commissionRate: 5.0, // Default 5% commission
61
+ commissionAmount: Math.floor(amountDiscounted * 0.05),
62
+ },
63
+ }),
64
+ };
65
+ };
66
+
67
+ /**
68
+ * Calculate discount from a special offer
69
+ */
70
+ export const calculateSpecialOfferDiscount = (
71
+ offer: SpecialOffer,
72
+ ticketPrice: number, // In cents
73
+ quantity: number,
74
+ userId?: string
75
+ ): DiscountResult => {
76
+ const originalTotal = ticketPrice * quantity;
77
+ let amountDiscounted = 0;
78
+ let discountType: 'PERCENTAGE' | 'FIXED_AMOUNT' = 'PERCENTAGE';
79
+ let discountValue = offer.discountValue;
80
+
81
+ // Calculate based on offer type
82
+ switch (offer.offerType) {
83
+ case SpecialOfferType.BOGO:
84
+ amountDiscounted = calculateBOGODiscount(offer, quantity, ticketPrice);
85
+ discountType = 'FREE_ITEMS' as any; // Special case
86
+ break;
87
+
88
+ case SpecialOfferType.GROUP_DISCOUNT:
89
+ amountDiscounted = calculateGroupDiscount(offer, quantity, ticketPrice);
90
+ discountType = offer.discountType === OfferDiscountType.PERCENTAGE ? 'PERCENTAGE' : 'FIXED_AMOUNT';
91
+ break;
92
+
93
+ case SpecialOfferType.VOLUME_DISCOUNT:
94
+ amountDiscounted = calculateVolumeDiscount(offer, quantity, ticketPrice);
95
+ discountType = offer.discountType === OfferDiscountType.PERCENTAGE ? 'PERCENTAGE' : 'FIXED_AMOUNT';
96
+ break;
97
+
98
+ case SpecialOfferType.EARLY_BIRD:
99
+ case SpecialOfferType.FLASH_SALE:
100
+ case SpecialOfferType.LOYALTY:
101
+ case SpecialOfferType.COUNTDOWN:
102
+ // These are simple percentage or fixed amount discounts
103
+ if (offer.discountType === OfferDiscountType.PERCENTAGE) {
104
+ discountType = 'PERCENTAGE';
105
+ amountDiscounted = Math.floor((originalTotal * offer.discountValue) / 100);
106
+ } else {
107
+ discountType = 'FIXED_AMOUNT';
108
+ amountDiscounted = Math.min(offer.discountValue * quantity, originalTotal);
109
+ }
110
+ break;
111
+
112
+ case SpecialOfferType.BUNDLE:
113
+ // Bundle savings are pre-calculated
114
+ amountDiscounted = offer.bundleSavings || 0;
115
+ discountType = 'FIXED_AMOUNT';
116
+ break;
117
+
118
+ case SpecialOfferType.REFERRAL:
119
+ // Referral discounts apply to both parties
120
+ amountDiscounted = offer.referralDiscountForBuyer || 0;
121
+ discountType = 'FIXED_AMOUNT';
122
+ break;
123
+
124
+ case SpecialOfferType.FAMILY_PACK:
125
+ // Family pack is typically a percentage off
126
+ if (offer.discountType === OfferDiscountType.PERCENTAGE) {
127
+ discountType = 'PERCENTAGE';
128
+ amountDiscounted = Math.floor((originalTotal * offer.discountValue) / 100);
129
+ } else {
130
+ discountType = 'FIXED_AMOUNT';
131
+ amountDiscounted = Math.min(offer.discountValue, originalTotal);
132
+ }
133
+ break;
134
+
135
+ default:
136
+ // Default to simple discount
137
+ if (offer.discountType === OfferDiscountType.PERCENTAGE) {
138
+ discountType = 'PERCENTAGE';
139
+ amountDiscounted = Math.floor((originalTotal * offer.discountValue) / 100);
140
+ } else {
141
+ discountType = 'FIXED_AMOUNT';
142
+ amountDiscounted = Math.min(offer.discountValue, originalTotal);
143
+ }
144
+ }
145
+
146
+ const finalPrice = Math.max(0, originalTotal - amountDiscounted);
147
+
148
+ return {
149
+ sourceType: 'SPECIAL_OFFER',
150
+ sourceId: offer.id,
151
+ sourceName: offer.title,
152
+ discountType,
153
+ discountValue,
154
+ amountDiscounted,
155
+ originalPrice: originalTotal,
156
+ finalPrice,
157
+ // Only referral offers have attribution
158
+ ...(offer.requiresAttribution && userId && offer.commissionRate && {
159
+ attribution: {
160
+ userId,
161
+ commissionRate: offer.commissionRate,
162
+ commissionAmount: Math.floor(amountDiscounted * (offer.commissionRate / 100)),
163
+ },
164
+ }),
165
+ };
166
+ };
167
+
168
+ /**
169
+ * Calculate BOGO (Buy One Get One) discount
170
+ * Example: Buy 2 get 1 free, Buy 3 get 2 at 50% off
171
+ */
172
+ export const calculateBOGODiscount = (
173
+ offer: SpecialOffer,
174
+ quantity: number,
175
+ ticketPrice: number
176
+ ): number => {
177
+ const buyQuantity = offer.buyQuantity || 1;
178
+ const getQuantity = offer.getQuantity || 1;
179
+ const getDiscountPercent = offer.getDiscountPercent || 100; // Default 100% off (free)
180
+
181
+ // Calculate how many sets of the offer apply
182
+ const sets = Math.floor(quantity / (buyQuantity + getQuantity));
183
+ const freeTickets = sets * getQuantity;
184
+
185
+ // Calculate discount on free tickets
186
+ const discountPerTicket = Math.floor((ticketPrice * getDiscountPercent) / 100);
187
+ return freeTickets * discountPerTicket;
188
+ };
189
+
190
+ /**
191
+ * Calculate group discount based on quantity
192
+ */
193
+ export const calculateGroupDiscount = (
194
+ offer: SpecialOffer,
195
+ quantity: number,
196
+ ticketPrice: number
197
+ ): number => {
198
+ // Check if quantity qualifies
199
+ if (offer.minGroupSize && quantity < offer.minGroupSize) {
200
+ return 0;
201
+ }
202
+ if (offer.maxGroupSize && quantity > offer.maxGroupSize) {
203
+ return 0; // Or cap at max group size
204
+ }
205
+
206
+ // Check for tiered discounts
207
+ if (offer.tieredDiscounts) {
208
+ const tiers = offer.tieredDiscounts as Array<{ qty: number; discount: number }>;
209
+ // Find the highest tier that applies
210
+ const applicableTier = tiers
211
+ .filter(tier => quantity >= tier.qty)
212
+ .sort((a, b) => b.discount - a.discount)[0];
213
+
214
+ if (applicableTier) {
215
+ return Math.floor((ticketPrice * quantity * applicableTier.discount) / 100);
216
+ }
217
+ }
218
+
219
+ // Fall back to simple discount
220
+ if (offer.discountType === OfferDiscountType.PERCENTAGE) {
221
+ return Math.floor((ticketPrice * quantity * offer.discountValue) / 100);
222
+ } else {
223
+ return Math.min(offer.discountValue * quantity, ticketPrice * quantity);
224
+ }
225
+ };
226
+
227
+ /**
228
+ * Calculate volume discount (buy more save more)
229
+ * Similar to group discount but typically for larger quantities
230
+ */
231
+ export const calculateVolumeDiscount = (
232
+ offer: SpecialOffer,
233
+ quantity: number,
234
+ ticketPrice: number
235
+ ): number => {
236
+ // Reuse group discount logic
237
+ return calculateGroupDiscount(offer, quantity, ticketPrice);
238
+ };
239
+
240
+ /**
241
+ * Calculate early bird discount (time-based)
242
+ */
243
+ export const calculateEarlyBirdDiscount = (
244
+ offer: SpecialOffer,
245
+ ticketPrice: number,
246
+ currentDate: Date
247
+ ): number => {
248
+ // Check if we're still in early bird period
249
+ if (offer.availableUntil && currentDate > offer.availableUntil) {
250
+ return 0;
251
+ }
252
+
253
+ if (offer.discountType === OfferDiscountType.PERCENTAGE) {
254
+ return Math.floor((ticketPrice * offer.discountValue) / 100);
255
+ } else {
256
+ return Math.min(offer.discountValue, ticketPrice);
257
+ }
258
+ };
259
+
260
+ /**
261
+ * Calculate bundle discount
262
+ */
263
+ export const calculateBundleDiscount = (
264
+ offer: SpecialOffer,
265
+ bundledItems: Array<{ type: string; price: number }>
266
+ ): number => {
267
+ // Bundle savings are pre-calculated and stored in the offer
268
+ return offer.bundleSavings || 0;
269
+ };
270
+
271
+ /**
272
+ * Helper: Convert discount result to human-readable string
273
+ */
274
+ export const formatDiscountDescription = (result: DiscountResult): string => {
275
+ if (result.discountType === 'PERCENTAGE') {
276
+ return `${result.sourceName}: -${result.discountValue}% ($${convertCentsToDollars(result.amountDiscounted).toFixed(2)})`;
277
+ } else {
278
+ return `${result.sourceName}: -$${convertCentsToDollars(result.amountDiscounted).toFixed(2)}`;
279
+ }
280
+ };
281
+