@bash-app/bash-common 30.431.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.
Files changed (68) hide show
  1. package/dist/__tests__/bashSectionTitleForBashType.test.js +2 -0
  2. package/dist/__tests__/bashSectionTitleForBashType.test.js.map +1 -1
  3. package/dist/__tests__/discoveryScope.test.js +9 -1
  4. package/dist/__tests__/discoveryScope.test.js.map +1 -1
  5. package/dist/__tests__/promoCartEngine.test.d.ts +2 -0
  6. package/dist/__tests__/promoCartEngine.test.d.ts.map +1 -0
  7. package/dist/__tests__/promoCartEngine.test.js +174 -0
  8. package/dist/__tests__/promoCartEngine.test.js.map +1 -0
  9. package/dist/__tests__/promoCodeRedemption.test.d.ts +2 -0
  10. package/dist/__tests__/promoCodeRedemption.test.d.ts.map +1 -0
  11. package/dist/__tests__/promoCodeRedemption.test.js +103 -0
  12. package/dist/__tests__/promoCodeRedemption.test.js.map +1 -0
  13. package/dist/__tests__/promoCodeScope.test.d.ts +2 -0
  14. package/dist/__tests__/promoCodeScope.test.d.ts.map +1 -0
  15. package/dist/__tests__/promoCodeScope.test.js +77 -0
  16. package/dist/__tests__/promoCodeScope.test.js.map +1 -0
  17. package/dist/definitions.d.ts +3 -1
  18. package/dist/definitions.d.ts.map +1 -1
  19. package/dist/definitions.js +4 -2
  20. package/dist/definitions.js.map +1 -1
  21. package/dist/discoveryScope.d.ts +6 -6
  22. package/dist/discoveryScope.d.ts.map +1 -1
  23. package/dist/discoveryScope.js +25 -16
  24. package/dist/discoveryScope.js.map +1 -1
  25. package/dist/extendedSchemas.d.ts +1 -0
  26. package/dist/extendedSchemas.d.ts.map +1 -1
  27. package/dist/extendedSchemas.js.map +1 -1
  28. package/dist/index.d.ts +3 -0
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +3 -0
  31. package/dist/index.js.map +1 -1
  32. package/dist/promoCartEngine.d.ts +55 -0
  33. package/dist/promoCartEngine.d.ts.map +1 -0
  34. package/dist/promoCartEngine.js +135 -0
  35. package/dist/promoCartEngine.js.map +1 -0
  36. package/dist/promoCodeRedemption.d.ts +44 -0
  37. package/dist/promoCodeRedemption.d.ts.map +1 -0
  38. package/dist/promoCodeRedemption.js +81 -0
  39. package/dist/promoCodeRedemption.js.map +1 -0
  40. package/dist/promoCodeScope.d.ts +85 -0
  41. package/dist/promoCodeScope.d.ts.map +1 -0
  42. package/dist/promoCodeScope.js +93 -0
  43. package/dist/promoCodeScope.js.map +1 -0
  44. package/dist/utils/__tests__/discountEngine/discountCalculator.test.js +8 -1
  45. package/dist/utils/__tests__/discountEngine/discountCalculator.test.js.map +1 -1
  46. package/dist/utils/__tests__/paymentUtils.test.js +87 -4
  47. package/dist/utils/__tests__/paymentUtils.test.js.map +1 -1
  48. package/dist/utils/paymentUtils.d.ts +30 -5
  49. package/dist/utils/paymentUtils.d.ts.map +1 -1
  50. package/dist/utils/paymentUtils.js +43 -11
  51. package/dist/utils/paymentUtils.js.map +1 -1
  52. package/package.json +7 -3
  53. package/prisma/schema.prisma +106 -4
  54. package/src/__tests__/bashSectionTitleForBashType.test.ts +4 -0
  55. package/src/__tests__/discoveryScope.test.ts +22 -1
  56. package/src/__tests__/promoCartEngine.test.ts +201 -0
  57. package/src/__tests__/promoCodeRedemption.test.ts +134 -0
  58. package/src/__tests__/promoCodeScope.test.ts +106 -0
  59. package/src/definitions.ts +4 -2
  60. package/src/discoveryScope.ts +30 -20
  61. package/src/extendedSchemas.ts +1 -0
  62. package/src/index.ts +3 -0
  63. package/src/promoCartEngine.ts +193 -0
  64. package/src/promoCodeRedemption.ts +123 -0
  65. package/src/promoCodeScope.ts +164 -0
  66. package/src/utils/__tests__/discountEngine/discountCalculator.test.ts +8 -0
  67. package/src/utils/__tests__/paymentUtils.test.ts +100 -6
  68. package/src/utils/paymentUtils.ts +56 -13
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Multi-tier promo engine (pure). Decides, per cart line, whether a code applies
3
+ * and how much cash and BashPoints it takes off, with a deterministic fixed-cash
4
+ * allocation that the API persists so refunds and reports reproduce exactly.
5
+ */
6
+ import {
7
+ promoCodeAppliesToTier,
8
+ promoCodeDiscountKind,
9
+ type PromoDiscountKind,
10
+ type PromoScopeFields,
11
+ } from "./promoCodeScope.js";
12
+
13
+ export type PromoCartLinePricing = "USD" | "BASHPOINTS" | "FREE";
14
+
15
+ export type PromoCartLine = {
16
+ ticketTierId: string;
17
+ pricing: PromoCartLinePricing;
18
+ quantity: number;
19
+ /** Cash subtotal for the line after special offers, in cents. */
20
+ cashSubtotalCents: number;
21
+ /** BashPoints subtotal for the line. */
22
+ pointsSubtotal: number;
23
+ };
24
+
25
+ export type PromoForCart = PromoScopeFields & {
26
+ discountAppliesToBashPoints?: boolean | null;
27
+ redeemBy?: Date | null;
28
+ disabledAt?: Date | null;
29
+ };
30
+
31
+ export type PromoCartLineResult = {
32
+ ticketTierId: string;
33
+ eligible: boolean;
34
+ cashDiscountCents: number;
35
+ pointsDiscount: number;
36
+ finalCashCents: number;
37
+ finalPoints: number;
38
+ attributedTicketCount: number;
39
+ };
40
+
41
+ export type PromoCartResult = {
42
+ status: "applied" | "not_applicable" | "expired" | "disabled";
43
+ discountKind: PromoDiscountKind;
44
+ lines: PromoCartLineResult[];
45
+ eligibleTierIds: string[];
46
+ cashDiscountTotalCents: number;
47
+ pointsDiscountTotal: number;
48
+ attributedTicketCount: number;
49
+ };
50
+
51
+ function clampPercent(value: number | null | undefined): number {
52
+ const pct = Math.floor(Number(value) || 0);
53
+ return Math.max(0, Math.min(100, pct));
54
+ }
55
+
56
+ /**
57
+ * Split `amount` across `weights` in proportion, flooring each share, then give
58
+ * leftover units one at a time to the largest weight first, ties by key ascending.
59
+ */
60
+ export function allocateProportionally(
61
+ amount: number,
62
+ items: ReadonlyArray<{ key: string; weight: number }>
63
+ ): Map<string, number> {
64
+ const result = new Map<string, number>();
65
+ const total = items.reduce((s, i) => s + Math.max(0, i.weight), 0);
66
+ const whole = Math.max(0, Math.floor(amount));
67
+ if (total <= 0 || whole <= 0) {
68
+ for (const i of items) result.set(i.key, 0);
69
+ return result;
70
+ }
71
+ const capped = Math.min(whole, total);
72
+ let assigned = 0;
73
+ for (const i of items) {
74
+ const share = Math.floor((capped * Math.max(0, i.weight)) / total);
75
+ result.set(i.key, share);
76
+ assigned += share;
77
+ }
78
+ // Fractional parts sum to fewer units than there are lines, so one pass suffices.
79
+ let leftover = capped - assigned;
80
+ const order = [...items]
81
+ .filter((i) => i.weight > 0)
82
+ .sort((a, b) => b.weight - a.weight || (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
83
+ for (const i of order) {
84
+ if (leftover <= 0) break;
85
+ result.set(i.key, (result.get(i.key) ?? 0) + 1);
86
+ leftover--;
87
+ }
88
+ return result;
89
+ }
90
+
91
+ /** Per-ticket split of a line amount: equal shares, leftover cents to tickets in id order. */
92
+ export function allocateAcrossTickets(
93
+ lineAmount: number,
94
+ ticketIds: readonly string[]
95
+ ): Map<string, number> {
96
+ const sorted = [...ticketIds].sort();
97
+ const result = new Map<string, number>();
98
+ const amount = Math.max(0, Math.floor(lineAmount));
99
+ if (sorted.length === 0) return result;
100
+ const base = Math.floor(amount / sorted.length);
101
+ let leftover = amount - base * sorted.length;
102
+ for (const id of sorted) {
103
+ result.set(id, base + (leftover > 0 ? 1 : 0));
104
+ if (leftover > 0) leftover--;
105
+ }
106
+ return result;
107
+ }
108
+
109
+ export function resolvePromoForCart(args: {
110
+ promo: PromoForCart;
111
+ cartLines: readonly PromoCartLine[];
112
+ now: Date;
113
+ }): PromoCartResult {
114
+ const { promo, cartLines, now } = args;
115
+ const kind = promoCodeDiscountKind(promo);
116
+ const empty = (status: PromoCartResult["status"]): PromoCartResult => ({
117
+ status,
118
+ discountKind: kind,
119
+ lines: cartLines.map((l) => ({
120
+ ticketTierId: l.ticketTierId,
121
+ eligible: false,
122
+ cashDiscountCents: 0,
123
+ pointsDiscount: 0,
124
+ finalCashCents: Math.max(0, l.cashSubtotalCents),
125
+ finalPoints: Math.max(0, l.pointsSubtotal),
126
+ attributedTicketCount: 0,
127
+ })),
128
+ eligibleTierIds: [],
129
+ cashDiscountTotalCents: 0,
130
+ pointsDiscountTotal: 0,
131
+ attributedTicketCount: 0,
132
+ });
133
+ if (promo.disabledAt) return empty("disabled");
134
+ if (promo.redeemBy && promo.redeemBy.getTime() < now.getTime()) return empty("expired");
135
+
136
+ const eligibleSet = new Set(
137
+ cartLines.filter((l) => promoCodeAppliesToTier(promo, l.ticketTierId)).map((l) => l.ticketTierId)
138
+ );
139
+ if (eligibleSet.size === 0) return empty("not_applicable");
140
+
141
+ const pct = clampPercent(promo.discountAmountPercentage);
142
+ const fixed = Math.max(0, Math.floor(promo.discountAmountInCents ?? 0));
143
+ const pointsAllowed = promo.discountAppliesToBashPoints === true;
144
+
145
+ const fixedAllocation =
146
+ kind === "FIXED_CASH"
147
+ ? allocateProportionally(
148
+ fixed,
149
+ cartLines
150
+ .filter((l) => eligibleSet.has(l.ticketTierId) && l.pricing === "USD")
151
+ .map((l) => ({ key: l.ticketTierId, weight: Math.max(0, l.cashSubtotalCents) }))
152
+ )
153
+ : new Map<string, number>();
154
+
155
+ const lines = cartLines.map((l): PromoCartLineResult => {
156
+ const eligible = eligibleSet.has(l.ticketTierId);
157
+ const cash = Math.max(0, l.cashSubtotalCents);
158
+ const points = Math.max(0, l.pointsSubtotal);
159
+ let cashDiscount = 0;
160
+ let pointsDiscount = 0;
161
+ if (eligible) {
162
+ if (kind === "PERCENT") {
163
+ if (l.pricing === "USD") cashDiscount = Math.floor((cash * pct) / 100);
164
+ if (l.pricing === "BASHPOINTS" && pointsAllowed) {
165
+ pointsDiscount = Math.floor((points * pct) / 100);
166
+ }
167
+ } else if (kind === "FIXED_CASH" && l.pricing === "USD") {
168
+ cashDiscount = fixedAllocation.get(l.ticketTierId) ?? 0;
169
+ }
170
+ }
171
+ cashDiscount = Math.min(cashDiscount, cash);
172
+ pointsDiscount = Math.min(pointsDiscount, points);
173
+ return {
174
+ ticketTierId: l.ticketTierId,
175
+ eligible,
176
+ cashDiscountCents: cashDiscount,
177
+ pointsDiscount,
178
+ finalCashCents: cash - cashDiscount,
179
+ finalPoints: points - pointsDiscount,
180
+ attributedTicketCount: eligible ? Math.max(0, l.quantity) : 0,
181
+ };
182
+ });
183
+
184
+ return {
185
+ status: "applied",
186
+ discountKind: kind,
187
+ lines,
188
+ eligibleTierIds: [...eligibleSet],
189
+ cashDiscountTotalCents: lines.reduce((s, l) => s + l.cashDiscountCents, 0),
190
+ pointsDiscountTotal: lines.reduce((s, l) => s + l.pointsDiscount, 0),
191
+ attributedTicketCount: lines.reduce((s, l) => s + l.attributedTicketCount, 0),
192
+ };
193
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Promo code redemption rules shared by the API and the app.
3
+ * The API owns the ledger; these helpers are pure.
4
+ */
5
+
6
+ export const PROMO_CODE_REJECT_REASONS = [
7
+ "expired",
8
+ "exhausted",
9
+ "already_used",
10
+ "disabled",
11
+ "invalid",
12
+ "host_review_required",
13
+ ] as const;
14
+
15
+ export type PromoCodeRejectReason = (typeof PROMO_CODE_REJECT_REASONS)[number];
16
+
17
+ /** Guest-facing copy. Plain and specific: this is about money. */
18
+ export const PROMO_CODE_REJECT_MESSAGES: Record<PromoCodeRejectReason, string> = {
19
+ expired: "This code has expired.",
20
+ exhausted: "This code has reached its order limit.",
21
+ already_used: "You've already used this code as many times as it allows.",
22
+ disabled: "This code is no longer active.",
23
+ invalid: "We couldn't find that code for this bash.",
24
+ host_review_required: "This code needs the host to review it.",
25
+ };
26
+
27
+ export function isPromoCodeRejectReason(value: unknown): value is PromoCodeRejectReason {
28
+ return (
29
+ typeof value === "string" &&
30
+ (PROMO_CODE_REJECT_REASONS as readonly string[]).includes(value)
31
+ );
32
+ }
33
+
34
+ export type PromoRedeemabilityInput = {
35
+ redeemBy: Date | null | undefined;
36
+ disabledAt?: Date | null;
37
+ maxRedemptions: number | null | undefined;
38
+ maxUsesPerAccount?: number | null;
39
+ /** Consumed plus unexpired Reserved orders for the code. */
40
+ activeOrderCount: number;
41
+ /** Same count for the buyer's account (user id, else normalized email). */
42
+ accountOrderCount: number;
43
+ now: Date;
44
+ };
45
+
46
+ /** First failing rule, or null when the code can be used for one more order. */
47
+ export function evaluatePromoRedeemability(
48
+ input: PromoRedeemabilityInput
49
+ ): PromoCodeRejectReason | null {
50
+ if (input.disabledAt) return "disabled";
51
+ if (input.redeemBy && input.redeemBy.getTime() < input.now.getTime()) {
52
+ return "expired";
53
+ }
54
+ if (
55
+ input.maxRedemptions != null &&
56
+ input.maxRedemptions > 0 &&
57
+ input.activeOrderCount >= input.maxRedemptions
58
+ ) {
59
+ return "exhausted";
60
+ }
61
+ if (
62
+ input.maxUsesPerAccount != null &&
63
+ input.maxUsesPerAccount > 0 &&
64
+ input.accountOrderCount >= input.maxUsesPerAccount
65
+ ) {
66
+ return "already_used";
67
+ }
68
+ return null;
69
+ }
70
+
71
+ export type PromoCodeDuplicateCandidate = {
72
+ id: string;
73
+ ticketTierId: string | null;
74
+ promoterId: string | null;
75
+ discountAmountInCents: number | null;
76
+ discountAmountPercentage: number | null;
77
+ redeemBy: Date | null;
78
+ maxRedemptions: number | null;
79
+ bashPointsRewardPerTicket: number | null;
80
+ };
81
+
82
+ function duplicateSignature(row: PromoCodeDuplicateCandidate): string {
83
+ return JSON.stringify([
84
+ row.promoterId ?? null,
85
+ row.discountAmountInCents ?? null,
86
+ row.discountAmountPercentage ?? null,
87
+ row.redeemBy ? new Date(row.redeemBy).getTime() : null,
88
+ row.maxRedemptions ?? null,
89
+ row.bashPointsRewardPerTicket ?? null,
90
+ ]);
91
+ }
92
+
93
+ /**
94
+ * Legacy codes are unique per tier, so one bash can hold several rows with the
95
+ * same code. Identical rows resolve to the oldest id, preferring a row whose tier
96
+ * is in the cart. Rows that differ need the host to review them.
97
+ */
98
+ export function pickPromoCodeAmongDuplicates<T extends PromoCodeDuplicateCandidate>(
99
+ rows: readonly T[],
100
+ cartTierIds?: readonly string[]
101
+ ): { row: T } | { reason: "host_review_required" } {
102
+ if (rows.length === 0) {
103
+ throw new Error("pickPromoCodeAmongDuplicates needs at least one row");
104
+ }
105
+ if (rows.length === 1) return { row: rows[0] };
106
+ const signature = duplicateSignature(rows[0]);
107
+ if (rows.some((r) => duplicateSignature(r) !== signature)) {
108
+ return { reason: "host_review_required" };
109
+ }
110
+ const inCart = cartTierIds?.length
111
+ ? rows.filter((r) => r.ticketTierId && cartTierIds.includes(r.ticketTierId))
112
+ : [];
113
+ const pool = inCart.length ? inCart : rows;
114
+ const oldest = [...pool].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))[0];
115
+ return { row: oldest };
116
+ }
117
+
118
+ export function normalizePromoRedemptionEmail(
119
+ email: string | null | undefined
120
+ ): string | null {
121
+ const trimmed = email?.trim().toLowerCase() ?? "";
122
+ return trimmed ? trimmed : null;
123
+ }
@@ -0,0 +1,164 @@
1
+ import { getPublicBashDetailPath } from "./utils/slugUtils.js";
2
+
3
+ /**
4
+ * Which bash and tiers a promo code applies to, and what kind of discount it
5
+ * gives, for both legacy single-tier rows and bash-level rows. Every reader goes
6
+ * through these instead of `promo.ticketTier.bashEventId` or `promo.ticketTierId`.
7
+ */
8
+
9
+ export type PromoDiscountKind = "NONE" | "PERCENT" | "FIXED_CASH";
10
+ export type PromoEligibilityMode = "ALL_TIERS" | "SELECTED_TIERS";
11
+
12
+ export type PromoScopeFields = {
13
+ ticketTierId?: string | null;
14
+ bashEventId?: string | null;
15
+ eligibilityMode?: PromoEligibilityMode | null;
16
+ discountType?: PromoDiscountKind | null;
17
+ discountAmountInCents?: number | null;
18
+ discountAmountPercentage?: number | null;
19
+ eligibleTiers?: ReadonlyArray<{ ticketTierId: string }> | null;
20
+ ticketTier?: { bashEventId: string } | null;
21
+ };
22
+
23
+ /** Explicit type wins; legacy rows derive it from whichever amount is set. */
24
+ export function promoCodeDiscountKind(promo: PromoScopeFields): PromoDiscountKind {
25
+ if (promo.discountType) return promo.discountType;
26
+ if ((promo.discountAmountPercentage ?? 0) > 0) return "PERCENT";
27
+ if ((promo.discountAmountInCents ?? 0) > 0) return "FIXED_CASH";
28
+ return "NONE";
29
+ }
30
+
31
+ export function promoCodeBashEventId(promo: PromoScopeFields): string | null {
32
+ return promo.bashEventId ?? promo.ticketTier?.bashEventId ?? null;
33
+ }
34
+
35
+ /**
36
+ * "ALL" for an all-tiers code (including tiers added later); otherwise the
37
+ * selected tier ids, falling back to the legacy single tier.
38
+ */
39
+ export function promoCodeEligibleTierIds(promo: PromoScopeFields): "ALL" | string[] {
40
+ if (promo.eligibilityMode === "ALL_TIERS") return "ALL";
41
+ const selected = (promo.eligibleTiers ?? []).map((t) => t.ticketTierId);
42
+ if (selected.length > 0) return [...new Set(selected)];
43
+ return promo.ticketTierId ? [promo.ticketTierId] : [];
44
+ }
45
+
46
+ export function promoCodeAppliesToTier(promo: PromoScopeFields, ticketTierId: string): boolean {
47
+ const ids = promoCodeEligibleTierIds(promo);
48
+ return ids === "ALL" || ids.includes(ticketTierId);
49
+ }
50
+
51
+ export type PromoCodeStatusReason =
52
+ | "expired"
53
+ | "exhausted"
54
+ | "already_used"
55
+ | "disabled"
56
+ | "invalid"
57
+ | "host_review_required";
58
+
59
+ /** Public, logged-out view of a code. Never includes a promoter id or email. */
60
+ export type PromoCodeStatus = {
61
+ status: "valid" | PromoCodeStatusReason;
62
+ reason: PromoCodeStatusReason | null;
63
+ code: string;
64
+ discountType: PromoDiscountKind | null;
65
+ /** Percent for PERCENT, cents for FIXED_CASH, 0 for NONE. */
66
+ value: number;
67
+ eligibilityMode: PromoEligibilityMode | null;
68
+ /** Empty for all-tiers codes. */
69
+ eligibleTierIds: string[];
70
+ appliesToBashPoints: boolean;
71
+ };
72
+
73
+ /**
74
+ * Promoter share link: `/bash/<active vanity>?promo=CODE`, else the public
75
+ * id-slug path. The code is uppercased and URL-encoded. Legacy `?promocode=`
76
+ * and `?ref=` links keep working on the bash page.
77
+ */
78
+ export function getPromoterShareUrl(args: {
79
+ bashEventId: string;
80
+ slug?: string | null;
81
+ vanityPaths?: ReadonlyArray<{ segment: string; status: string }> | null;
82
+ code: string;
83
+ /** Absolute origin, e.g. "https://bash.community". Omit for a relative path. */
84
+ origin?: string;
85
+ }): string {
86
+ const active = args.vanityPaths?.find((v) => v.status === "Active" && v.segment.trim());
87
+ const path = active
88
+ ? `/bash/${encodeURIComponent(active.segment.trim().toLowerCase())}`
89
+ : getPublicBashDetailPath(args.bashEventId, args.slug);
90
+ const query = `?promo=${encodeURIComponent(args.code.trim().toUpperCase())}`;
91
+ const origin = args.origin?.replace(/\/$/, "") ?? "";
92
+ return `${origin}${path}${query}`;
93
+ }
94
+
95
+ /** Host form payload for a bash-level promoter or promo code. */
96
+ export type PromoterCodeInput = {
97
+ code: string;
98
+ promoterUserId?: string | null;
99
+ promoterEmail?: string | null;
100
+ discountType: PromoDiscountKind;
101
+ /** Percent (1-100) for PERCENT, cents for FIXED_CASH, ignored for NONE. */
102
+ discountValue?: number | null;
103
+ eligibilityMode: PromoEligibilityMode;
104
+ eligibleTierIds?: string[];
105
+ discountAppliesToBashPoints?: boolean;
106
+ redeemBy?: string | null;
107
+ /** Maximum orders. */
108
+ maxRedemptions?: number | null;
109
+ maxUsesPerAccount?: number | null;
110
+ bashPointsRewardPerTicket?: number | null;
111
+ /** The promoter's rate for this bash, 0-10000 bps of eligible net ticket proceeds. */
112
+ promoterCommissionBps?: number | null;
113
+ /** The host opened and confirmed the Promoter reward section. */
114
+ promoterRewardReviewed?: boolean;
115
+ };
116
+
117
+ export type PromoterCodeStatusLabel = "active" | "expired" | "exhausted" | "disabled";
118
+
119
+ /** Validation shared by the host form and the API. Empty array means valid. */
120
+ export function promoterCodeInputErrors(
121
+ input: PromoterCodeInput,
122
+ bashTierIds: readonly string[]
123
+ ): string[] {
124
+ const errors: string[] = [];
125
+ const code = input.code?.trim() ?? "";
126
+ if (!code) errors.push("Enter a code.");
127
+ else if (!/^[A-Za-z0-9_-]{2,32}$/.test(code)) {
128
+ errors.push("Codes use 2-32 letters, numbers, dashes, or underscores.");
129
+ }
130
+ if (input.discountType === "PERCENT") {
131
+ const pct = Number(input.discountValue);
132
+ if (!Number.isInteger(pct) || pct < 1 || pct > 100) {
133
+ errors.push("Guest discount must be between 1% and 100%.");
134
+ }
135
+ } else if (input.discountType === "FIXED_CASH") {
136
+ const cents = Number(input.discountValue);
137
+ if (!Number.isInteger(cents) || cents <= 0) errors.push("Enter a fixed discount above $0.");
138
+ }
139
+ if (input.eligibilityMode === "SELECTED_TIERS") {
140
+ const ids = input.eligibleTierIds ?? [];
141
+ if (ids.length === 0) errors.push("Pick at least one ticket tier.");
142
+ if (ids.some((id) => !bashTierIds.includes(id))) errors.push("A selected tier isn't on this bash.");
143
+ }
144
+ if (input.promoterCommissionBps != null) {
145
+ const bps = input.promoterCommissionBps;
146
+ if (!Number.isInteger(bps) || bps < 0 || bps > 10_000) {
147
+ errors.push("Promoter commission must be between 0% and 100%.");
148
+ }
149
+ }
150
+ for (const [label, value] of [
151
+ ["Maximum orders", input.maxRedemptions],
152
+ ["Per-guest limit", input.maxUsesPerAccount],
153
+ ["BashPoints per ticket", input.bashPointsRewardPerTicket],
154
+ ] as const) {
155
+ if (value != null && (!Number.isInteger(value) || value < 0)) {
156
+ errors.push(`${label} must be a whole number of 0 or more.`);
157
+ }
158
+ }
159
+ const hasPromoter = !!(input.promoterUserId || input.promoterEmail?.trim());
160
+ if (hasPromoter && !input.promoterRewardReviewed) {
161
+ errors.push("Review the promoter reward before saving.");
162
+ }
163
+ return errors;
164
+ }
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  OfferDiscountType,
3
+ PromoCodeEligibilityMode,
3
4
  SpecialOffer,
4
5
  SpecialOfferType,
5
6
  } from "@prisma/client";
