@86d-app/payments 0.0.3 → 0.0.6
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/.turbo/turbo-build.log +1 -0
- package/AGENTS.md +52 -45
- package/README.md +27 -7
- package/dist/__tests__/controllers.test.d.ts +2 -0
- package/dist/__tests__/controllers.test.d.ts.map +1 -0
- package/dist/__tests__/edge-cases.test.d.ts +2 -0
- package/dist/__tests__/edge-cases.test.d.ts.map +1 -0
- package/dist/__tests__/endpoint-security.test.d.ts +2 -0
- package/dist/__tests__/endpoint-security.test.d.ts.map +1 -0
- package/dist/__tests__/financial-safety.test.d.ts +2 -0
- package/dist/__tests__/financial-safety.test.d.ts.map +1 -0
- package/dist/__tests__/service-impl.test.d.ts +2 -0
- package/dist/__tests__/service-impl.test.d.ts.map +1 -0
- package/dist/admin/components/index.d.ts +2 -0
- package/dist/admin/components/index.d.ts.map +1 -0
- package/dist/admin/components/payments-admin.d.ts +2 -0
- package/dist/admin/components/payments-admin.d.ts.map +1 -0
- package/dist/admin/endpoints/create-refund.d.ts +20 -0
- package/dist/admin/endpoints/create-refund.d.ts.map +1 -0
- package/dist/admin/endpoints/get-intent.d.ts +16 -0
- package/dist/admin/endpoints/get-intent.d.ts.map +1 -0
- package/dist/admin/endpoints/index.d.ts +63 -0
- package/dist/admin/endpoints/index.d.ts.map +1 -0
- package/dist/admin/endpoints/list-intents.d.ts +22 -0
- package/dist/admin/endpoints/list-intents.d.ts.map +1 -0
- package/dist/admin/endpoints/list-refunds.d.ts +10 -0
- package/dist/admin/endpoints/list-refunds.d.ts.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/schema.d.ts +169 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/service-impl.d.ts +4 -0
- package/dist/service-impl.d.ts.map +1 -0
- package/dist/service.d.ts +125 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/store/endpoints/cancel-intent.d.ts +16 -0
- package/dist/store/endpoints/cancel-intent.d.ts.map +1 -0
- package/dist/store/endpoints/confirm-intent.d.ts +16 -0
- package/dist/store/endpoints/confirm-intent.d.ts.map +1 -0
- package/dist/store/endpoints/create-intent.d.ts +15 -0
- package/dist/store/endpoints/create-intent.d.ts.map +1 -0
- package/dist/store/endpoints/delete-method.d.ts +16 -0
- package/dist/store/endpoints/delete-method.d.ts.map +1 -0
- package/dist/store/endpoints/get-intent.d.ts +16 -0
- package/dist/store/endpoints/get-intent.d.ts.map +1 -0
- package/dist/store/endpoints/index.d.ts +83 -0
- package/dist/store/endpoints/index.d.ts.map +1 -0
- package/dist/store/endpoints/list-methods.d.ts +12 -0
- package/dist/store/endpoints/list-methods.d.ts.map +1 -0
- package/package.json +3 -3
- package/src/__tests__/controllers.test.ts +1043 -0
- package/src/__tests__/edge-cases.test.ts +547 -0
- package/src/__tests__/endpoint-security.test.ts +360 -0
- package/src/__tests__/financial-safety.test.ts +576 -0
- package/src/__tests__/service-impl.test.ts +30 -5
- package/src/admin/endpoints/create-refund.ts +11 -6
- package/src/service-impl.ts +60 -0
- package/src/store/endpoints/cancel-intent.ts +20 -4
- package/src/store/endpoints/confirm-intent.ts +20 -4
- package/src/store/endpoints/create-intent.ts +18 -5
- package/src/store/endpoints/delete-method.ts +11 -1
- package/src/store/endpoints/get-intent.ts +1 -1
- package/src/store/endpoints/list-methods.ts +7 -5
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
import { createMockDataService } from "@86d-app/core/test-utils";
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import type { PaymentProvider } from "../service";
|
|
4
|
+
import { createPaymentController } from "../service-impl";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Financial safety regression tests.
|
|
8
|
+
*
|
|
9
|
+
* These tests verify the hardened payment controller prevents:
|
|
10
|
+
* - Negative or zero-amount intents
|
|
11
|
+
* - Refunds on non-succeeded intents
|
|
12
|
+
* - Refunds exceeding the original charge
|
|
13
|
+
* - Confirming or cancelling terminal-state intents
|
|
14
|
+
* - Duplicate webhook refunds
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
describe("financial safety guards", () => {
|
|
18
|
+
let mockData: ReturnType<typeof createMockDataService>;
|
|
19
|
+
let controller: ReturnType<typeof createPaymentController>;
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
mockData = createMockDataService();
|
|
23
|
+
controller = createPaymentController(mockData);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// ── Amount validation ──────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
describe("intent amount validation", () => {
|
|
29
|
+
it("rejects zero amount", async () => {
|
|
30
|
+
await expect(controller.createIntent({ amount: 0 })).rejects.toThrow(
|
|
31
|
+
"Amount must be a positive integer",
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("rejects negative amount", async () => {
|
|
36
|
+
await expect(controller.createIntent({ amount: -100 })).rejects.toThrow(
|
|
37
|
+
"Amount must be a positive integer",
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("rejects fractional amount", async () => {
|
|
42
|
+
await expect(controller.createIntent({ amount: 49.99 })).rejects.toThrow(
|
|
43
|
+
"Amount must be a positive integer",
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("accepts minimum valid amount (1 cent)", async () => {
|
|
48
|
+
const intent = await controller.createIntent({ amount: 1 });
|
|
49
|
+
expect(intent.amount).toBe(1);
|
|
50
|
+
expect(intent.status).toBe("pending");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("accepts large integer amount", async () => {
|
|
54
|
+
const intent = await controller.createIntent({ amount: 999999999 });
|
|
55
|
+
expect(intent.amount).toBe(999999999);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// ── Confirm status guards ──────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
describe("confirmIntent status guards", () => {
|
|
62
|
+
it("confirms pending intent", async () => {
|
|
63
|
+
const intent = await controller.createIntent({ amount: 1000 });
|
|
64
|
+
const confirmed = await controller.confirmIntent(intent.id);
|
|
65
|
+
expect(confirmed?.status).toBe("succeeded");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("returns already-succeeded intent unchanged", async () => {
|
|
69
|
+
const intent = await controller.createIntent({ amount: 1000 });
|
|
70
|
+
await controller.confirmIntent(intent.id);
|
|
71
|
+
const again = await controller.confirmIntent(intent.id);
|
|
72
|
+
expect(again?.status).toBe("succeeded");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("rejects confirming cancelled intent", async () => {
|
|
76
|
+
const intent = await controller.createIntent({ amount: 1000 });
|
|
77
|
+
await controller.cancelIntent(intent.id);
|
|
78
|
+
await expect(controller.confirmIntent(intent.id)).rejects.toThrow(
|
|
79
|
+
"Cannot confirm intent in 'cancelled' state",
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("rejects confirming failed intent", async () => {
|
|
84
|
+
const mockProvider: PaymentProvider = {
|
|
85
|
+
createIntent: vi.fn().mockResolvedValue({
|
|
86
|
+
providerIntentId: "pi_fail",
|
|
87
|
+
status: "failed",
|
|
88
|
+
providerMetadata: {},
|
|
89
|
+
}),
|
|
90
|
+
confirmIntent: vi.fn(),
|
|
91
|
+
cancelIntent: vi.fn(),
|
|
92
|
+
createRefund: vi.fn(),
|
|
93
|
+
};
|
|
94
|
+
const ctrl = createPaymentController(mockData, mockProvider);
|
|
95
|
+
const intent = await ctrl.createIntent({ amount: 1000 });
|
|
96
|
+
expect(intent.status).toBe("failed");
|
|
97
|
+
await expect(ctrl.confirmIntent(intent.id)).rejects.toThrow(
|
|
98
|
+
"Cannot confirm intent in 'failed' state",
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("rejects confirming refunded intent", async () => {
|
|
103
|
+
const intent = await controller.createIntent({ amount: 1000 });
|
|
104
|
+
await controller.confirmIntent(intent.id);
|
|
105
|
+
await controller.createRefund({ intentId: intent.id });
|
|
106
|
+
await expect(controller.confirmIntent(intent.id)).rejects.toThrow(
|
|
107
|
+
"Cannot confirm intent in 'refunded' state",
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// ── Cancel status guards ───────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
describe("cancelIntent status guards", () => {
|
|
115
|
+
it("cancels pending intent", async () => {
|
|
116
|
+
const intent = await controller.createIntent({ amount: 1000 });
|
|
117
|
+
const cancelled = await controller.cancelIntent(intent.id);
|
|
118
|
+
expect(cancelled?.status).toBe("cancelled");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("returns already-cancelled intent unchanged", async () => {
|
|
122
|
+
const intent = await controller.createIntent({ amount: 1000 });
|
|
123
|
+
await controller.cancelIntent(intent.id);
|
|
124
|
+
const again = await controller.cancelIntent(intent.id);
|
|
125
|
+
expect(again?.status).toBe("cancelled");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("rejects cancelling succeeded intent", async () => {
|
|
129
|
+
const intent = await controller.createIntent({ amount: 1000 });
|
|
130
|
+
await controller.confirmIntent(intent.id);
|
|
131
|
+
await expect(controller.cancelIntent(intent.id)).rejects.toThrow(
|
|
132
|
+
"Cannot cancel intent in 'succeeded' state",
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("rejects cancelling refunded intent", async () => {
|
|
137
|
+
const intent = await controller.createIntent({ amount: 1000 });
|
|
138
|
+
await controller.confirmIntent(intent.id);
|
|
139
|
+
await controller.createRefund({ intentId: intent.id });
|
|
140
|
+
await expect(controller.cancelIntent(intent.id)).rejects.toThrow(
|
|
141
|
+
"Cannot cancel intent in 'refunded' state",
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("rejects cancelling failed intent", async () => {
|
|
146
|
+
const mockProvider: PaymentProvider = {
|
|
147
|
+
createIntent: vi.fn().mockResolvedValue({
|
|
148
|
+
providerIntentId: "pi_fail",
|
|
149
|
+
status: "failed",
|
|
150
|
+
providerMetadata: {},
|
|
151
|
+
}),
|
|
152
|
+
confirmIntent: vi.fn(),
|
|
153
|
+
cancelIntent: vi.fn(),
|
|
154
|
+
createRefund: vi.fn(),
|
|
155
|
+
};
|
|
156
|
+
const ctrl = createPaymentController(mockData, mockProvider);
|
|
157
|
+
const intent = await ctrl.createIntent({ amount: 1000 });
|
|
158
|
+
await expect(ctrl.cancelIntent(intent.id)).rejects.toThrow(
|
|
159
|
+
"Cannot cancel intent in 'failed' state",
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// ── Refund status guards ───────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
describe("createRefund status guards", () => {
|
|
167
|
+
it("refunds succeeded intent", async () => {
|
|
168
|
+
const intent = await controller.createIntent({ amount: 5000 });
|
|
169
|
+
await controller.confirmIntent(intent.id);
|
|
170
|
+
const refund = await controller.createRefund({
|
|
171
|
+
intentId: intent.id,
|
|
172
|
+
});
|
|
173
|
+
expect(refund.amount).toBe(5000);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("refunds already-refunded intent (partial refunds)", async () => {
|
|
177
|
+
const intent = await controller.createIntent({ amount: 5000 });
|
|
178
|
+
await controller.confirmIntent(intent.id);
|
|
179
|
+
await controller.createRefund({
|
|
180
|
+
intentId: intent.id,
|
|
181
|
+
amount: 2000,
|
|
182
|
+
});
|
|
183
|
+
const r2 = await controller.createRefund({
|
|
184
|
+
intentId: intent.id,
|
|
185
|
+
amount: 1000,
|
|
186
|
+
});
|
|
187
|
+
expect(r2.amount).toBe(1000);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("rejects refunding pending intent", async () => {
|
|
191
|
+
const intent = await controller.createIntent({ amount: 5000 });
|
|
192
|
+
await expect(
|
|
193
|
+
controller.createRefund({ intentId: intent.id }),
|
|
194
|
+
).rejects.toThrow("Cannot refund intent in 'pending' state");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("rejects refunding cancelled intent", async () => {
|
|
198
|
+
const intent = await controller.createIntent({ amount: 5000 });
|
|
199
|
+
await controller.cancelIntent(intent.id);
|
|
200
|
+
await expect(
|
|
201
|
+
controller.createRefund({ intentId: intent.id }),
|
|
202
|
+
).rejects.toThrow("Cannot refund intent in 'cancelled' state");
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("rejects refunding failed intent", async () => {
|
|
206
|
+
const mockProvider: PaymentProvider = {
|
|
207
|
+
createIntent: vi.fn().mockResolvedValue({
|
|
208
|
+
providerIntentId: "pi_fail",
|
|
209
|
+
status: "failed",
|
|
210
|
+
providerMetadata: {},
|
|
211
|
+
}),
|
|
212
|
+
confirmIntent: vi.fn(),
|
|
213
|
+
cancelIntent: vi.fn(),
|
|
214
|
+
createRefund: vi.fn(),
|
|
215
|
+
};
|
|
216
|
+
const ctrl = createPaymentController(mockData, mockProvider);
|
|
217
|
+
const intent = await ctrl.createIntent({ amount: 5000 });
|
|
218
|
+
await expect(ctrl.createRefund({ intentId: intent.id })).rejects.toThrow(
|
|
219
|
+
"Cannot refund intent in 'failed' state",
|
|
220
|
+
);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// ── Refund amount cap ──────────────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
describe("refund amount cap", () => {
|
|
227
|
+
it("allows full refund equal to intent amount", async () => {
|
|
228
|
+
const intent = await controller.createIntent({ amount: 10000 });
|
|
229
|
+
await controller.confirmIntent(intent.id);
|
|
230
|
+
const refund = await controller.createRefund({
|
|
231
|
+
intentId: intent.id,
|
|
232
|
+
amount: 10000,
|
|
233
|
+
});
|
|
234
|
+
expect(refund.amount).toBe(10000);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("allows default full refund (no amount specified)", async () => {
|
|
238
|
+
const intent = await controller.createIntent({ amount: 7500 });
|
|
239
|
+
await controller.confirmIntent(intent.id);
|
|
240
|
+
const refund = await controller.createRefund({
|
|
241
|
+
intentId: intent.id,
|
|
242
|
+
});
|
|
243
|
+
expect(refund.amount).toBe(7500);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("rejects refund exceeding intent amount", async () => {
|
|
247
|
+
const intent = await controller.createIntent({ amount: 5000 });
|
|
248
|
+
await controller.confirmIntent(intent.id);
|
|
249
|
+
await expect(
|
|
250
|
+
controller.createRefund({
|
|
251
|
+
intentId: intent.id,
|
|
252
|
+
amount: 5001,
|
|
253
|
+
}),
|
|
254
|
+
).rejects.toThrow(
|
|
255
|
+
"Refund amount 5001 exceeds remaining refundable amount 5000",
|
|
256
|
+
);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("rejects partial refunds that cumulatively exceed intent amount", async () => {
|
|
260
|
+
const intent = await controller.createIntent({ amount: 10000 });
|
|
261
|
+
await controller.confirmIntent(intent.id);
|
|
262
|
+
|
|
263
|
+
// First refund: 6000
|
|
264
|
+
await controller.createRefund({
|
|
265
|
+
intentId: intent.id,
|
|
266
|
+
amount: 6000,
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// Second refund: 3000 (total 9000, OK)
|
|
270
|
+
await controller.createRefund({
|
|
271
|
+
intentId: intent.id,
|
|
272
|
+
amount: 3000,
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
// Third refund: 2000 (would total 11000, REJECTED)
|
|
276
|
+
await expect(
|
|
277
|
+
controller.createRefund({
|
|
278
|
+
intentId: intent.id,
|
|
279
|
+
amount: 2000,
|
|
280
|
+
}),
|
|
281
|
+
).rejects.toThrow(
|
|
282
|
+
"Refund amount 2000 exceeds remaining refundable amount 1000",
|
|
283
|
+
);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("allows final refund consuming exact remaining amount", async () => {
|
|
287
|
+
const intent = await controller.createIntent({ amount: 5000 });
|
|
288
|
+
await controller.confirmIntent(intent.id);
|
|
289
|
+
|
|
290
|
+
await controller.createRefund({
|
|
291
|
+
intentId: intent.id,
|
|
292
|
+
amount: 3000,
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
const final = await controller.createRefund({
|
|
296
|
+
intentId: intent.id,
|
|
297
|
+
amount: 2000,
|
|
298
|
+
});
|
|
299
|
+
expect(final.amount).toBe(2000);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("rejects any refund after full refund", async () => {
|
|
303
|
+
const intent = await controller.createIntent({ amount: 5000 });
|
|
304
|
+
await controller.confirmIntent(intent.id);
|
|
305
|
+
|
|
306
|
+
await controller.createRefund({ intentId: intent.id });
|
|
307
|
+
|
|
308
|
+
await expect(
|
|
309
|
+
controller.createRefund({
|
|
310
|
+
intentId: intent.id,
|
|
311
|
+
amount: 1,
|
|
312
|
+
}),
|
|
313
|
+
).rejects.toThrow(
|
|
314
|
+
"Refund amount 1 exceeds remaining refundable amount 0",
|
|
315
|
+
);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("rejects zero refund amount", async () => {
|
|
319
|
+
const intent = await controller.createIntent({ amount: 5000 });
|
|
320
|
+
await controller.confirmIntent(intent.id);
|
|
321
|
+
await expect(
|
|
322
|
+
controller.createRefund({
|
|
323
|
+
intentId: intent.id,
|
|
324
|
+
amount: 0,
|
|
325
|
+
}),
|
|
326
|
+
).rejects.toThrow("Refund amount must be positive");
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("rejects negative refund amount", async () => {
|
|
330
|
+
const intent = await controller.createIntent({ amount: 5000 });
|
|
331
|
+
await controller.confirmIntent(intent.id);
|
|
332
|
+
await expect(
|
|
333
|
+
controller.createRefund({
|
|
334
|
+
intentId: intent.id,
|
|
335
|
+
amount: -100,
|
|
336
|
+
}),
|
|
337
|
+
).rejects.toThrow("Refund amount must be positive");
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it("excludes failed refunds from cumulative total", async () => {
|
|
341
|
+
const mockProvider: PaymentProvider = {
|
|
342
|
+
createIntent: vi.fn().mockResolvedValue({
|
|
343
|
+
providerIntentId: "pi_partial_fail",
|
|
344
|
+
status: "succeeded",
|
|
345
|
+
providerMetadata: {},
|
|
346
|
+
}),
|
|
347
|
+
confirmIntent: vi.fn(),
|
|
348
|
+
cancelIntent: vi.fn(),
|
|
349
|
+
createRefund: vi
|
|
350
|
+
.fn()
|
|
351
|
+
.mockResolvedValueOnce({
|
|
352
|
+
providerRefundId: "re_ok",
|
|
353
|
+
status: "succeeded",
|
|
354
|
+
})
|
|
355
|
+
.mockResolvedValueOnce({
|
|
356
|
+
providerRefundId: "re_fail",
|
|
357
|
+
status: "failed",
|
|
358
|
+
})
|
|
359
|
+
.mockResolvedValueOnce({
|
|
360
|
+
providerRefundId: "re_ok2",
|
|
361
|
+
status: "succeeded",
|
|
362
|
+
}),
|
|
363
|
+
};
|
|
364
|
+
const ctrl = createPaymentController(mockData, mockProvider);
|
|
365
|
+
const intent = await ctrl.createIntent({ amount: 10000 });
|
|
366
|
+
|
|
367
|
+
// First refund: 5000 succeeded
|
|
368
|
+
await ctrl.createRefund({
|
|
369
|
+
intentId: intent.id,
|
|
370
|
+
amount: 5000,
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
// Second refund: 3000 failed (shouldn't count toward cap)
|
|
374
|
+
await ctrl.createRefund({
|
|
375
|
+
intentId: intent.id,
|
|
376
|
+
amount: 3000,
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
// Third refund: 5000 should work (only 5000 succeeded so far)
|
|
380
|
+
const r3 = await ctrl.createRefund({
|
|
381
|
+
intentId: intent.id,
|
|
382
|
+
amount: 5000,
|
|
383
|
+
});
|
|
384
|
+
expect(r3.amount).toBe(5000);
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
// ── Webhook refund deduplication ────────────────────────────────────
|
|
389
|
+
|
|
390
|
+
describe("webhook refund deduplication", () => {
|
|
391
|
+
it("processes first webhook refund normally", async () => {
|
|
392
|
+
const mockProvider: PaymentProvider = {
|
|
393
|
+
createIntent: vi.fn().mockResolvedValue({
|
|
394
|
+
providerIntentId: "pi_dedup",
|
|
395
|
+
status: "succeeded",
|
|
396
|
+
providerMetadata: {},
|
|
397
|
+
}),
|
|
398
|
+
confirmIntent: vi.fn(),
|
|
399
|
+
cancelIntent: vi.fn(),
|
|
400
|
+
createRefund: vi.fn(),
|
|
401
|
+
};
|
|
402
|
+
const ctrl = createPaymentController(mockData, mockProvider);
|
|
403
|
+
await ctrl.createIntent({ amount: 5000 });
|
|
404
|
+
|
|
405
|
+
const result = await ctrl.handleWebhookRefund({
|
|
406
|
+
providerIntentId: "pi_dedup",
|
|
407
|
+
providerRefundId: "re_first",
|
|
408
|
+
amount: 3000,
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
expect(result).not.toBeNull();
|
|
412
|
+
expect(result?.refund.amount).toBe(3000);
|
|
413
|
+
expect(result?.intent.status).toBe("refunded");
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it("returns existing refund on duplicate providerRefundId", async () => {
|
|
417
|
+
const mockProvider: PaymentProvider = {
|
|
418
|
+
createIntent: vi.fn().mockResolvedValue({
|
|
419
|
+
providerIntentId: "pi_dedup2",
|
|
420
|
+
status: "succeeded",
|
|
421
|
+
providerMetadata: {},
|
|
422
|
+
}),
|
|
423
|
+
confirmIntent: vi.fn(),
|
|
424
|
+
cancelIntent: vi.fn(),
|
|
425
|
+
createRefund: vi.fn(),
|
|
426
|
+
};
|
|
427
|
+
const ctrl = createPaymentController(mockData, mockProvider);
|
|
428
|
+
const intent = await ctrl.createIntent({ amount: 5000 });
|
|
429
|
+
|
|
430
|
+
// First webhook
|
|
431
|
+
const first = await ctrl.handleWebhookRefund({
|
|
432
|
+
providerIntentId: "pi_dedup2",
|
|
433
|
+
providerRefundId: "re_dup",
|
|
434
|
+
amount: 3000,
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
// Duplicate webhook (retry)
|
|
438
|
+
const second = await ctrl.handleWebhookRefund({
|
|
439
|
+
providerIntentId: "pi_dedup2",
|
|
440
|
+
providerRefundId: "re_dup",
|
|
441
|
+
amount: 3000,
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
// Should return same refund, not create a new one
|
|
445
|
+
expect(second?.refund.id).toBe(first?.refund.id);
|
|
446
|
+
expect(second?.refund.amount).toBe(3000);
|
|
447
|
+
|
|
448
|
+
// Only one refund record should exist
|
|
449
|
+
const refunds = await ctrl.listRefunds(intent.id);
|
|
450
|
+
expect(refunds).toHaveLength(1);
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it("allows different providerRefundIds for same intent", async () => {
|
|
454
|
+
const mockProvider: PaymentProvider = {
|
|
455
|
+
createIntent: vi.fn().mockResolvedValue({
|
|
456
|
+
providerIntentId: "pi_multi",
|
|
457
|
+
status: "succeeded",
|
|
458
|
+
providerMetadata: {},
|
|
459
|
+
}),
|
|
460
|
+
confirmIntent: vi.fn(),
|
|
461
|
+
cancelIntent: vi.fn(),
|
|
462
|
+
createRefund: vi.fn(),
|
|
463
|
+
};
|
|
464
|
+
const ctrl = createPaymentController(mockData, mockProvider);
|
|
465
|
+
const intent = await ctrl.createIntent({ amount: 10000 });
|
|
466
|
+
|
|
467
|
+
await ctrl.handleWebhookRefund({
|
|
468
|
+
providerIntentId: "pi_multi",
|
|
469
|
+
providerRefundId: "re_a",
|
|
470
|
+
amount: 3000,
|
|
471
|
+
});
|
|
472
|
+
await ctrl.handleWebhookRefund({
|
|
473
|
+
providerIntentId: "pi_multi",
|
|
474
|
+
providerRefundId: "re_b",
|
|
475
|
+
amount: 4000,
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
const refunds = await ctrl.listRefunds(intent.id);
|
|
479
|
+
expect(refunds).toHaveLength(2);
|
|
480
|
+
const total = refunds.reduce((s, r) => s + r.amount, 0);
|
|
481
|
+
expect(total).toBe(7000);
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
it("dedup returns original refund even if amount differs in retry", async () => {
|
|
485
|
+
const mockProvider: PaymentProvider = {
|
|
486
|
+
createIntent: vi.fn().mockResolvedValue({
|
|
487
|
+
providerIntentId: "pi_dedup3",
|
|
488
|
+
status: "succeeded",
|
|
489
|
+
providerMetadata: {},
|
|
490
|
+
}),
|
|
491
|
+
confirmIntent: vi.fn(),
|
|
492
|
+
cancelIntent: vi.fn(),
|
|
493
|
+
createRefund: vi.fn(),
|
|
494
|
+
};
|
|
495
|
+
const ctrl = createPaymentController(mockData, mockProvider);
|
|
496
|
+
await ctrl.createIntent({ amount: 5000 });
|
|
497
|
+
|
|
498
|
+
// First call
|
|
499
|
+
const first = await ctrl.handleWebhookRefund({
|
|
500
|
+
providerIntentId: "pi_dedup3",
|
|
501
|
+
providerRefundId: "re_same",
|
|
502
|
+
amount: 3000,
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
// Retry with different amount (shouldn't matter — dedup by providerRefundId)
|
|
506
|
+
const second = await ctrl.handleWebhookRefund({
|
|
507
|
+
providerIntentId: "pi_dedup3",
|
|
508
|
+
providerRefundId: "re_same",
|
|
509
|
+
amount: 9999,
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
expect(second?.refund.amount).toBe(first?.refund.amount);
|
|
513
|
+
expect(second?.refund.amount).toBe(3000);
|
|
514
|
+
});
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
// ── Combined lifecycle with guards ─────────────────────────────────
|
|
518
|
+
|
|
519
|
+
describe("end-to-end lifecycle with safety guards", () => {
|
|
520
|
+
it("complete happy path: create → confirm → partial refund → rest refund", async () => {
|
|
521
|
+
const intent = await controller.createIntent({
|
|
522
|
+
amount: 10000,
|
|
523
|
+
customerId: "cust_safe",
|
|
524
|
+
});
|
|
525
|
+
expect(intent.status).toBe("pending");
|
|
526
|
+
|
|
527
|
+
const confirmed = await controller.confirmIntent(intent.id);
|
|
528
|
+
expect(confirmed?.status).toBe("succeeded");
|
|
529
|
+
|
|
530
|
+
// Can't cancel after confirm
|
|
531
|
+
await expect(controller.cancelIntent(intent.id)).rejects.toThrow(
|
|
532
|
+
"Cannot cancel intent in 'succeeded' state",
|
|
533
|
+
);
|
|
534
|
+
|
|
535
|
+
// Partial refund
|
|
536
|
+
const r1 = await controller.createRefund({
|
|
537
|
+
intentId: intent.id,
|
|
538
|
+
amount: 4000,
|
|
539
|
+
});
|
|
540
|
+
expect(r1.amount).toBe(4000);
|
|
541
|
+
|
|
542
|
+
// Remaining: 6000
|
|
543
|
+
const r2 = await controller.createRefund({
|
|
544
|
+
intentId: intent.id,
|
|
545
|
+
amount: 6000,
|
|
546
|
+
});
|
|
547
|
+
expect(r2.amount).toBe(6000);
|
|
548
|
+
|
|
549
|
+
// Fully refunded — no more refunds allowed
|
|
550
|
+
await expect(
|
|
551
|
+
controller.createRefund({
|
|
552
|
+
intentId: intent.id,
|
|
553
|
+
amount: 1,
|
|
554
|
+
}),
|
|
555
|
+
).rejects.toThrow("exceeds remaining refundable amount 0");
|
|
556
|
+
|
|
557
|
+
// Can't confirm refunded intent
|
|
558
|
+
await expect(controller.confirmIntent(intent.id)).rejects.toThrow(
|
|
559
|
+
"Cannot confirm intent in 'refunded' state",
|
|
560
|
+
);
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
it("cancel path: create → cancel, no further transitions", async () => {
|
|
564
|
+
const intent = await controller.createIntent({ amount: 5000 });
|
|
565
|
+
await controller.cancelIntent(intent.id);
|
|
566
|
+
|
|
567
|
+
await expect(controller.confirmIntent(intent.id)).rejects.toThrow(
|
|
568
|
+
"Cannot confirm intent in 'cancelled' state",
|
|
569
|
+
);
|
|
570
|
+
|
|
571
|
+
await expect(
|
|
572
|
+
controller.createRefund({ intentId: intent.id }),
|
|
573
|
+
).rejects.toThrow("Cannot refund intent in 'cancelled' state");
|
|
574
|
+
});
|
|
575
|
+
});
|
|
576
|
+
});
|
|
@@ -343,8 +343,9 @@ describe("createPaymentController", () => {
|
|
|
343
343
|
// ── createRefund ─────────────────────────────────────────────────────
|
|
344
344
|
|
|
345
345
|
describe("createRefund", () => {
|
|
346
|
-
it("creates a full refund", async () => {
|
|
346
|
+
it("creates a full refund on succeeded intent", async () => {
|
|
347
347
|
const intent = await controller.createIntent({ amount: 5000 });
|
|
348
|
+
await controller.confirmIntent(intent.id);
|
|
348
349
|
const refund = await controller.createRefund({
|
|
349
350
|
intentId: intent.id,
|
|
350
351
|
});
|
|
@@ -359,6 +360,7 @@ describe("createPaymentController", () => {
|
|
|
359
360
|
|
|
360
361
|
it("creates a partial refund", async () => {
|
|
361
362
|
const intent = await controller.createIntent({ amount: 5000 });
|
|
363
|
+
await controller.confirmIntent(intent.id);
|
|
362
364
|
const refund = await controller.createRefund({
|
|
363
365
|
intentId: intent.id,
|
|
364
366
|
amount: 2000,
|
|
@@ -374,6 +376,21 @@ describe("createPaymentController", () => {
|
|
|
374
376
|
).rejects.toThrow("Payment intent not found");
|
|
375
377
|
});
|
|
376
378
|
|
|
379
|
+
it("throws when refunding a pending intent", async () => {
|
|
380
|
+
const intent = await controller.createIntent({ amount: 5000 });
|
|
381
|
+
await expect(
|
|
382
|
+
controller.createRefund({ intentId: intent.id }),
|
|
383
|
+
).rejects.toThrow("Cannot refund intent in 'pending' state");
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it("throws when refunding a cancelled intent", async () => {
|
|
387
|
+
const intent = await controller.createIntent({ amount: 5000 });
|
|
388
|
+
await controller.cancelIntent(intent.id);
|
|
389
|
+
await expect(
|
|
390
|
+
controller.createRefund({ intentId: intent.id }),
|
|
391
|
+
).rejects.toThrow("Cannot refund intent in 'cancelled' state");
|
|
392
|
+
});
|
|
393
|
+
|
|
377
394
|
it("delegates to provider when available", async () => {
|
|
378
395
|
const mockProvider: PaymentProvider = {
|
|
379
396
|
createIntent: vi.fn().mockResolvedValue({
|
|
@@ -390,6 +407,7 @@ describe("createPaymentController", () => {
|
|
|
390
407
|
};
|
|
391
408
|
const ctrl = createPaymentController(mockData, mockProvider);
|
|
392
409
|
const intent = await ctrl.createIntent({ amount: 3000 });
|
|
410
|
+
// Provider-created intent already has succeeded status
|
|
393
411
|
const refund = await ctrl.createRefund({ intentId: intent.id });
|
|
394
412
|
expect(refund.providerRefundId).toBe("re_ext_123");
|
|
395
413
|
expect(mockProvider.createRefund).toHaveBeenCalledOnce();
|
|
@@ -397,6 +415,7 @@ describe("createPaymentController", () => {
|
|
|
397
415
|
|
|
398
416
|
it("generates local refund ID for local-only intents", async () => {
|
|
399
417
|
const intent = await controller.createIntent({ amount: 2000 });
|
|
418
|
+
await controller.confirmIntent(intent.id);
|
|
400
419
|
const refund = await controller.createRefund({
|
|
401
420
|
intentId: intent.id,
|
|
402
421
|
});
|
|
@@ -404,8 +423,6 @@ describe("createPaymentController", () => {
|
|
|
404
423
|
});
|
|
405
424
|
|
|
406
425
|
it("throws when provider intent exists but provider is not configured", async () => {
|
|
407
|
-
// Simulate an intent that was created through a provider
|
|
408
|
-
// but the controller no longer has the provider attached
|
|
409
426
|
const mockProvider: PaymentProvider = {
|
|
410
427
|
createIntent: vi.fn().mockResolvedValue({
|
|
411
428
|
providerIntentId: "pi_stripe_orphan",
|
|
@@ -416,11 +433,9 @@ describe("createPaymentController", () => {
|
|
|
416
433
|
cancelIntent: vi.fn(),
|
|
417
434
|
createRefund: vi.fn(),
|
|
418
435
|
};
|
|
419
|
-
// Create intent WITH provider
|
|
420
436
|
const ctrlWithProvider = createPaymentController(mockData, mockProvider);
|
|
421
437
|
const intent = await ctrlWithProvider.createIntent({ amount: 5000 });
|
|
422
438
|
|
|
423
|
-
// Try to refund WITHOUT provider (simulates misconfiguration)
|
|
424
439
|
const ctrlWithout = createPaymentController(mockData);
|
|
425
440
|
await expect(
|
|
426
441
|
ctrlWithout.createRefund({ intentId: intent.id }),
|
|
@@ -428,6 +443,14 @@ describe("createPaymentController", () => {
|
|
|
428
443
|
"Cannot refund: payment was created through a provider but no provider is configured",
|
|
429
444
|
);
|
|
430
445
|
});
|
|
446
|
+
|
|
447
|
+
it("rejects refund exceeding intent amount", async () => {
|
|
448
|
+
const intent = await controller.createIntent({ amount: 5000 });
|
|
449
|
+
await controller.confirmIntent(intent.id);
|
|
450
|
+
await expect(
|
|
451
|
+
controller.createRefund({ intentId: intent.id, amount: 6000 }),
|
|
452
|
+
).rejects.toThrow("exceeds remaining refundable amount");
|
|
453
|
+
});
|
|
431
454
|
});
|
|
432
455
|
|
|
433
456
|
// ── getRefund / listRefunds ──────────────────────────────────────────
|
|
@@ -435,6 +458,7 @@ describe("createPaymentController", () => {
|
|
|
435
458
|
describe("getRefund", () => {
|
|
436
459
|
it("returns an existing refund", async () => {
|
|
437
460
|
const intent = await controller.createIntent({ amount: 1000 });
|
|
461
|
+
await controller.confirmIntent(intent.id);
|
|
438
462
|
const refund = await controller.createRefund({
|
|
439
463
|
intentId: intent.id,
|
|
440
464
|
});
|
|
@@ -451,6 +475,7 @@ describe("createPaymentController", () => {
|
|
|
451
475
|
describe("listRefunds", () => {
|
|
452
476
|
it("lists refunds for an intent", async () => {
|
|
453
477
|
const intent = await controller.createIntent({ amount: 5000 });
|
|
478
|
+
await controller.confirmIntent(intent.id);
|
|
454
479
|
await controller.createRefund({
|
|
455
480
|
intentId: intent.id,
|
|
456
481
|
amount: 1000,
|
|
@@ -15,11 +15,16 @@ export const createRefund = createAdminEndpoint(
|
|
|
15
15
|
const controller = ctx.context.controllers.payments as PaymentController;
|
|
16
16
|
const intent = await controller.getIntent(ctx.params.id);
|
|
17
17
|
if (!intent) return { error: "Payment intent not found", status: 404 };
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
18
|
+
try {
|
|
19
|
+
const refund = await controller.createRefund({
|
|
20
|
+
intentId: ctx.params.id,
|
|
21
|
+
amount: ctx.body.amount,
|
|
22
|
+
reason: ctx.body.reason,
|
|
23
|
+
});
|
|
24
|
+
return { refund };
|
|
25
|
+
} catch (err) {
|
|
26
|
+
const message = err instanceof Error ? err.message : "Refund failed";
|
|
27
|
+
return { error: message, status: 400 };
|
|
28
|
+
}
|
|
24
29
|
},
|
|
25
30
|
);
|