@bash-app/bash-common 30.432.0 → 30.433.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/__tests__/bashSectionTitleForBashType.test.js +2 -0
- package/dist/__tests__/bashSectionTitleForBashType.test.js.map +1 -1
- package/dist/__tests__/discoveryScope.test.js +9 -1
- package/dist/__tests__/discoveryScope.test.js.map +1 -1
- package/dist/__tests__/promoCartEngine.test.d.ts +2 -0
- package/dist/__tests__/promoCartEngine.test.d.ts.map +1 -0
- package/dist/__tests__/promoCartEngine.test.js +174 -0
- package/dist/__tests__/promoCartEngine.test.js.map +1 -0
- package/dist/__tests__/promoCodeRedemption.test.d.ts +2 -0
- package/dist/__tests__/promoCodeRedemption.test.d.ts.map +1 -0
- package/dist/__tests__/promoCodeRedemption.test.js +103 -0
- package/dist/__tests__/promoCodeRedemption.test.js.map +1 -0
- package/dist/__tests__/promoCodeScope.test.d.ts +2 -0
- package/dist/__tests__/promoCodeScope.test.d.ts.map +1 -0
- package/dist/__tests__/promoCodeScope.test.js +77 -0
- package/dist/__tests__/promoCodeScope.test.js.map +1 -0
- package/dist/definitions.d.ts +3 -1
- package/dist/definitions.d.ts.map +1 -1
- package/dist/definitions.js +4 -2
- package/dist/definitions.js.map +1 -1
- package/dist/discoveryScope.d.ts +6 -6
- package/dist/discoveryScope.d.ts.map +1 -1
- package/dist/discoveryScope.js +25 -16
- package/dist/discoveryScope.js.map +1 -1
- package/dist/extendedSchemas.d.ts +1 -0
- package/dist/extendedSchemas.d.ts.map +1 -1
- package/dist/extendedSchemas.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/promoCartEngine.d.ts +55 -0
- package/dist/promoCartEngine.d.ts.map +1 -0
- package/dist/promoCartEngine.js +135 -0
- package/dist/promoCartEngine.js.map +1 -0
- package/dist/promoCodeRedemption.d.ts +44 -0
- package/dist/promoCodeRedemption.d.ts.map +1 -0
- package/dist/promoCodeRedemption.js +81 -0
- package/dist/promoCodeRedemption.js.map +1 -0
- package/dist/promoCodeScope.d.ts +85 -0
- package/dist/promoCodeScope.d.ts.map +1 -0
- package/dist/promoCodeScope.js +93 -0
- package/dist/promoCodeScope.js.map +1 -0
- package/dist/utils/__tests__/discountEngine/discountCalculator.test.js +8 -1
- package/dist/utils/__tests__/discountEngine/discountCalculator.test.js.map +1 -1
- package/dist/utils/__tests__/paymentUtils.test.js +87 -4
- package/dist/utils/__tests__/paymentUtils.test.js.map +1 -1
- package/dist/utils/paymentUtils.d.ts +30 -5
- package/dist/utils/paymentUtils.d.ts.map +1 -1
- package/dist/utils/paymentUtils.js +43 -11
- package/dist/utils/paymentUtils.js.map +1 -1
- package/package.json +1 -1
- package/prisma/schema.prisma +106 -4
- package/src/__tests__/bashSectionTitleForBashType.test.ts +4 -0
- package/src/__tests__/discoveryScope.test.ts +22 -1
- package/src/__tests__/promoCartEngine.test.ts +201 -0
- package/src/__tests__/promoCodeRedemption.test.ts +134 -0
- package/src/__tests__/promoCodeScope.test.ts +106 -0
- package/src/definitions.ts +4 -2
- package/src/discoveryScope.ts +30 -20
- package/src/extendedSchemas.ts +1 -0
- package/src/index.ts +3 -0
- package/src/promoCartEngine.ts +193 -0
- package/src/promoCodeRedemption.ts +123 -0
- package/src/promoCodeScope.ts +164 -0
- package/src/utils/__tests__/discountEngine/discountCalculator.test.ts +8 -0
- package/src/utils/__tests__/paymentUtils.test.ts +100 -6
- package/src/utils/paymentUtils.ts +56 -13
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import {
|
|
2
|
+
allocateAcrossTickets,
|
|
3
|
+
allocateProportionally,
|
|
4
|
+
resolvePromoForCart,
|
|
5
|
+
type PromoCartLine,
|
|
6
|
+
} from "../promoCartEngine.js";
|
|
7
|
+
|
|
8
|
+
const now = new Date("2026-10-14T12:00:00Z");
|
|
9
|
+
|
|
10
|
+
const earlyBird: PromoCartLine = { ticketTierId: "early", pricing: "USD", quantity: 2, cashSubtotalCents: 2000, pointsSubtotal: 0 };
|
|
11
|
+
const regular: PromoCartLine = { ticketTierId: "regular", pricing: "USD", quantity: 1, cashSubtotalCents: 2500, pointsSubtotal: 0 };
|
|
12
|
+
const vip: PromoCartLine = { ticketTierId: "vip", pricing: "USD", quantity: 1, cashSubtotalCents: 7500, pointsSubtotal: 0 };
|
|
13
|
+
const points: PromoCartLine = { ticketTierId: "points", pricing: "BASHPOINTS", quantity: 2, cashSubtotalCents: 0, pointsSubtotal: 1000 };
|
|
14
|
+
const free: PromoCartLine = { ticketTierId: "free", pricing: "FREE", quantity: 3, cashSubtotalCents: 0, pointsSubtotal: 0 };
|
|
15
|
+
|
|
16
|
+
describe("allocateProportionally", () => {
|
|
17
|
+
it("splits by weight, flooring, with leftover to the largest line then tier id", () => {
|
|
18
|
+
const result = allocateProportionally(1000, [
|
|
19
|
+
{ key: "b", weight: 1000 },
|
|
20
|
+
{ key: "a", weight: 1000 },
|
|
21
|
+
{ key: "c", weight: 1000 },
|
|
22
|
+
]);
|
|
23
|
+
expect([...result.entries()].sort()).toEqual([["a", 334], ["b", 333], ["c", 333]]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("gives leftover cents to the largest line first", () => {
|
|
27
|
+
const result = allocateProportionally(1001, [
|
|
28
|
+
{ key: "small", weight: 1000 },
|
|
29
|
+
{ key: "big", weight: 3000 },
|
|
30
|
+
]);
|
|
31
|
+
expect(result.get("big")).toBe(751);
|
|
32
|
+
expect(result.get("small")).toBe(250);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("never allocates more than the eligible total", () => {
|
|
36
|
+
const result = allocateProportionally(9999, [{ key: "a", weight: 500 }]);
|
|
37
|
+
expect(result.get("a")).toBe(500);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("allocateAcrossTickets", () => {
|
|
42
|
+
it("splits evenly with leftover cents to the lowest ticket ids, summing exactly", () => {
|
|
43
|
+
const result = allocateAcrossTickets(1001, ["t3", "t1", "t2"]);
|
|
44
|
+
expect([...result.entries()]).toEqual([["t1", 334], ["t2", 334], ["t3", 333]]);
|
|
45
|
+
expect([...result.values()].reduce((s, v) => s + v, 0)).toBe(1001);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("resolvePromoForCart", () => {
|
|
50
|
+
it("attribution-only (NONE) on all tiers counts cash, BashPoints, and free tickets with no discount", () => {
|
|
51
|
+
const result = resolvePromoForCart({
|
|
52
|
+
promo: { eligibilityMode: "ALL_TIERS", discountType: "NONE" },
|
|
53
|
+
cartLines: [earlyBird, points, free],
|
|
54
|
+
now,
|
|
55
|
+
});
|
|
56
|
+
expect(result.status).toBe("applied");
|
|
57
|
+
expect(result.cashDiscountTotalCents).toBe(0);
|
|
58
|
+
expect(result.pointsDiscountTotal).toBe(0);
|
|
59
|
+
expect(result.attributedTicketCount).toBe(7);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("attributes cash, hybrid, BashPoints-only, and free lines, including a tier added later, with a 0 guest discount", () => {
|
|
63
|
+
const hybrid: PromoCartLine = {
|
|
64
|
+
ticketTierId: "hybrid",
|
|
65
|
+
pricing: "USD",
|
|
66
|
+
quantity: 1,
|
|
67
|
+
cashSubtotalCents: 3000,
|
|
68
|
+
pointsSubtotal: 100,
|
|
69
|
+
};
|
|
70
|
+
const addedLater: PromoCartLine = {
|
|
71
|
+
ticketTierId: "tier_added_later",
|
|
72
|
+
pricing: "USD",
|
|
73
|
+
quantity: 4,
|
|
74
|
+
cashSubtotalCents: 4000,
|
|
75
|
+
pointsSubtotal: 0,
|
|
76
|
+
};
|
|
77
|
+
const result = resolvePromoForCart({
|
|
78
|
+
promo: { eligibilityMode: "ALL_TIERS", discountType: "NONE" },
|
|
79
|
+
cartLines: [earlyBird, hybrid, points, free, addedLater],
|
|
80
|
+
now,
|
|
81
|
+
});
|
|
82
|
+
expect(result.status).toBe("applied");
|
|
83
|
+
expect(result.cashDiscountTotalCents).toBe(0);
|
|
84
|
+
expect(result.pointsDiscountTotal).toBe(0);
|
|
85
|
+
expect(result.lines.map((line) => [line.ticketTierId, line.eligible, line.attributedTicketCount])).toEqual([
|
|
86
|
+
["early", true, 2],
|
|
87
|
+
["hybrid", true, 1],
|
|
88
|
+
["points", true, 2],
|
|
89
|
+
["free", true, 3],
|
|
90
|
+
["tier_added_later", true, 4],
|
|
91
|
+
]);
|
|
92
|
+
expect(result.attributedTicketCount).toBe(12);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("percentage on selected tiers discounts only those cash lines", () => {
|
|
96
|
+
const result = resolvePromoForCart({
|
|
97
|
+
promo: {
|
|
98
|
+
eligibilityMode: "SELECTED_TIERS",
|
|
99
|
+
eligibleTiers: [{ ticketTierId: "regular" }, { ticketTierId: "vip" }],
|
|
100
|
+
discountType: "PERCENT",
|
|
101
|
+
discountAmountPercentage: 10,
|
|
102
|
+
},
|
|
103
|
+
cartLines: [earlyBird, regular, vip],
|
|
104
|
+
now,
|
|
105
|
+
});
|
|
106
|
+
expect(result.lines.map((l) => [l.ticketTierId, l.cashDiscountCents])).toEqual([
|
|
107
|
+
["early", 0],
|
|
108
|
+
["regular", 250],
|
|
109
|
+
["vip", 750],
|
|
110
|
+
]);
|
|
111
|
+
expect(result.attributedTicketCount).toBe(2);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("percentage leaves BashPoints alone unless the host opts in", () => {
|
|
115
|
+
const off = resolvePromoForCart({
|
|
116
|
+
promo: { eligibilityMode: "ALL_TIERS", discountAmountPercentage: 20 },
|
|
117
|
+
cartLines: [points],
|
|
118
|
+
now,
|
|
119
|
+
});
|
|
120
|
+
expect(off.pointsDiscountTotal).toBe(0);
|
|
121
|
+
expect(off.attributedTicketCount).toBe(2);
|
|
122
|
+
|
|
123
|
+
const on = resolvePromoForCart({
|
|
124
|
+
promo: { eligibilityMode: "ALL_TIERS", discountAmountPercentage: 20, discountAppliesToBashPoints: true },
|
|
125
|
+
cartLines: [points],
|
|
126
|
+
now,
|
|
127
|
+
});
|
|
128
|
+
expect(on.pointsDiscountTotal).toBe(200);
|
|
129
|
+
expect(on.lines[0].finalPoints).toBe(800);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("fixed cash applies once per order across eligible cash lines, never to points", () => {
|
|
133
|
+
const result = resolvePromoForCart({
|
|
134
|
+
promo: { eligibilityMode: "ALL_TIERS", discountType: "FIXED_CASH", discountAmountInCents: 1000 },
|
|
135
|
+
cartLines: [earlyBird, regular, points],
|
|
136
|
+
now,
|
|
137
|
+
});
|
|
138
|
+
expect(result.cashDiscountTotalCents).toBe(1000);
|
|
139
|
+
expect(result.pointsDiscountTotal).toBe(0);
|
|
140
|
+
const byTier = Object.fromEntries(result.lines.map((l) => [l.ticketTierId, l.cashDiscountCents]));
|
|
141
|
+
expect(byTier).toEqual({ early: 444, regular: 556, points: 0 });
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("caps a fixed discount at the eligible cash total and floors totals at 0", () => {
|
|
145
|
+
const result = resolvePromoForCart({
|
|
146
|
+
promo: { eligibilityMode: "ALL_TIERS", discountAmountInCents: 99999 },
|
|
147
|
+
cartLines: [regular],
|
|
148
|
+
now,
|
|
149
|
+
});
|
|
150
|
+
expect(result.lines[0].finalCashCents).toBe(0);
|
|
151
|
+
expect(result.cashDiscountTotalCents).toBe(2500);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("caps a percentage above 100 at the line price", () => {
|
|
155
|
+
const result = resolvePromoForCart({
|
|
156
|
+
promo: { ticketTierId: "regular", discountAmountPercentage: 150 },
|
|
157
|
+
cartLines: [regular],
|
|
158
|
+
now,
|
|
159
|
+
});
|
|
160
|
+
expect(result.lines[0].cashDiscountCents).toBe(2500);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("a legacy single-tier code keeps today's total: whole discount on its one line", () => {
|
|
164
|
+
const result = resolvePromoForCart({
|
|
165
|
+
promo: { ticketTierId: "regular", discountAmountInCents: 500 },
|
|
166
|
+
cartLines: [earlyBird, regular],
|
|
167
|
+
now,
|
|
168
|
+
});
|
|
169
|
+
expect(result.cashDiscountTotalCents).toBe(500);
|
|
170
|
+
expect(result.lines.find((l) => l.ticketTierId === "regular")?.cashDiscountCents).toBe(500);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("an all-tiers code covers a tier added after the code was created", () => {
|
|
174
|
+
const result = resolvePromoForCart({
|
|
175
|
+
promo: { eligibilityMode: "ALL_TIERS", discountAmountPercentage: 10 },
|
|
176
|
+
cartLines: [{ ...regular, ticketTierId: "added_later" }],
|
|
177
|
+
now,
|
|
178
|
+
});
|
|
179
|
+
expect(result.lines[0].eligible).toBe(true);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("reports not_applicable, expired, and disabled", () => {
|
|
183
|
+
expect(
|
|
184
|
+
resolvePromoForCart({ promo: { ticketTierId: "vip" }, cartLines: [regular], now }).status
|
|
185
|
+
).toBe("not_applicable");
|
|
186
|
+
expect(
|
|
187
|
+
resolvePromoForCart({
|
|
188
|
+
promo: { eligibilityMode: "ALL_TIERS", redeemBy: new Date("2026-10-01") },
|
|
189
|
+
cartLines: [regular],
|
|
190
|
+
now,
|
|
191
|
+
}).status
|
|
192
|
+
).toBe("expired");
|
|
193
|
+
expect(
|
|
194
|
+
resolvePromoForCart({
|
|
195
|
+
promo: { eligibilityMode: "ALL_TIERS", disabledAt: now },
|
|
196
|
+
cartLines: [regular],
|
|
197
|
+
now,
|
|
198
|
+
}).status
|
|
199
|
+
).toBe("disabled");
|
|
200
|
+
});
|
|
201
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import {
|
|
2
|
+
evaluatePromoRedeemability,
|
|
3
|
+
isPromoCodeRejectReason,
|
|
4
|
+
normalizePromoRedemptionEmail,
|
|
5
|
+
pickPromoCodeAmongDuplicates,
|
|
6
|
+
PROMO_CODE_REJECT_MESSAGES,
|
|
7
|
+
PROMO_CODE_REJECT_REASONS,
|
|
8
|
+
type PromoCodeDuplicateCandidate,
|
|
9
|
+
} from "../promoCodeRedemption.js";
|
|
10
|
+
|
|
11
|
+
const now = new Date("2026-10-14T12:00:00Z");
|
|
12
|
+
|
|
13
|
+
function base(overrides: Partial<Parameters<typeof evaluatePromoRedeemability>[0]> = {}) {
|
|
14
|
+
return {
|
|
15
|
+
redeemBy: null,
|
|
16
|
+
disabledAt: null,
|
|
17
|
+
maxRedemptions: null,
|
|
18
|
+
maxUsesPerAccount: null,
|
|
19
|
+
activeOrderCount: 0,
|
|
20
|
+
accountOrderCount: 0,
|
|
21
|
+
now,
|
|
22
|
+
...overrides,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe("evaluatePromoRedeemability", () => {
|
|
27
|
+
it("allows a code with no limits", () => {
|
|
28
|
+
expect(evaluatePromoRedeemability(base())).toBeNull();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("rejects a disabled code before anything else", () => {
|
|
32
|
+
expect(
|
|
33
|
+
evaluatePromoRedeemability(
|
|
34
|
+
base({ disabledAt: now, redeemBy: new Date("2020-01-01"), activeOrderCount: 99, maxRedemptions: 1 })
|
|
35
|
+
)
|
|
36
|
+
).toBe("disabled");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("rejects a code past redeemBy and allows one still before it", () => {
|
|
40
|
+
expect(evaluatePromoRedeemability(base({ redeemBy: new Date("2026-10-14T11:59:59Z") }))).toBe("expired");
|
|
41
|
+
expect(evaluatePromoRedeemability(base({ redeemBy: new Date("2026-10-14T12:00:01Z") }))).toBeNull();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("counts orders against maxRedemptions: 8 of 10 used leaves room, 10 of 10 is exhausted", () => {
|
|
45
|
+
expect(evaluatePromoRedeemability(base({ maxRedemptions: 10, activeOrderCount: 8 }))).toBeNull();
|
|
46
|
+
expect(evaluatePromoRedeemability(base({ maxRedemptions: 10, activeOrderCount: 10 }))).toBe("exhausted");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("treats maxRedemptions 0 or null as unlimited", () => {
|
|
50
|
+
expect(evaluatePromoRedeemability(base({ maxRedemptions: 0, activeOrderCount: 50 }))).toBeNull();
|
|
51
|
+
expect(evaluatePromoRedeemability(base({ maxRedemptions: null, activeOrderCount: 50 }))).toBeNull();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("applies the per-account limit only when set", () => {
|
|
55
|
+
expect(evaluatePromoRedeemability(base({ maxUsesPerAccount: 1, accountOrderCount: 1 }))).toBe("already_used");
|
|
56
|
+
expect(evaluatePromoRedeemability(base({ maxUsesPerAccount: 2, accountOrderCount: 1 }))).toBeNull();
|
|
57
|
+
expect(evaluatePromoRedeemability(base({ maxUsesPerAccount: null, accountOrderCount: 9 }))).toBeNull();
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
function row(id: string, overrides: Partial<PromoCodeDuplicateCandidate> = {}): PromoCodeDuplicateCandidate {
|
|
62
|
+
return {
|
|
63
|
+
id,
|
|
64
|
+
ticketTierId: `tier_${id}`,
|
|
65
|
+
promoterId: "promoter_row",
|
|
66
|
+
discountAmountInCents: 500,
|
|
67
|
+
discountAmountPercentage: null,
|
|
68
|
+
redeemBy: null,
|
|
69
|
+
maxRedemptions: null,
|
|
70
|
+
bashPointsRewardPerTicket: null,
|
|
71
|
+
...overrides,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
describe("pickPromoCodeAmongDuplicates", () => {
|
|
76
|
+
it("returns the only row", () => {
|
|
77
|
+
expect(pickPromoCodeAmongDuplicates([row("b")])).toEqual({ row: row("b") });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("picks the oldest id among identical rows", () => {
|
|
81
|
+
const result = pickPromoCodeAmongDuplicates([row("c"), row("a"), row("b")]);
|
|
82
|
+
expect("row" in result && result.row.id).toBe("a");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("prefers an identical row whose tier is in the cart", () => {
|
|
86
|
+
const result = pickPromoCodeAmongDuplicates([row("a"), row("b")], ["tier_b"]);
|
|
87
|
+
expect("row" in result && result.row.id).toBe("b");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("falls back to the oldest row when no duplicate tier is in the cart", () => {
|
|
91
|
+
const result = pickPromoCodeAmongDuplicates([row("b"), row("a")], ["tier_z"]);
|
|
92
|
+
expect("row" in result && result.row.id).toBe("a");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it.each([
|
|
96
|
+
["promoter", { promoterId: "someone_else" }],
|
|
97
|
+
["discount", { discountAmountInCents: 700 }],
|
|
98
|
+
["percentage", { discountAmountPercentage: 10, discountAmountInCents: null }],
|
|
99
|
+
["expiry", { redeemBy: new Date("2027-01-01") }],
|
|
100
|
+
["limit", { maxRedemptions: 5 }],
|
|
101
|
+
["BashPoints reward", { bashPointsRewardPerTicket: 25 }],
|
|
102
|
+
])("asks the host to review duplicates that differ on %s", (_label, diff) => {
|
|
103
|
+
expect(pickPromoCodeAmongDuplicates([row("a"), row("b", diff)])).toEqual({
|
|
104
|
+
reason: "host_review_required",
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("throws on an empty list", () => {
|
|
109
|
+
expect(() => pickPromoCodeAmongDuplicates([])).toThrow();
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("reason helpers", () => {
|
|
114
|
+
it("has a guest message for every reason", () => {
|
|
115
|
+
for (const reason of PROMO_CODE_REJECT_REASONS) {
|
|
116
|
+
expect(PROMO_CODE_REJECT_MESSAGES[reason].length).toBeGreaterThan(0);
|
|
117
|
+
}
|
|
118
|
+
expect(PROMO_CODE_REJECT_MESSAGES.host_review_required).toBe(
|
|
119
|
+
"This code needs the host to review it."
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("recognizes reason strings", () => {
|
|
124
|
+
expect(isPromoCodeRejectReason("expired")).toBe(true);
|
|
125
|
+
expect(isPromoCodeRejectReason("nope")).toBe(false);
|
|
126
|
+
expect(isPromoCodeRejectReason(3)).toBe(false);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("normalizes emails", () => {
|
|
130
|
+
expect(normalizePromoRedemptionEmail(" Guest@Example.COM ")).toBe("guest@example.com");
|
|
131
|
+
expect(normalizePromoRedemptionEmail(" ")).toBeNull();
|
|
132
|
+
expect(normalizePromoRedemptionEmail(null)).toBeNull();
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import {
|
|
2
|
+
promoCodeAppliesToTier,
|
|
3
|
+
promoCodeBashEventId,
|
|
4
|
+
promoCodeDiscountKind,
|
|
5
|
+
promoCodeEligibleTierIds,
|
|
6
|
+
getPromoterShareUrl,
|
|
7
|
+
promoterCodeInputErrors,
|
|
8
|
+
} from "../promoCodeScope.js";
|
|
9
|
+
|
|
10
|
+
describe("promoCodeDiscountKind", () => {
|
|
11
|
+
it("uses the explicit type, else derives it from legacy amounts", () => {
|
|
12
|
+
expect(promoCodeDiscountKind({ discountType: "NONE", discountAmountPercentage: 10 })).toBe("NONE");
|
|
13
|
+
expect(promoCodeDiscountKind({ discountAmountPercentage: 10 })).toBe("PERCENT");
|
|
14
|
+
expect(promoCodeDiscountKind({ discountAmountInCents: 500 })).toBe("FIXED_CASH");
|
|
15
|
+
expect(promoCodeDiscountKind({})).toBe("NONE");
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("promoCodeBashEventId", () => {
|
|
20
|
+
it("prefers the row's bash, else the legacy tier's bash", () => {
|
|
21
|
+
expect(promoCodeBashEventId({ bashEventId: "evt_1", ticketTier: { bashEventId: "evt_2" } })).toBe("evt_1");
|
|
22
|
+
expect(promoCodeBashEventId({ ticketTier: { bashEventId: "evt_2" } })).toBe("evt_2");
|
|
23
|
+
expect(promoCodeBashEventId({})).toBeNull();
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("promoCodeEligibleTierIds", () => {
|
|
28
|
+
it("returns ALL for an all-tiers code, so tiers added later qualify", () => {
|
|
29
|
+
const allTiers = { eligibilityMode: "ALL_TIERS" as const, ticketTierId: "ga" };
|
|
30
|
+
expect(promoCodeEligibleTierIds(allTiers)).toBe("ALL");
|
|
31
|
+
expect(promoCodeAppliesToTier(allTiers, "tier_added_later")).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("uses selected tiers, else the legacy tier", () => {
|
|
35
|
+
expect(
|
|
36
|
+
promoCodeEligibleTierIds({
|
|
37
|
+
eligibilityMode: "SELECTED_TIERS",
|
|
38
|
+
eligibleTiers: [{ ticketTierId: "ga" }, { ticketTierId: "vip" }, { ticketTierId: "ga" }],
|
|
39
|
+
})
|
|
40
|
+
).toEqual(["ga", "vip"]);
|
|
41
|
+
expect(promoCodeEligibleTierIds({ ticketTierId: "ga" })).toEqual(["ga"]);
|
|
42
|
+
expect(promoCodeEligibleTierIds({})).toEqual([]);
|
|
43
|
+
expect(promoCodeAppliesToTier({ ticketTierId: "ga" }, "vip")).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("getPromoterShareUrl", () => {
|
|
48
|
+
it("uses the active vanity path when there is one", () => {
|
|
49
|
+
expect(
|
|
50
|
+
getPromoterShareUrl({
|
|
51
|
+
bashEventId: "evt_1",
|
|
52
|
+
slug: "summer-bash",
|
|
53
|
+
vanityPaths: [
|
|
54
|
+
{ segment: "old", status: "Revoked" },
|
|
55
|
+
{ segment: "BashFest", status: "Active" },
|
|
56
|
+
],
|
|
57
|
+
code: " crew 1 ",
|
|
58
|
+
origin: "https://bash.community/",
|
|
59
|
+
})
|
|
60
|
+
).toBe("https://bash.community/bash/bashfest?promo=CREW%201");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("falls back to the id-slug path, never /bashes/", () => {
|
|
64
|
+
const url = getPromoterShareUrl({ bashEventId: "evt_1", slug: "summer-bash", code: "vip" });
|
|
65
|
+
expect(url).toBe("/bash/evt_1-summer-bash?promo=VIP");
|
|
66
|
+
expect(url).not.toContain("/bashes/");
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("promoterCodeInputErrors", () => {
|
|
71
|
+
const ok = {
|
|
72
|
+
code: "CREW",
|
|
73
|
+
discountType: "NONE" as const,
|
|
74
|
+
eligibilityMode: "ALL_TIERS" as const,
|
|
75
|
+
};
|
|
76
|
+
it("accepts an attribution-only all-tiers code", () => {
|
|
77
|
+
expect(promoterCodeInputErrors(ok, ["ga"])).toEqual([]);
|
|
78
|
+
});
|
|
79
|
+
it("validates guest percentage 1-100 separately from promoter commission 0-100", () => {
|
|
80
|
+
expect(promoterCodeInputErrors({ ...ok, discountType: "PERCENT", discountValue: 0 }, [])).toContain(
|
|
81
|
+
"Guest discount must be between 1% and 100%."
|
|
82
|
+
);
|
|
83
|
+
expect(promoterCodeInputErrors({ ...ok, discountType: "PERCENT", discountValue: 100 }, [])).toEqual([]);
|
|
84
|
+
expect(promoterCodeInputErrors({ ...ok, promoterCommissionBps: 0 }, [])).toEqual([]);
|
|
85
|
+
expect(promoterCodeInputErrors({ ...ok, promoterCommissionBps: 10000 }, [])).toEqual([]);
|
|
86
|
+
expect(promoterCodeInputErrors({ ...ok, promoterCommissionBps: 10001 }, [])).toContain(
|
|
87
|
+
"Promoter commission must be between 0% and 100%."
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
it("requires selected tiers on this bash", () => {
|
|
91
|
+
expect(promoterCodeInputErrors({ ...ok, eligibilityMode: "SELECTED_TIERS" }, ["ga"])).toContain(
|
|
92
|
+
"Pick at least one ticket tier."
|
|
93
|
+
);
|
|
94
|
+
expect(
|
|
95
|
+
promoterCodeInputErrors({ ...ok, eligibilityMode: "SELECTED_TIERS", eligibleTierIds: ["other"] }, ["ga"])
|
|
96
|
+
).toContain("A selected tier isn't on this bash.");
|
|
97
|
+
});
|
|
98
|
+
it("blocks saving a promoter code until the reward is reviewed", () => {
|
|
99
|
+
expect(promoterCodeInputErrors({ ...ok, promoterEmail: "p@x.com" }, [])).toContain(
|
|
100
|
+
"Review the promoter reward before saving."
|
|
101
|
+
);
|
|
102
|
+
expect(
|
|
103
|
+
promoterCodeInputErrors({ ...ok, promoterEmail: "p@x.com", promoterRewardReviewed: true }, [])
|
|
104
|
+
).toEqual([]);
|
|
105
|
+
});
|
|
106
|
+
});
|
package/src/definitions.ts
CHANGED
|
@@ -1225,6 +1225,8 @@ export enum ApiErrorType {
|
|
|
1225
1225
|
EventWaiverNotAccepted,
|
|
1226
1226
|
/** Bash is not Published/PreSale. Ticket purchase and checkout creation are blocked */
|
|
1227
1227
|
EventNotAcceptingPurchases,
|
|
1228
|
+
/** Promo code can't be used; `code` carries the PromoCodeRejectReason */
|
|
1229
|
+
PromoCodeRejected,
|
|
1228
1230
|
}
|
|
1229
1231
|
|
|
1230
1232
|
export type ErrorDataType = Record<RecordKey, unknown>;
|
|
@@ -1979,8 +1981,8 @@ const SYNTHETIC_BASH_SECTION_TITLE: Record<string, string> = {
|
|
|
1979
1981
|
Popular: "Popular",
|
|
1980
1982
|
Trending: "Trending",
|
|
1981
1983
|
CrewGoing: "The crew's going",
|
|
1982
|
-
Local: "
|
|
1983
|
-
State: "
|
|
1984
|
+
Local: "City & Community Events",
|
|
1985
|
+
State: "State Events",
|
|
1984
1986
|
National: "Across the country",
|
|
1985
1987
|
International: "Worth the flight",
|
|
1986
1988
|
};
|
package/src/discoveryScope.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* Who puts the event on, for the City & Community and State homepage rows.
|
|
3
|
+
* Nearby events are a location filter, not a value here.
|
|
4
|
+
* National and International stay destination rows.
|
|
5
|
+
* Orthogonal to `BashEventType`.
|
|
4
6
|
*/
|
|
5
7
|
|
|
6
8
|
export const DiscoveryScope = {
|
|
@@ -34,8 +36,8 @@ export const DISCOVERY_SCOPE_SECTION_TITLE: Record<
|
|
|
34
36
|
(typeof DISCOVERY_SCOPE_ROW_KEYS)[number],
|
|
35
37
|
string
|
|
36
38
|
> = {
|
|
37
|
-
Local: "
|
|
38
|
-
State: "
|
|
39
|
+
Local: "City & Community Events",
|
|
40
|
+
State: "State Events",
|
|
39
41
|
National: "Across the country",
|
|
40
42
|
International: "Worth the flight",
|
|
41
43
|
};
|
|
@@ -47,18 +49,6 @@ export const HOST_SETTABLE_DISCOVERY_SCOPES: readonly DiscoveryScope[] = [
|
|
|
47
49
|
DiscoveryScope.State,
|
|
48
50
|
];
|
|
49
51
|
|
|
50
|
-
export const HOST_SCOPE_CHIPS: ReadonlyArray<{
|
|
51
|
-
id: DiscoveryScope;
|
|
52
|
-
label: string;
|
|
53
|
-
}> = [
|
|
54
|
-
{ id: DiscoveryScope.Unspecified, label: "Not sure yet" },
|
|
55
|
-
{ id: DiscoveryScope.Local, label: "Just the neighborhood / city-wide" },
|
|
56
|
-
{
|
|
57
|
-
id: DiscoveryScope.State,
|
|
58
|
-
label: "People will drive from across the state",
|
|
59
|
-
},
|
|
60
|
-
];
|
|
61
|
-
|
|
62
52
|
export function isDiscoveryScope(value: unknown): value is DiscoveryScope {
|
|
63
53
|
return (
|
|
64
54
|
typeof value === "string" &&
|
|
@@ -91,11 +81,27 @@ export const NATIONAL_SCOPE_BRAND_PATTERN =
|
|
|
91
81
|
export const INTERNATIONAL_SCOPE_PATTERN =
|
|
92
82
|
/\b(world tour|world cup|olympics|tomorrowland|glastonbury|primavera sound|oktoberfest|cannes|eurovision|ibiza)\b/i;
|
|
93
83
|
|
|
84
|
+
/** Organizer is a state agency or an official statewide organization. Sponsorship is not enough. */
|
|
94
85
|
export const STATE_SCOPE_PATTERN =
|
|
95
|
-
/\b(state fair|
|
|
86
|
+
/\b(state fair|state agency|state parks|governor'?s? (?:ball|festival))\b/i;
|
|
96
87
|
|
|
88
|
+
/** Organizer is a city, municipality, or local community organization. */
|
|
97
89
|
export const LOCAL_SCOPE_PATTERN =
|
|
98
|
-
/\b(
|
|
90
|
+
/\b(city of|municipality|municipal|parks and recreation|community organization|city council)\b/i;
|
|
91
|
+
|
|
92
|
+
const SPONSORSHIP_ONLY_PATTERN = /\bsponsored by\b/i;
|
|
93
|
+
const ORGANIZER_PATTERN = /\b(organized|hosted|presented|produced) by\b/i;
|
|
94
|
+
|
|
95
|
+
function matchesOrganizerScope(text: string, pattern: RegExp): boolean {
|
|
96
|
+
if (!pattern.test(text)) return false;
|
|
97
|
+
if (
|
|
98
|
+
SPONSORSHIP_ONLY_PATTERN.test(text) &&
|
|
99
|
+
!ORGANIZER_PATTERN.test(text)
|
|
100
|
+
) {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
99
105
|
|
|
100
106
|
export function inferDiscoveryScopeFromText(
|
|
101
107
|
title: string | undefined,
|
|
@@ -109,8 +115,12 @@ export function inferDiscoveryScopeFromText(
|
|
|
109
115
|
if (NATIONAL_SCOPE_BRAND_PATTERN.test(text)) {
|
|
110
116
|
return DiscoveryScope.National;
|
|
111
117
|
}
|
|
112
|
-
if (
|
|
113
|
-
|
|
118
|
+
if (matchesOrganizerScope(text, STATE_SCOPE_PATTERN)) {
|
|
119
|
+
return DiscoveryScope.State;
|
|
120
|
+
}
|
|
121
|
+
if (matchesOrganizerScope(text, LOCAL_SCOPE_PATTERN)) {
|
|
122
|
+
return DiscoveryScope.Local;
|
|
123
|
+
}
|
|
114
124
|
return DiscoveryScope.Unspecified;
|
|
115
125
|
}
|
|
116
126
|
|
package/src/extendedSchemas.ts
CHANGED
|
@@ -672,6 +672,7 @@ type BashEventExtBase = Override<
|
|
|
672
672
|
hideCancellationPolicy?: boolean;
|
|
673
673
|
hideGroupUp?: boolean;
|
|
674
674
|
hideServicesOpenTo?: boolean;
|
|
675
|
+
hideLiveLocation?: boolean;
|
|
675
676
|
/** GET /event/:id. Tickets sold (non-cancelled), for social proof */
|
|
676
677
|
goingCount?: number;
|
|
677
678
|
/** GET /event/:id. True when at least one Budget line has `showOnBashDetailPage` set. Drives the "View budget" reveal on the public bash page; never carries dollar amounts. */
|
package/src/index.ts
CHANGED
|
@@ -50,6 +50,9 @@ export * from "./hostCrmAutomation.js";
|
|
|
50
50
|
export * from "./relationshipHealth.js";
|
|
51
51
|
export * from "./hostCrmConstants.js";
|
|
52
52
|
export * from "./hostPromoter.js";
|
|
53
|
+
export * from "./promoCodeRedemption.js";
|
|
54
|
+
export * from "./promoCodeScope.js";
|
|
55
|
+
export * from "./promoCartEngine.js";
|
|
53
56
|
export * from "./audienceImport.js";
|
|
54
57
|
export * from "./eventStaffRoles.js";
|
|
55
58
|
export * from "./bashEventCancellationPolicy.js";
|