@@ -23,10 +24,17 @@ function basePromoCode(
23
24
  Pick<BashEventPromoCodeWithPromoter, "id" | "code">
24
25
  ): BashEventPromoCodeWithPromoter {
25
26
  return {
27
+ bashEventId: null,
26
28
  ticketTierId: "tier-1",
29
+ eligibilityMode: PromoCodeEligibilityMode.SELECTED_TIERS,
30
+ discountType: null,
31
+ discountAppliesToBashPoints: false,
32
+ disabledAt: null,
33
+ legacyReviewFlag: null,
27
34
  discountAmountInCents: null,
28
35
  discountAmountPercentage: null,
29
36
  maxRedemptions: null,
37
+ maxUsesPerAccount: null,
30
38
  redeemBy: null,
31
39
  promoterId: null,
32
40
  stripeCouponId: null,
@@ -23,7 +23,9 @@ import {
23
23
  computeTicketApplicationFeeAmountCents,
24
24
  computeGuestTicketCheckoutBreakdown,
25
25
  ticketCommissionCents,
26
- clampTicketCommissionBps,
26
+ eligibleNetTicketProceedsCents,
27
+ ticketCommissionBpsError,
28
+ TEMPORARY_SETTLEMENT_SAFETY_REMAINDER_CENTS,
27
29
  MAX_TICKET_COMMISSION_BPS,
28
30
  guestAllInUnitPriceCents,
29
31
  guestCheckoutHasNoAddedFees,
@@ -282,13 +284,105 @@ describe("PaymentUtils - Core Functions", () => {
282
284
  expect(r.applicationFeeAmountCents).toBe(r.stripeProcessingCents);
283
285
  });
284
286
 
285
- test("ticketCommissionCents floors bps of the subtotal and caps at 50%", () => {
287
+ test("ticketCommissionCents floors bps of the basis and pays up to 100%", () => {
288
+ expect(MAX_TICKET_COMMISSION_BPS).toBe(10000);
286
289
  expect(ticketCommissionCents(10000, 1500)).toBe(1500);
287
290
  expect(ticketCommissionCents(10000, 0)).toBe(0);
288
- expect(ticketCommissionCents(10000, MAX_TICKET_COMMISSION_BPS + 100)).toBe(
289
- 5000
290
- );
291
- expect(clampTicketCommissionBps(99999)).toBe(MAX_TICKET_COMMISSION_BPS);
291
+ expect(ticketCommissionCents(1912, 10000)).toBe(1912);
292
+ expect(ticketCommissionCents(1912, 5000)).toBe(956);
293
+ expect(ticketCommissionCents(1912, 99999)).toBe(1912);
294
+ expect(ticketCommissionCents(0, 10000)).toBe(0);
295
+ });
296
+
297
+ test("ticketCommissionBpsError accepts 0-100% and rejects the rest", () => {
298
+ expect(ticketCommissionBpsError(0)).toBeNull();
299
+ expect(ticketCommissionBpsError(5000)).toBeNull();
300
+ expect(ticketCommissionBpsError(10000)).toBeNull();
301
+ expect(ticketCommissionBpsError(null)).toBeNull();
302
+ expect(ticketCommissionBpsError(10001)).toMatch(/between 0% and 100%/);
303
+ expect(ticketCommissionBpsError(-1)).toMatch(/between 0% and 100%/);
304
+ expect(ticketCommissionBpsError(12.5)).toMatch(/whole number/);
305
+ });
306
+
307
+ describe("eligibleNetTicketProceedsCents: $20 sale", () => {
308
+ const fees = (feeHandling: string, platformFeeEnabled: boolean) =>
309
+ computeTicketApplicationFeeAmountCents({
310
+ discountedSubtotalCents: 2000,
311
+ platformFeeEnabled,
312
+ platformFeeRate: 0.03,
313
+ feeHandling,
314
+ bashPassWaivesGuestFee: false,
315
+ });
316
+ const net = (f: ReturnType<typeof fees>, eligible = 2000) =>
317
+ eligibleNetTicketProceedsCents({
318
+ eligibleSubtotalCents: eligible,
319
+ discountedSubtotalCents: 2000,
320
+ bashPlatformTotalCents: f.bashPlatformTotalCents,
321
+ guestPlatformLineCents: f.guestPlatformLineCents,
322
+ stripeProcessingCents: f.stripeProcessingCents,
323
+ guestProcessingLineCents: f.guestProcessingLineCents,
324
+ });
325
+
326
+ test("host absorbs, platform fee off: 2000 - 88 = 1912; 100% owes 1912, 50% owes 956", () => {
327
+ const f = fees("HostAbsorbs", false);
328
+ expect(f.applicationFeeAmountCents).toBe(88);
329
+ expect(net(f)).toBe(1912);
330
+ expect(ticketCommissionCents(net(f), 10000)).toBe(1912);
331
+ expect(ticketCommissionCents(net(f), 5000)).toBe(956);
332
+ });
333
+
334
+ test("host absorbs a 3% platform fee: app fee 148, net 1852", () => {
335
+ const f = fees("HostAbsorbs", true);
336
+ expect(f.applicationFeeAmountCents).toBe(148);
337
+ expect(net(f)).toBe(1852);
338
+ expect(ticketCommissionCents(net(f), 5000)).toBe(926);
339
+ });
340
+
341
+ test("guest pays the fees: the guest lines cover them, so the host share is close to the subtotal", () => {
342
+ const f = fees("GuestPays", true);
343
+ const hostShare =
344
+ Math.max(0, f.bashPlatformTotalCents - f.guestPlatformLineCents) +
345
+ Math.max(0, f.stripeProcessingCents - f.guestProcessingLineCents);
346
+ expect(net(f)).toBe(2000 - hostShare);
347
+ expect(net(f)).toBeGreaterThan(1900);
348
+ });
349
+
350
+ test("only the code's tier counts: a $20 eligible line in a $40 cart carries half the fees", () => {
351
+ const both = computeTicketApplicationFeeAmountCents({
352
+ discountedSubtotalCents: 4000,
353
+ platformFeeEnabled: false,
354
+ platformFeeRate: 0.03,
355
+ feeHandling: "HostAbsorbs",
356
+ bashPassWaivesGuestFee: false,
357
+ });
358
+ const eligible = eligibleNetTicketProceedsCents({
359
+ eligibleSubtotalCents: 2000,
360
+ discountedSubtotalCents: 4000,
361
+ bashPlatformTotalCents: both.bashPlatformTotalCents,
362
+ guestPlatformLineCents: both.guestPlatformLineCents,
363
+ stripeProcessingCents: both.stripeProcessingCents,
364
+ guestProcessingLineCents: both.guestProcessingLineCents,
365
+ });
366
+ expect(eligible).toBe(2000 - Math.ceil(both.stripeProcessingCents / 2));
367
+ });
368
+
369
+ test("zero eligible proceeds owe nothing", () => {
370
+ expect(net(fees("HostAbsorbs", false), 0)).toBe(0);
371
+ });
372
+ });
373
+
374
+ test("withholding can take the full eligible net when the safety remainder is 0", () => {
375
+ expect(TEMPORARY_SETTLEMENT_SAFETY_REMAINDER_CENTS).toBe(0);
376
+ const r = computeTicketApplicationFeeAmountCents({
377
+ discountedSubtotalCents: 2000,
378
+ platformFeeEnabled: false,
379
+ platformFeeRate: 0.03,
380
+ feeHandling: "HostAbsorbs",
381
+ bashPassWaivesGuestFee: false,
382
+ performerCommissionCents: 1912,
383
+ });
384
+ expect(r.performerCommissionCents).toBe(1912);
385
+ expect(r.totalChargedCents - r.applicationFeeAmountCents).toBe(0);
292
386
  });
293
387
 
294
388
  test("PROMO-002: performer cut is withheld on the application fee, not the guest total", () => {