@tribe-nest/forge 3.2.0 → 3.4.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/package.json +1 -1
- package/src/data/queries/useCheckouts.ts +84 -1
- package/src/data/queries/useCoachingAvailability.ts +18 -3
- package/src/data/queries/useCourses.ts +42 -1
- package/src/data/queries/useEvents.ts +15 -1
- package/src/data/queries/usePaymentFlow.ts +12 -0
- package/src/data/queries/useWebsite.ts +6 -0
- package/src/index.ts +9 -0
- package/src/types/models.ts +66 -0
- package/src/ui/headless/calendar/useAddToCalendar.ts +194 -0
- package/src/ui/headless/checkout/_tests/bundleCoupon.spec.ts +169 -0
- package/src/ui/headless/checkout/bundleCoupon.ts +96 -0
- package/src/ui/headless/checkout/useCheckout.ts +156 -8
- package/src/ui/headless/coaching/useCoachingBooking.ts +53 -3
- package/src/ui/headless/coupon/_tests/couponFailureMessage.spec.ts +84 -0
- package/src/ui/headless/coupon/useCouponField.ts +164 -0
- package/src/ui/headless/course/useCourseCheckout.ts +113 -18
- package/src/ui/headless/event/useEventCheckout.ts +53 -2
- package/src/ui/headless/index.ts +15 -0
- package/src/ui/index.ts +7 -0
- package/src/ui/shell/PoweredBy.tsx +60 -0
- package/src/ui/shell/TribeNestApp.tsx +15 -1
- package/src/ui/shell/shellGating.spec.ts +21 -1
- package/src/ui/shell/shellGating.ts +14 -0
- package/src/ui/styled/AddToCalendar.tsx +104 -0
- package/src/ui/styled/Checkout.tsx +45 -14
- package/src/ui/styled/CoachingBooking.tsx +28 -8
- package/src/ui/styled/CoachingConfirmation.tsx +12 -0
- package/src/ui/styled/CourseCheckout.tsx +49 -18
- package/src/ui/styled/DiscountCode.tsx +206 -0
- package/src/ui/styled/EventConfirmation.tsx +68 -22
- package/src/ui/styled/EventDetail.tsx +18 -5
- package/src/ui/styled/EventTickets.tsx +49 -5
- package/src/ui/styled/_tests/DiscountCode.spec.tsx +272 -0
- package/src/ui/styled/_tests/EventConfirmation.spec.tsx +154 -0
- package/src/utils/_tests/ticketOrderOutcome.spec.ts +126 -0
- package/src/utils/ticketOrderOutcome.ts +125 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
3
|
+
import { ForgeThemeProvider } from "../../theme/ForgeThemeProvider";
|
|
4
|
+
import { DiscountCodeField, DiscountSummaryLines } from "../DiscountCode";
|
|
5
|
+
import type { CouponField } from "../../headless/coupon/useCouponField";
|
|
6
|
+
import type { PillarDiscountQuote } from "../../../types/models";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Rendered through `react-dom/server`, which needs no DOM — so these run in the
|
|
10
|
+
* package's existing node vitest environment with no new dependency and no
|
|
11
|
+
* jsdom project. Effects never fire under `renderToStaticMarkup`, so the
|
|
12
|
+
* `CouponField` fixture is the only data source and nothing reaches the network.
|
|
13
|
+
*
|
|
14
|
+
* `DiscountCodeField` and `DiscountSummaryLines` are the REAL components every
|
|
15
|
+
* ticket, booking, course and (client-app) checkout renders, so these assertions
|
|
16
|
+
* are about shipped behaviour, not a harness.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const fmt = (n: number) => `$${n.toFixed(2)}`;
|
|
20
|
+
|
|
21
|
+
/** A `CouponField` in a given state. The setters are inert — nothing clicks here. */
|
|
22
|
+
function field(overrides: Partial<CouponField> = {}): CouponField {
|
|
23
|
+
const quote = overrides.quote ?? null;
|
|
24
|
+
return {
|
|
25
|
+
code: "",
|
|
26
|
+
setCode: () => {},
|
|
27
|
+
submittedCode: undefined,
|
|
28
|
+
showInput: false,
|
|
29
|
+
setShowInput: () => {},
|
|
30
|
+
error: null,
|
|
31
|
+
setError: () => {},
|
|
32
|
+
isApplying: false,
|
|
33
|
+
quote,
|
|
34
|
+
appliedCoupons: quote?.appliedCoupons ?? [],
|
|
35
|
+
discountAmount: quote?.discountAmount ?? 0,
|
|
36
|
+
hasDiscount: (quote?.discountAmount ?? 0) > 0,
|
|
37
|
+
apply: async () => {},
|
|
38
|
+
remove: async () => {},
|
|
39
|
+
record: () => {},
|
|
40
|
+
fail: () => {},
|
|
41
|
+
reset: () => {},
|
|
42
|
+
canQuote: true,
|
|
43
|
+
...overrides,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** What the server returns once SUMMER10 has been applied to a $50 course. */
|
|
48
|
+
const appliedQuote: PillarDiscountQuote = {
|
|
49
|
+
subTotal: 50,
|
|
50
|
+
totalAmount: 42.5,
|
|
51
|
+
discountAmount: 7.5,
|
|
52
|
+
couponId: "coupon-1",
|
|
53
|
+
appliedCoupons: [{ code: "SUMMER10", discountKind: "simple", discountAmount: 7.5 }],
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** The undiscounted answer — what `remove()` puts back. */
|
|
57
|
+
const clearedQuote: PillarDiscountQuote = {
|
|
58
|
+
subTotal: 50,
|
|
59
|
+
totalAmount: 50,
|
|
60
|
+
discountAmount: 0,
|
|
61
|
+
couponId: null,
|
|
62
|
+
appliedCoupons: [],
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** The summary as every checkout composes it: the lines, then the total. */
|
|
66
|
+
function summary(coupon: CouponField, total: number) {
|
|
67
|
+
return renderToStaticMarkup(
|
|
68
|
+
<ForgeThemeProvider theme={{ colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" }, cornerRadius: 8 }}>
|
|
69
|
+
<div>
|
|
70
|
+
<DiscountSummaryLines
|
|
71
|
+
subTotal={coupon.quote?.subTotal ?? total}
|
|
72
|
+
discountAmount={coupon.discountAmount}
|
|
73
|
+
appliedCoupons={coupon.appliedCoupons}
|
|
74
|
+
fmt={fmt}
|
|
75
|
+
/>
|
|
76
|
+
<div>
|
|
77
|
+
<span>Total</span>
|
|
78
|
+
<span>{fmt(total)}</span>
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
</ForgeThemeProvider>,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function input(coupon: CouponField, props: Partial<{ mode: "quote" | "deferred"; disabled: boolean }> = {}) {
|
|
86
|
+
return renderToStaticMarkup(
|
|
87
|
+
<ForgeThemeProvider theme={{ colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" }, cornerRadius: 8 }}>
|
|
88
|
+
<DiscountCodeField coupon={coupon} fmt={fmt} {...props} />
|
|
89
|
+
</ForgeThemeProvider>,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The markup carries HTML entities for the minus sign — normalise. */
|
|
94
|
+
const plain = (html: string) => html.replace(/−|−/g, "−");
|
|
95
|
+
|
|
96
|
+
describe("discount code — applying a valid code", () => {
|
|
97
|
+
const applied = field({ quote: appliedQuote, code: "SUMMER10", showInput: true });
|
|
98
|
+
|
|
99
|
+
it("shows a Discount LINE and the reduced total, not just a smaller number", () => {
|
|
100
|
+
const html = plain(summary(applied, appliedQuote.totalAmount));
|
|
101
|
+
|
|
102
|
+
// The line the whole feature exists for.
|
|
103
|
+
expect(html).toContain('data-testid="discount-line"');
|
|
104
|
+
expect(html).toContain("−$7.50");
|
|
105
|
+
// …with the subtotal beside it, so the buyer can see what it came off.
|
|
106
|
+
expect(html).toContain("Subtotal");
|
|
107
|
+
expect(html).toContain("$50.00");
|
|
108
|
+
// …and the reduced total.
|
|
109
|
+
expect(html).toContain("$42.50");
|
|
110
|
+
// The arithmetic on screen is the SERVER's and is self-consistent.
|
|
111
|
+
expect(appliedQuote.subTotal - appliedQuote.discountAmount).toBe(appliedQuote.totalAmount);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("names the code that produced the discount", () => {
|
|
115
|
+
expect(plain(summary(applied, appliedQuote.totalAmount))).toContain("Discount (SUMMER10)");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("shows the applied code with what it saved, and a way to remove it", () => {
|
|
119
|
+
const html = input(applied);
|
|
120
|
+
expect(html).toContain('data-testid="discount-code-applied"');
|
|
121
|
+
expect(html).toContain("SUMMER10");
|
|
122
|
+
expect(html).toContain("$7.50 off");
|
|
123
|
+
expect(html).toContain('data-testid="discount-code-remove"');
|
|
124
|
+
// The prompt is gone once something is applied.
|
|
125
|
+
expect(html).not.toContain('data-testid="discount-code-open"');
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("discount code — an AUTOMATIC (no-code) discount", () => {
|
|
130
|
+
// The buyer typed nothing. Without this, the only evidence is a total that
|
|
131
|
+
// silently shrank.
|
|
132
|
+
const auto = field({
|
|
133
|
+
quote: {
|
|
134
|
+
subTotal: 50,
|
|
135
|
+
totalAmount: 45,
|
|
136
|
+
discountAmount: 5,
|
|
137
|
+
couponId: "coupon-auto",
|
|
138
|
+
appliedCoupons: [{ code: "AUTUMN", discountKind: "simple", discountAmount: 5 }],
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("is shown as applied even though the input was never opened", () => {
|
|
143
|
+
const html = input(auto);
|
|
144
|
+
expect(html).toContain('data-testid="discount-code-applied"');
|
|
145
|
+
expect(html).toContain("AUTUMN");
|
|
146
|
+
expect(html).toContain("$5.00 off");
|
|
147
|
+
expect(html).not.toContain('data-testid="discount-code-input"');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("gets its own discount line", () => {
|
|
151
|
+
const html = plain(summary(auto, 45));
|
|
152
|
+
expect(html).toContain('data-testid="discount-line"');
|
|
153
|
+
expect(html).toContain("Discount (AUTUMN)");
|
|
154
|
+
expect(html).toContain("−$5.00");
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe("discount code — a rejected code", () => {
|
|
159
|
+
it("shows the API's own reason, not a generic 'invalid code'", () => {
|
|
160
|
+
// Exactly what `errors.coupon.min_order_value_not_met` renders to in `en`.
|
|
161
|
+
const html = input(
|
|
162
|
+
field({ code: "SUMMER10", showInput: true, error: "Your order does not reach this coupon's minimum spend" }),
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
expect(html).toContain('data-testid="discount-code-error"');
|
|
166
|
+
expect(html).toContain("Your order does not reach this coupon");
|
|
167
|
+
expect(html).toContain("minimum spend");
|
|
168
|
+
// The generic copy that would have thrown that reason away.
|
|
169
|
+
expect(html.toLowerCase()).not.toContain("invalid coupon code");
|
|
170
|
+
expect(html.toLowerCase()).not.toContain("invalid code");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("carries each distinct reason through verbatim", () => {
|
|
174
|
+
for (const reason of [
|
|
175
|
+
"This coupon has expired",
|
|
176
|
+
"You have already used this coupon the maximum number of times",
|
|
177
|
+
"This coupon is only available to members of a specific tier",
|
|
178
|
+
"This coupon does not apply to anything in this order",
|
|
179
|
+
]) {
|
|
180
|
+
expect(input(field({ code: "X", showInput: true, error: reason }))).toContain(reason);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("does not record a discount for a code that was refused", () => {
|
|
185
|
+
// The total must keep matching the server: a refused code leaves the
|
|
186
|
+
// previous (here: absent) quote standing.
|
|
187
|
+
const html = plain(summary(field({ code: "X", showInput: true, error: "This coupon has expired" }), 50));
|
|
188
|
+
expect(html).not.toContain('data-testid="discount-line"');
|
|
189
|
+
expect(html).toContain("$50.00");
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe("discount code — removing a code", () => {
|
|
194
|
+
it("restores the original total and drops the discount line", () => {
|
|
195
|
+
// The state after `remove()` re-quoted with no code.
|
|
196
|
+
const html = plain(summary(field({ quote: clearedQuote }), clearedQuote.totalAmount));
|
|
197
|
+
|
|
198
|
+
expect(html).not.toContain('data-testid="discount-line"');
|
|
199
|
+
expect(html).not.toContain('data-testid="discount-summary"');
|
|
200
|
+
expect(html).toContain("$50.00");
|
|
201
|
+
expect(html).not.toContain("$42.50");
|
|
202
|
+
expect(clearedQuote.totalAmount).toBe(appliedQuote.subTotal);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("puts the 'have a code?' prompt back", () => {
|
|
206
|
+
const html = input(field({ quote: clearedQuote }));
|
|
207
|
+
expect(html).toContain('data-testid="discount-code-open"');
|
|
208
|
+
expect(html).toContain("Have a discount code?");
|
|
209
|
+
expect(html).not.toContain('data-testid="discount-code-applied"');
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
describe("discount code — a checkout with no code (regression)", () => {
|
|
214
|
+
it("adds NOTHING to the summary", () => {
|
|
215
|
+
// The only thing inserted into every existing order summary is
|
|
216
|
+
// <DiscountSummaryLines>, and with no discount it must render nothing at
|
|
217
|
+
// all — so an undiscounted checkout looks exactly as it did before.
|
|
218
|
+
const html = renderToStaticMarkup(
|
|
219
|
+
<ForgeThemeProvider theme={{ colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" }, cornerRadius: 8 }}>
|
|
220
|
+
<DiscountSummaryLines subTotal={50} discountAmount={0} fmt={fmt} />
|
|
221
|
+
</ForgeThemeProvider>,
|
|
222
|
+
);
|
|
223
|
+
// Only the provider's own theme <style> — the component itself emits nothing.
|
|
224
|
+
expect(html.replace(/<style>[\s\S]*?<\/style>/, "")).toBe("");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("offers only a collapsed prompt — no input, no error, no applied block", () => {
|
|
228
|
+
const html = input(field());
|
|
229
|
+
expect(html).toContain('data-testid="discount-code-open"');
|
|
230
|
+
expect(html).not.toContain('data-testid="discount-code-input"');
|
|
231
|
+
expect(html).not.toContain('data-testid="discount-code-error"');
|
|
232
|
+
expect(html).not.toContain('data-testid="discount-code-applied"');
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("renders the untouched total", () => {
|
|
236
|
+
expect(plain(summary(field(), 50))).toContain("$50.00");
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
describe("discount code — the two apply modes", () => {
|
|
241
|
+
it("offers an Apply button where the pillar can really re-quote", () => {
|
|
242
|
+
const html = input(field({ showInput: true, code: "X" }), { mode: "quote" });
|
|
243
|
+
expect(html).toContain('data-testid="discount-code-apply"');
|
|
244
|
+
expect(html).not.toContain("applied at the next step");
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("offers no Apply button where the code can only be redeemed by the next call", () => {
|
|
248
|
+
// Tickets and bundles: `POST …/orders` and `POST /public/checkouts` CREATE
|
|
249
|
+
// the record, so there is nothing to press that would price a code without
|
|
250
|
+
// leaving a real abandoned order behind.
|
|
251
|
+
const html = input(field({ showInput: true, code: "X" }), { mode: "deferred" });
|
|
252
|
+
expect(html).not.toContain('data-testid="discount-code-apply"');
|
|
253
|
+
expect(html).toContain("Your code is applied at the next step.");
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("freezes once the code can no longer be changed", () => {
|
|
257
|
+
// A created bundle: the code was consumed by the call that made it.
|
|
258
|
+
const html = input(field({ quote: appliedQuote }), { disabled: true });
|
|
259
|
+
expect(html).toContain('data-testid="discount-code-applied"');
|
|
260
|
+
expect(html).toContain("SUMMER10");
|
|
261
|
+
expect(html).not.toContain('data-testid="discount-code-remove"');
|
|
262
|
+
expect(html).not.toContain('data-testid="discount-code-open"');
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("hides Remove for a pillar that cannot un-apply in place", () => {
|
|
266
|
+
// Event tickets expose their own "Remove discount" on the payment step,
|
|
267
|
+
// which restarts the order; the field itself must not pretend otherwise.
|
|
268
|
+
const html = input(field({ quote: appliedQuote, canQuote: false }));
|
|
269
|
+
expect(html).toContain('data-testid="discount-code-applied"');
|
|
270
|
+
expect(html).not.toContain('data-testid="discount-code-remove"');
|
|
271
|
+
});
|
|
272
|
+
});
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
3
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
4
|
+
import { ForgeClientProvider } from "../../../provider/ForgeProvider";
|
|
5
|
+
import { PublicAuthContext } from "../../../contexts/PublicAuthContext";
|
|
6
|
+
import { ForgeThemeProvider } from "../../theme/ForgeThemeProvider";
|
|
7
|
+
import { EventConfirmation } from "../EventConfirmation";
|
|
8
|
+
import { OrderStatus, type ITicketOrder } from "../../../types/models";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Rendered through `react-dom/server`, which needs no DOM — so these run in the
|
|
12
|
+
* package's existing node vitest environment with no new dependency and no
|
|
13
|
+
* jsdom project. Effects never fire under `renderToStaticMarkup`, so the seeded
|
|
14
|
+
* query cache is the only data source and nothing reaches the network.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const PROFILE_ID = "profile-1";
|
|
18
|
+
const ORDER_ID = "order-1";
|
|
19
|
+
const EVENT_ID = "event-1";
|
|
20
|
+
|
|
21
|
+
const baseOrder: ITicketOrder = {
|
|
22
|
+
id: "0f9c8b7a-1111-2222-3333-444455556666",
|
|
23
|
+
items: [{ id: "item-1", eventTicketId: "ticket-1", quantity: 2, price: 12.5, title: "General Admission" }],
|
|
24
|
+
firstName: "Ada",
|
|
25
|
+
lastName: "Lovelace",
|
|
26
|
+
email: "ada@example.com",
|
|
27
|
+
totalAmount: 25,
|
|
28
|
+
currency: "USD",
|
|
29
|
+
status: OrderStatus.Paid,
|
|
30
|
+
customerName: "Ada Lovelace",
|
|
31
|
+
customerEmail: "ada@example.com",
|
|
32
|
+
createdAt: "2026-07-01T10:00:00.000Z",
|
|
33
|
+
updatedAt: "2026-07-01T10:00:00.000Z",
|
|
34
|
+
eventTitle: "Midnight Set",
|
|
35
|
+
questionnaire: [],
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function render(order: ITicketOrder) {
|
|
39
|
+
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
40
|
+
queryClient.setQueryData(["eventTicketOrderStatus", PROFILE_ID, ORDER_ID], order);
|
|
41
|
+
queryClient.setQueryData(["event", EVENT_ID, PROFILE_ID], {
|
|
42
|
+
id: EVENT_ID,
|
|
43
|
+
title: "Midnight Set",
|
|
44
|
+
description: "A set",
|
|
45
|
+
type: "virtual",
|
|
46
|
+
status: "published",
|
|
47
|
+
dateTime: "2026-09-01T20:00:00.000Z",
|
|
48
|
+
endDateTime: "2026-09-01T23:00:00.000Z",
|
|
49
|
+
timezone: "UTC",
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
return renderToStaticMarkup(
|
|
53
|
+
<QueryClientProvider client={queryClient}>
|
|
54
|
+
<ForgeClientProvider apiUrl="http://api.test" profileId={PROFILE_ID}>
|
|
55
|
+
<PublicAuthContext.Provider value={{ currencies: null, userSelectedCurrency: "USD" } as never}>
|
|
56
|
+
<ForgeThemeProvider
|
|
57
|
+
theme={{ colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" }, cornerRadius: 8 }}
|
|
58
|
+
>
|
|
59
|
+
<EventConfirmation slug={EVENT_ID} orderId={ORDER_ID} formatAmount={(n) => `$${n.toFixed(2)}`} />
|
|
60
|
+
</ForgeThemeProvider>
|
|
61
|
+
</PublicAuthContext.Provider>
|
|
62
|
+
</ForgeClientProvider>
|
|
63
|
+
</QueryClientProvider>,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The markup carries HTML entities for the curly apostrophes — normalise. */
|
|
68
|
+
const plain = (html: string) => html.replace(/'|'/g, "'").replace(/’/g, "'");
|
|
69
|
+
|
|
70
|
+
describe("EventConfirmation — refunded vs abandoned", () => {
|
|
71
|
+
it("greets a refund-voided order with refunded copy, not 'order not completed'", () => {
|
|
72
|
+
const html = plain(
|
|
73
|
+
render({
|
|
74
|
+
...baseOrder,
|
|
75
|
+
status: OrderStatus.Cancelled,
|
|
76
|
+
refundedAmountCents: "2500",
|
|
77
|
+
refundState: "full",
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
expect(html).toContain("This order was refunded");
|
|
82
|
+
expect(html).toContain("bank or card issuer");
|
|
83
|
+
expect(html).toContain("will not admit entry");
|
|
84
|
+
expect(html).toContain("Refunded");
|
|
85
|
+
|
|
86
|
+
// The three things that were false for this buyer.
|
|
87
|
+
expect(html).not.toContain("Order not completed");
|
|
88
|
+
expect(html).not.toContain("contact support");
|
|
89
|
+
expect(html).not.toContain("If you were charged");
|
|
90
|
+
// ...and it must not congratulate them either.
|
|
91
|
+
expect(html).not.toContain("You're going!");
|
|
92
|
+
|
|
93
|
+
// The money line tells the truth about which direction it went.
|
|
94
|
+
expect(html).toContain("Total refunded");
|
|
95
|
+
expect(html).not.toContain("Total paid");
|
|
96
|
+
|
|
97
|
+
// Nothing that reads as a still-valid ticket: no scannable pass is rendered
|
|
98
|
+
// on this surface at all (the QR only ever lived in the emailed PDF), and
|
|
99
|
+
// the "add to calendar" prompt is paid-only.
|
|
100
|
+
expect(html.toLowerCase()).not.toContain("qr");
|
|
101
|
+
expect(html).not.toContain('data-testid="add-to-calendar"');
|
|
102
|
+
expect(html).not.toContain("E-Ticket");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("still shows the existing 'not completed' copy for an abandoned checkout", () => {
|
|
106
|
+
// Regression guard. This path is unchanged and must stay: for a buyer who
|
|
107
|
+
// never paid, "order not completed / if you were charged, contact support"
|
|
108
|
+
// is the correct thing to say.
|
|
109
|
+
const html = plain(
|
|
110
|
+
render({
|
|
111
|
+
...baseOrder,
|
|
112
|
+
status: OrderStatus.Cancelled,
|
|
113
|
+
refundedAmountCents: 0,
|
|
114
|
+
refundState: "none",
|
|
115
|
+
}),
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
expect(html).toContain("Order not completed");
|
|
119
|
+
expect(html).toContain("If you were charged, contact support.");
|
|
120
|
+
expect(html).toContain("Total"); // bare "Total", not "Total paid"
|
|
121
|
+
expect(html).not.toContain("Total paid");
|
|
122
|
+
expect(html).not.toContain("This order was refunded");
|
|
123
|
+
expect(html).not.toContain("Total refunded");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("is unchanged for a paid order", () => {
|
|
127
|
+
const html = plain(render(baseOrder));
|
|
128
|
+
|
|
129
|
+
expect(html).toContain("You're going!");
|
|
130
|
+
expect(html).toContain("E-Ticket");
|
|
131
|
+
expect(html).toContain("We've sent your tickets to ada@example.com.");
|
|
132
|
+
expect(html).toContain("Total paid");
|
|
133
|
+
// Pins the "add to calendar" affordance as really present on the paid path,
|
|
134
|
+
// so its absence in the refunded case above is a meaningful assertion.
|
|
135
|
+
expect(html).toContain('data-testid="add-to-calendar"');
|
|
136
|
+
expect(html).not.toContain("This order was refunded");
|
|
137
|
+
expect(html).not.toContain("Order not completed");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("keeps a partially refunded but still-valid order on the paid copy", () => {
|
|
141
|
+
// A partial refund voids nothing, so the pass still admits entry.
|
|
142
|
+
const html = plain(render({ ...baseOrder, refundedAmountCents: "500", refundState: "partial" }));
|
|
143
|
+
|
|
144
|
+
expect(html).toContain("You're going!");
|
|
145
|
+
expect(html).not.toContain("This order was refunded");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("does not read a refund into a plain payment failure", () => {
|
|
149
|
+
const html = plain(render({ ...baseOrder, status: OrderStatus.PaymentFailed }));
|
|
150
|
+
|
|
151
|
+
expect(html).toContain("Order not completed");
|
|
152
|
+
expect(html).not.toContain("This order was refunded");
|
|
153
|
+
});
|
|
154
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
getTicketOrderOutcome,
|
|
4
|
+
isTicketOrderRefunded,
|
|
5
|
+
TICKET_ORDER_REFUNDED_COPY,
|
|
6
|
+
} from "../ticketOrderOutcome";
|
|
7
|
+
import { OrderStatus } from "../../types/models";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The whole point of this module: `event_ticket_orders.status = cancelled`
|
|
11
|
+
* means two different things and the buyer must not be told the wrong one.
|
|
12
|
+
*
|
|
13
|
+
* The fixtures below mirror exactly what the backend writes:
|
|
14
|
+
* - abandoned → the sweeper only ever touches `initiated_payment` rows, so the
|
|
15
|
+
* refund columns are still at their defaults (0 / "none");
|
|
16
|
+
* - refund-voided → `adjustPillarRefundTracking` runs BEFORE the void, and the
|
|
17
|
+
* void is gated on a FULL refund, so the row is (>0 / "full") by then.
|
|
18
|
+
*/
|
|
19
|
+
const abandoned = {
|
|
20
|
+
status: OrderStatus.Cancelled,
|
|
21
|
+
refundedAmountCents: 0,
|
|
22
|
+
refundState: "none",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const refundVoided = {
|
|
26
|
+
status: OrderStatus.Cancelled,
|
|
27
|
+
// bigint over JSON — a string, which is the shape the API really sends.
|
|
28
|
+
refundedAmountCents: "2500",
|
|
29
|
+
refundState: "full",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
describe("getTicketOrderOutcome", () => {
|
|
33
|
+
it("treats every fulfillment state of a successful order as paid", () => {
|
|
34
|
+
for (const status of [
|
|
35
|
+
OrderStatus.Paid,
|
|
36
|
+
OrderStatus.Processing,
|
|
37
|
+
OrderStatus.LabelPurchased,
|
|
38
|
+
OrderStatus.Shipped,
|
|
39
|
+
OrderStatus.Delivered,
|
|
40
|
+
OrderStatus.Processed,
|
|
41
|
+
]) {
|
|
42
|
+
expect(getTicketOrderOutcome({ status })).toBe("paid");
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("calls a refund-voided cancelled order refunded, not not-completed", () => {
|
|
47
|
+
expect(getTicketOrderOutcome(refundVoided)).toBe("refunded");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("still calls a swept, never-paid cancelled order not_completed", () => {
|
|
51
|
+
// Regression guard: this path is unchanged and MUST stay — the abandoned
|
|
52
|
+
// copy is correct for a buyer who never paid.
|
|
53
|
+
expect(getTicketOrderOutcome(abandoned)).toBe("not_completed");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("treats a cancelled order with no refund fields at all as not_completed", () => {
|
|
57
|
+
// Rows written before the refund columns existed, and any caller that
|
|
58
|
+
// trims the payload: absence of evidence is not evidence of a refund.
|
|
59
|
+
expect(getTicketOrderOutcome({ status: OrderStatus.Cancelled })).toBe("not_completed");
|
|
60
|
+
expect(
|
|
61
|
+
getTicketOrderOutcome({ status: OrderStatus.Cancelled, refundedAmountCents: null, refundState: null }),
|
|
62
|
+
).toBe("not_completed");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("keeps a failed payment not_completed even though it is not cancelled", () => {
|
|
66
|
+
expect(getTicketOrderOutcome({ status: OrderStatus.PaymentFailed })).toBe("not_completed");
|
|
67
|
+
expect(getTicketOrderOutcome({ status: OrderStatus.InitiatedPayment })).toBe("not_completed");
|
|
68
|
+
expect(getTicketOrderOutcome({ status: OrderStatus.Failed })).toBe("not_completed");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("leaves a PARTIALLY refunded, still-valid order as paid", () => {
|
|
72
|
+
// A partial refund voids nothing (786a20bc's rule) — the pass still admits
|
|
73
|
+
// entry, and this screen is about whether it does. Showing "refunded" here
|
|
74
|
+
// would be the mirror-image lie.
|
|
75
|
+
expect(
|
|
76
|
+
getTicketOrderOutcome({ status: OrderStatus.Paid, refundedAmountCents: "500", refundState: "partial" }),
|
|
77
|
+
).toBe("paid");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("tolerates a garbage refunded amount rather than inventing a refund", () => {
|
|
81
|
+
expect(
|
|
82
|
+
getTicketOrderOutcome({ status: OrderStatus.Cancelled, refundedAmountCents: "not-a-number" }),
|
|
83
|
+
).toBe("not_completed");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("falls back to refund_state when the amount is missing", () => {
|
|
87
|
+
// The two are written together, so this should not happen — but if it does,
|
|
88
|
+
// the state is still evidence money moved.
|
|
89
|
+
expect(getTicketOrderOutcome({ status: OrderStatus.Cancelled, refundState: "full" })).toBe("refunded");
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("isTicketOrderRefunded", () => {
|
|
94
|
+
it("is false for an abandoned order and true for a refunded one", () => {
|
|
95
|
+
expect(isTicketOrderRefunded(abandoned)).toBe(false);
|
|
96
|
+
expect(isTicketOrderRefunded(refundVoided)).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("does not treat a zero-cent refund row as a refund", () => {
|
|
100
|
+
expect(isTicketOrderRefunded({ status: OrderStatus.Cancelled, refundedAmountCents: "0", refundState: "none" })).toBe(
|
|
101
|
+
false,
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("TICKET_ORDER_REFUNDED_COPY", () => {
|
|
107
|
+
it("never tells a refunded buyer their order was not completed", () => {
|
|
108
|
+
const all = Object.values(TICKET_ORDER_REFUNDED_COPY).join(" ").toLowerCase();
|
|
109
|
+
expect(all).not.toContain("not completed");
|
|
110
|
+
expect(all).not.toContain("contact support");
|
|
111
|
+
expect(all).not.toContain("if you were charged");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("promises no settlement deadline we do not control", () => {
|
|
115
|
+
const body = TICKET_ORDER_REFUNDED_COPY.body.toLowerCase();
|
|
116
|
+
// No "5-10 business days" style claim: settlement is the provider's and the
|
|
117
|
+
// buyer's bank's, not ours.
|
|
118
|
+
expect(body).not.toMatch(/\d+\s*(-|–|to)?\s*\d*\s*(business\s+)?days?/);
|
|
119
|
+
expect(body).not.toContain("working days");
|
|
120
|
+
expect(body).toContain("bank or card issuer");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("says the tickets no longer admit entry", () => {
|
|
124
|
+
expect(TICKET_ORDER_REFUNDED_COPY.body.toLowerCase()).toContain("will not admit entry");
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { OrderStatus } from "../types/models";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What a buyer looking at their ticket confirmation should be told.
|
|
5
|
+
*
|
|
6
|
+
* ── Why this exists ──────────────────────────────────────────────────────────
|
|
7
|
+
* `event_ticket_orders.status = cancelled` means TWO different things, and the
|
|
8
|
+
* schema carries no discriminator column for them:
|
|
9
|
+
*
|
|
10
|
+
* 1. **Abandoned** — the buyer opened checkout, never paid, and
|
|
11
|
+
* `SWEEP_ABANDONED_TICKET_ORDERS_JOB` retired the row. No money ever moved.
|
|
12
|
+
* 2. **Refund-voided** — the buyer DID pay, was refunded in full, and the
|
|
13
|
+
* refund voided the order so the released seat cannot also scan at the
|
|
14
|
+
* door (see `releaseInventoryForSource` / `voidTicketOrder`).
|
|
15
|
+
*
|
|
16
|
+
* Both surfaces used to render case 1's copy for both: "Order not completed —
|
|
17
|
+
* if you were charged, contact support." Told to someone who was charged and
|
|
18
|
+
* refunded, that is simply false.
|
|
19
|
+
*
|
|
20
|
+
* ── The signal, and why it is trustworthy ────────────────────────────────────
|
|
21
|
+
* `refunded_amount_cents` (> 0 once any money has gone back) and `refund_state`
|
|
22
|
+
* ("none" | "partial" | "full"). Both live on `event_ticket_orders` and both
|
|
23
|
+
* reach the buyer already: the public finalize endpoint answers with
|
|
24
|
+
* `EventTicketOrder.getOrderById`, which is `selectAll("eto")` — the whole row.
|
|
25
|
+
* No serializer change was needed to read them.
|
|
26
|
+
*
|
|
27
|
+
* They separate the two cases cleanly because of where each is written:
|
|
28
|
+
*
|
|
29
|
+
* • The sweeper matches ONLY `status = initiated_payment` — an order that was
|
|
30
|
+
* never paid — and touches no refund column. An abandoned order therefore
|
|
31
|
+
* always carries `refunded_amount_cents = 0` / `refund_state = 'none'`
|
|
32
|
+
* (the column defaults), and it is the only other writer of `cancelled` on
|
|
33
|
+
* this table.
|
|
34
|
+
* • Every path that voids a paid ticket order runs through the refund
|
|
35
|
+
* service, which writes `adjustPillarRefundTracking` BEFORE it voids
|
|
36
|
+
* (`recordRefund`: pillar sync, then `releaseInventoryForSource`). Both void
|
|
37
|
+
* triggers — the seat release and an explicit `revokeAccess` — are gated on
|
|
38
|
+
* `isFullyRefunded`, which requires `capturedAmountCents > 0`, so the state
|
|
39
|
+
* written is always `"full"`, never `"partial"`.
|
|
40
|
+
*
|
|
41
|
+
* So the order of writes guarantees that a refund-voided order is never
|
|
42
|
+
* observed with zero refunded, and the sweeper guarantees an abandoned order is
|
|
43
|
+
* never observed with more than zero.
|
|
44
|
+
*
|
|
45
|
+
* `refunded_amount_cents > 0` is the primary test rather than
|
|
46
|
+
* `refund_state === "full"`: it is the value money is actually counted in, and
|
|
47
|
+
* it stays honest if a future partial-refund flow ever voids an order.
|
|
48
|
+
*/
|
|
49
|
+
export type TicketOrderOutcome =
|
|
50
|
+
/** Paid (or free) and still valid — the ticket admits entry. */
|
|
51
|
+
| "paid"
|
|
52
|
+
/** Was paid, money has gone back, the ticket no longer admits entry. */
|
|
53
|
+
| "refunded"
|
|
54
|
+
/** Never paid — abandoned checkout or a failed payment. */
|
|
55
|
+
| "not_completed";
|
|
56
|
+
|
|
57
|
+
/** The only fields of a ticket order this decision reads. */
|
|
58
|
+
export interface TicketOrderOutcomeInput {
|
|
59
|
+
status: OrderStatus | string;
|
|
60
|
+
/** Bigint column — arrives over JSON as a string. */
|
|
61
|
+
refundedAmountCents?: number | string | null;
|
|
62
|
+
refundState?: string | null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A paid (or free) order advances through fulfillment — processing → shipped /
|
|
67
|
+
* delivered / processed. All of these mean the order succeeded, so the ticket
|
|
68
|
+
* confirmation treats them as "paid"; only failed/pending-payment states are
|
|
69
|
+
* "not completed". (A free order can be `delivered` by the time this loads.)
|
|
70
|
+
*/
|
|
71
|
+
const PAID_STATES = new Set<string>([
|
|
72
|
+
OrderStatus.Paid,
|
|
73
|
+
OrderStatus.Processing,
|
|
74
|
+
OrderStatus.LabelPurchased,
|
|
75
|
+
OrderStatus.Shipped,
|
|
76
|
+
OrderStatus.Delivered,
|
|
77
|
+
OrderStatus.Processed,
|
|
78
|
+
]);
|
|
79
|
+
|
|
80
|
+
/** Has any money gone back on this order? */
|
|
81
|
+
export function isTicketOrderRefunded(order: TicketOrderOutcomeInput): boolean {
|
|
82
|
+
const cents = Number(order.refundedAmountCents ?? 0);
|
|
83
|
+
if (Number.isFinite(cents) && cents > 0) return true;
|
|
84
|
+
// Belt and braces: a `refund_state` of full/partial without a positive amount
|
|
85
|
+
// should not happen (they are written together), but if it ever does, the
|
|
86
|
+
// state is still evidence that money moved — and saying "refunded" to someone
|
|
87
|
+
// who was refunded is the failure we can afford.
|
|
88
|
+
return order.refundState === "full" || order.refundState === "partial";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Which of the three things happened to this ticket order.
|
|
93
|
+
*
|
|
94
|
+
* A still-valid order that carries a PARTIAL refund stays `"paid"` — the pass
|
|
95
|
+
* still admits entry, which is what this screen is about. Only an order that no
|
|
96
|
+
* longer admits entry AND has money back is `"refunded"`.
|
|
97
|
+
*/
|
|
98
|
+
export function getTicketOrderOutcome(order: TicketOrderOutcomeInput): TicketOrderOutcome {
|
|
99
|
+
if (PAID_STATES.has(order.status)) return "paid";
|
|
100
|
+
if (isTicketOrderRefunded(order)) return "refunded";
|
|
101
|
+
return "not_completed";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The refunded copy, shared verbatim by both rendering surfaces (the Forge
|
|
106
|
+
* `EventConfirmation` block and the client app's `/events/:id/finalise` page)
|
|
107
|
+
* so the two can never drift.
|
|
108
|
+
*
|
|
109
|
+
* Deliberately says nothing about how many days the money takes: settlement is
|
|
110
|
+
* the payment provider's and the buyer's bank's, not ours, and a number we
|
|
111
|
+
* cannot honour is the same kind of lie as "contact support" was.
|
|
112
|
+
*
|
|
113
|
+
* Equally deliberately does NOT invite the buyer to contact support: nothing
|
|
114
|
+
* went wrong here. The refund is the intended outcome.
|
|
115
|
+
*/
|
|
116
|
+
export const TICKET_ORDER_REFUNDED_COPY = {
|
|
117
|
+
/** Small uppercase label above the heading. */
|
|
118
|
+
eyebrow: "Refunded",
|
|
119
|
+
heading: "This order was refunded",
|
|
120
|
+
body:
|
|
121
|
+
"Your payment has been sent back to the original payment method — how long it takes to appear is up to your bank or card issuer. " +
|
|
122
|
+
"These tickets have been cancelled and will not admit entry.",
|
|
123
|
+
/** Replaces "Total paid" on the amount line. */
|
|
124
|
+
totalLabel: "Total refunded",
|
|
125
|
+
} as const;
|