@bash-app/bash-common 30.113.0 → 30.115.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.
- package/dist/definitions.d.ts +2 -2
- package/dist/definitions.d.ts.map +1 -1
- package/dist/definitions.js +2 -2
- package/dist/definitions.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/utils/__tests__/recurrenceUtils.test.d.ts +2 -0
- package/dist/utils/__tests__/recurrenceUtils.test.d.ts.map +1 -0
- package/dist/utils/__tests__/recurrenceUtils.test.js +193 -0
- package/dist/utils/__tests__/recurrenceUtils.test.js.map +1 -0
- package/dist/utils/discountEngine/bestPriceResolver.d.ts +31 -0
- package/dist/utils/discountEngine/bestPriceResolver.d.ts.map +1 -0
- package/dist/utils/discountEngine/bestPriceResolver.js +147 -0
- package/dist/utils/discountEngine/bestPriceResolver.js.map +1 -0
- package/dist/utils/discountEngine/discountCalculator.d.ts +56 -0
- package/dist/utils/discountEngine/discountCalculator.d.ts.map +1 -0
- package/dist/utils/discountEngine/discountCalculator.js +219 -0
- package/dist/utils/discountEngine/discountCalculator.js.map +1 -0
- package/dist/utils/discountEngine/eligibilityValidator.d.ts +36 -0
- package/dist/utils/discountEngine/eligibilityValidator.d.ts.map +1 -0
- package/dist/utils/discountEngine/eligibilityValidator.js +189 -0
- package/dist/utils/discountEngine/eligibilityValidator.js.map +1 -0
- package/dist/utils/discountEngine/index.d.ts +4 -0
- package/dist/utils/discountEngine/index.d.ts.map +1 -0
- package/dist/utils/discountEngine/index.js +5 -0
- package/dist/utils/discountEngine/index.js.map +1 -0
- package/dist/utils/recurrenceUtils.d.ts.map +1 -1
- package/dist/utils/recurrenceUtils.js +17 -4
- package/dist/utils/recurrenceUtils.js.map +1 -1
- package/package.json +5 -5
- package/prisma/schema.prisma +387 -81
- package/src/definitions.ts +2 -1
- package/src/index.ts +2 -0
- package/src/utils/discountEngine/bestPriceResolver.ts +212 -0
- package/src/utils/discountEngine/discountCalculator.ts +281 -0
- package/src/utils/discountEngine/eligibilityValidator.ts +256 -0
- package/src/utils/discountEngine/index.ts +5 -0
- package/src/utils/recurrenceUtils.ts +20 -4
|
@@ -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
|
+
|