@86d-app/payments 0.0.4 → 0.0.13

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 (63) hide show
  1. package/.turbo/turbo-build.log +1 -0
  2. package/AGENTS.md +52 -45
  3. package/README.md +26 -5
  4. package/dist/__tests__/controllers.test.d.ts +2 -0
  5. package/dist/__tests__/controllers.test.d.ts.map +1 -0
  6. package/dist/__tests__/edge-cases.test.d.ts +2 -0
  7. package/dist/__tests__/edge-cases.test.d.ts.map +1 -0
  8. package/dist/__tests__/endpoint-security.test.d.ts +2 -0
  9. package/dist/__tests__/endpoint-security.test.d.ts.map +1 -0
  10. package/dist/__tests__/financial-safety.test.d.ts +2 -0
  11. package/dist/__tests__/financial-safety.test.d.ts.map +1 -0
  12. package/dist/__tests__/service-impl.test.d.ts +2 -0
  13. package/dist/__tests__/service-impl.test.d.ts.map +1 -0
  14. package/dist/admin/components/index.d.ts +2 -0
  15. package/dist/admin/components/index.d.ts.map +1 -0
  16. package/dist/admin/components/payments-admin.d.ts +2 -0
  17. package/dist/admin/components/payments-admin.d.ts.map +1 -0
  18. package/dist/admin/endpoints/create-refund.d.ts +20 -0
  19. package/dist/admin/endpoints/create-refund.d.ts.map +1 -0
  20. package/dist/admin/endpoints/get-intent.d.ts +16 -0
  21. package/dist/admin/endpoints/get-intent.d.ts.map +1 -0
  22. package/dist/admin/endpoints/index.d.ts +63 -0
  23. package/dist/admin/endpoints/index.d.ts.map +1 -0
  24. package/dist/admin/endpoints/list-intents.d.ts +22 -0
  25. package/dist/admin/endpoints/list-intents.d.ts.map +1 -0
  26. package/dist/admin/endpoints/list-refunds.d.ts +10 -0
  27. package/dist/admin/endpoints/list-refunds.d.ts.map +1 -0
  28. package/dist/index.d.ts +11 -0
  29. package/dist/index.d.ts.map +1 -0
  30. package/dist/schema.d.ts +169 -0
  31. package/dist/schema.d.ts.map +1 -0
  32. package/dist/service-impl.d.ts +4 -0
  33. package/dist/service-impl.d.ts.map +1 -0
  34. package/dist/service.d.ts +125 -0
  35. package/dist/service.d.ts.map +1 -0
  36. package/dist/store/endpoints/cancel-intent.d.ts +16 -0
  37. package/dist/store/endpoints/cancel-intent.d.ts.map +1 -0
  38. package/dist/store/endpoints/confirm-intent.d.ts +16 -0
  39. package/dist/store/endpoints/confirm-intent.d.ts.map +1 -0
  40. package/dist/store/endpoints/create-intent.d.ts +15 -0
  41. package/dist/store/endpoints/create-intent.d.ts.map +1 -0
  42. package/dist/store/endpoints/delete-method.d.ts +16 -0
  43. package/dist/store/endpoints/delete-method.d.ts.map +1 -0
  44. package/dist/store/endpoints/get-intent.d.ts +16 -0
  45. package/dist/store/endpoints/get-intent.d.ts.map +1 -0
  46. package/dist/store/endpoints/index.d.ts +83 -0
  47. package/dist/store/endpoints/index.d.ts.map +1 -0
  48. package/dist/store/endpoints/list-methods.d.ts +12 -0
  49. package/dist/store/endpoints/list-methods.d.ts.map +1 -0
  50. package/package.json +3 -3
  51. package/src/__tests__/controllers.test.ts +1043 -0
  52. package/src/__tests__/edge-cases.test.ts +547 -0
  53. package/src/__tests__/endpoint-security.test.ts +360 -0
  54. package/src/__tests__/financial-safety.test.ts +576 -0
  55. package/src/__tests__/service-impl.test.ts +30 -5
  56. package/src/admin/endpoints/create-refund.ts +11 -6
  57. package/src/service-impl.ts +60 -0
  58. package/src/store/endpoints/cancel-intent.ts +20 -4
  59. package/src/store/endpoints/confirm-intent.ts +20 -4
  60. package/src/store/endpoints/create-intent.ts +18 -5
  61. package/src/store/endpoints/delete-method.ts +11 -1
  62. package/src/store/endpoints/get-intent.ts +1 -1
  63. 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
- const refund = await controller.createRefund({
19
- intentId: ctx.params.id,
20
- amount: ctx.body.amount,
21
- reason: ctx.body.reason,
22
- });
23
- return { refund };
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
  );