@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,1043 @@
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
+ describe("payment controller edge cases", () => {
7
+ let mockData: ReturnType<typeof createMockDataService>;
8
+ let controller: ReturnType<typeof createPaymentController>;
9
+
10
+ beforeEach(() => {
11
+ mockData = createMockDataService();
12
+ controller = createPaymentController(mockData);
13
+ });
14
+
15
+ // ── createIntent edge cases ─────────────────────────────────────────
16
+
17
+ describe("createIntent edge cases", () => {
18
+ it("each intent gets a unique id", async () => {
19
+ const ids = new Set<string>();
20
+ for (let i = 1; i <= 20; i++) {
21
+ const intent = await controller.createIntent({ amount: 100 * i });
22
+ ids.add(intent.id);
23
+ }
24
+ expect(ids.size).toBe(20);
25
+ });
26
+
27
+ it("rejects zero amount", async () => {
28
+ await expect(controller.createIntent({ amount: 0 })).rejects.toThrow(
29
+ "Amount must be a positive integer",
30
+ );
31
+ });
32
+
33
+ it("rejects negative amount", async () => {
34
+ await expect(controller.createIntent({ amount: -500 })).rejects.toThrow(
35
+ "Amount must be a positive integer",
36
+ );
37
+ });
38
+
39
+ it("handles very large amount", async () => {
40
+ const intent = await controller.createIntent({
41
+ amount: Number.MAX_SAFE_INTEGER,
42
+ });
43
+ expect(intent.amount).toBe(Number.MAX_SAFE_INTEGER);
44
+ });
45
+
46
+ it("rejects fractional amount", async () => {
47
+ await expect(controller.createIntent({ amount: 99.99 })).rejects.toThrow(
48
+ "Amount must be a positive integer",
49
+ );
50
+ });
51
+
52
+ it("preserves special characters in metadata keys and values", async () => {
53
+ const meta = {
54
+ "key with spaces": "value with spaces",
55
+ "unicode-\u00e9\u00e8": "\u00fc\u00f6\u00e4",
56
+ nested: { deep: { value: true } },
57
+ array: [1, 2, 3],
58
+ };
59
+ const intent = await controller.createIntent({
60
+ amount: 1000,
61
+ metadata: meta,
62
+ });
63
+ expect(intent.metadata).toEqual(meta);
64
+ });
65
+
66
+ it("createdAt and updatedAt are the same on creation", async () => {
67
+ const intent = await controller.createIntent({ amount: 500 });
68
+ expect(intent.createdAt.getTime()).toBe(intent.updatedAt.getTime());
69
+ });
70
+
71
+ it("handles empty string for optional string fields", async () => {
72
+ const intent = await controller.createIntent({
73
+ amount: 1000,
74
+ customerId: "",
75
+ email: "",
76
+ orderId: "",
77
+ checkoutSessionId: "",
78
+ });
79
+ expect(intent.customerId).toBe("");
80
+ expect(intent.email).toBe("");
81
+ expect(intent.orderId).toBe("");
82
+ expect(intent.checkoutSessionId).toBe("");
83
+ });
84
+
85
+ it("provider returning undefined providerMetadata defaults to empty object", async () => {
86
+ const mockProvider: PaymentProvider = {
87
+ createIntent: vi.fn().mockResolvedValue({
88
+ providerIntentId: "pi_no_meta",
89
+ status: "pending",
90
+ }),
91
+ confirmIntent: vi.fn(),
92
+ cancelIntent: vi.fn(),
93
+ createRefund: vi.fn(),
94
+ };
95
+ const ctrl = createPaymentController(mockData, mockProvider);
96
+ const intent = await ctrl.createIntent({ amount: 1000 });
97
+ expect(intent.providerMetadata).toEqual({});
98
+ });
99
+
100
+ it("stores intent in data service under paymentIntent entity type", async () => {
101
+ await controller.createIntent({ amount: 1000 });
102
+ await controller.createIntent({ amount: 2000 });
103
+ expect(mockData.size("paymentIntent")).toBe(2);
104
+ });
105
+ });
106
+
107
+ // ── getIntent edge cases ────────────────────────────────────────────
108
+
109
+ describe("getIntent edge cases", () => {
110
+ it("returns null for empty string id", async () => {
111
+ const result = await controller.getIntent("");
112
+ expect(result).toBeNull();
113
+ });
114
+
115
+ it("returns correct intent among many", async () => {
116
+ const intents = [];
117
+ for (let i = 1; i <= 15; i++) {
118
+ const intent = await controller.createIntent({ amount: i * 100 });
119
+ intents.push(intent);
120
+ }
121
+ const middle = await controller.getIntent(intents[7].id);
122
+ expect(middle).not.toBeNull();
123
+ expect(middle?.amount).toBe(800);
124
+ });
125
+
126
+ it("reflects updated status after confirm", async () => {
127
+ const intent = await controller.createIntent({ amount: 1000 });
128
+ await controller.confirmIntent(intent.id);
129
+ const fetched = await controller.getIntent(intent.id);
130
+ expect(fetched?.status).toBe("succeeded");
131
+ });
132
+ });
133
+
134
+ // ── confirmIntent edge cases ────────────────────────────────────────
135
+
136
+ describe("confirmIntent edge cases", () => {
137
+ it("confirm after cancel throws status guard error", async () => {
138
+ const intent = await controller.createIntent({ amount: 1000 });
139
+ await controller.cancelIntent(intent.id);
140
+ await expect(controller.confirmIntent(intent.id)).rejects.toThrow(
141
+ "Cannot confirm intent in 'cancelled' state",
142
+ );
143
+ });
144
+
145
+ it("confirm after refund throws status guard error", async () => {
146
+ const intent = await controller.createIntent({ amount: 1000 });
147
+ await controller.confirmIntent(intent.id);
148
+ await controller.createRefund({ intentId: intent.id });
149
+ await expect(controller.confirmIntent(intent.id)).rejects.toThrow(
150
+ "Cannot confirm intent in 'refunded' state",
151
+ );
152
+ });
153
+
154
+ it("provider confirmIntent returning failed preserves that status", async () => {
155
+ const mockProvider: PaymentProvider = {
156
+ createIntent: vi.fn().mockResolvedValue({
157
+ providerIntentId: "pi_fail_confirm",
158
+ status: "pending",
159
+ providerMetadata: {},
160
+ }),
161
+ confirmIntent: vi.fn().mockResolvedValue({
162
+ status: "failed",
163
+ providerMetadata: { error: "authentication_required" },
164
+ }),
165
+ cancelIntent: vi.fn(),
166
+ createRefund: vi.fn(),
167
+ };
168
+ const ctrl = createPaymentController(mockData, mockProvider);
169
+ const intent = await ctrl.createIntent({ amount: 2000 });
170
+ const confirmed = await ctrl.confirmIntent(intent.id);
171
+ expect(confirmed?.status).toBe("failed");
172
+ expect(confirmed?.providerMetadata).toEqual({
173
+ error: "authentication_required",
174
+ });
175
+ });
176
+
177
+ it("confirm preserves original intent data", async () => {
178
+ const intent = await controller.createIntent({
179
+ amount: 5000,
180
+ customerId: "cust_x",
181
+ email: "x@test.com",
182
+ orderId: "ord_x",
183
+ metadata: { foo: "bar" },
184
+ });
185
+ const confirmed = await controller.confirmIntent(intent.id);
186
+ expect(confirmed?.amount).toBe(5000);
187
+ expect(confirmed?.customerId).toBe("cust_x");
188
+ expect(confirmed?.email).toBe("x@test.com");
189
+ expect(confirmed?.orderId).toBe("ord_x");
190
+ expect(confirmed?.metadata).toEqual({ foo: "bar" });
191
+ });
192
+
193
+ it("provider confirmIntent with undefined providerMetadata preserves existing", async () => {
194
+ const mockProvider: PaymentProvider = {
195
+ createIntent: vi.fn().mockResolvedValue({
196
+ providerIntentId: "pi_keep_meta",
197
+ status: "pending",
198
+ providerMetadata: { original: "keep" },
199
+ }),
200
+ confirmIntent: vi.fn().mockResolvedValue({
201
+ status: "succeeded",
202
+ }),
203
+ cancelIntent: vi.fn(),
204
+ createRefund: vi.fn(),
205
+ };
206
+ const ctrl = createPaymentController(mockData, mockProvider);
207
+ const intent = await ctrl.createIntent({ amount: 1000 });
208
+ const confirmed = await ctrl.confirmIntent(intent.id);
209
+ expect(confirmed?.providerMetadata).toEqual({ original: "keep" });
210
+ });
211
+ });
212
+
213
+ // ── cancelIntent edge cases ─────────────────────────────────────────
214
+
215
+ describe("cancelIntent edge cases", () => {
216
+ it("cancel after confirm throws status guard error", async () => {
217
+ const intent = await controller.createIntent({ amount: 1000 });
218
+ await controller.confirmIntent(intent.id);
219
+ await expect(controller.cancelIntent(intent.id)).rejects.toThrow(
220
+ "Cannot cancel intent in 'succeeded' state",
221
+ );
222
+ });
223
+
224
+ it("cancel preserves original intent data", async () => {
225
+ const intent = await controller.createIntent({
226
+ amount: 3000,
227
+ customerId: "cust_y",
228
+ email: "y@test.com",
229
+ currency: "GBP",
230
+ });
231
+ const cancelled = await controller.cancelIntent(intent.id);
232
+ expect(cancelled?.amount).toBe(3000);
233
+ expect(cancelled?.customerId).toBe("cust_y");
234
+ expect(cancelled?.email).toBe("y@test.com");
235
+ expect(cancelled?.currency).toBe("GBP");
236
+ });
237
+
238
+ it("provider cancelIntent with undefined providerMetadata preserves existing", async () => {
239
+ const mockProvider: PaymentProvider = {
240
+ createIntent: vi.fn().mockResolvedValue({
241
+ providerIntentId: "pi_cancel_meta",
242
+ status: "pending",
243
+ providerMetadata: { keep: "this" },
244
+ }),
245
+ confirmIntent: vi.fn(),
246
+ cancelIntent: vi.fn().mockResolvedValue({}),
247
+ createRefund: vi.fn(),
248
+ };
249
+ const ctrl = createPaymentController(mockData, mockProvider);
250
+ const intent = await ctrl.createIntent({ amount: 1000 });
251
+ const cancelled = await ctrl.cancelIntent(intent.id);
252
+ expect(cancelled?.providerMetadata).toEqual({ keep: "this" });
253
+ });
254
+ });
255
+
256
+ // ── listIntents pagination edge cases ───────────────────────────────
257
+
258
+ describe("listIntents pagination edge cases", () => {
259
+ it("returns empty array with take=0", async () => {
260
+ await controller.createIntent({ amount: 100 });
261
+ await controller.createIntent({ amount: 200 });
262
+ const result = await controller.listIntents({ take: 0 });
263
+ expect(result).toHaveLength(0);
264
+ });
265
+
266
+ it("returns empty array when skip exceeds total", async () => {
267
+ await controller.createIntent({ amount: 100 });
268
+ const result = await controller.listIntents({ skip: 100 });
269
+ expect(result).toHaveLength(0);
270
+ });
271
+
272
+ it("handles take larger than total items", async () => {
273
+ await controller.createIntent({ amount: 100 });
274
+ const result = await controller.listIntents({ take: 1000 });
275
+ expect(result).toHaveLength(1);
276
+ });
277
+
278
+ it("paginates through all intents correctly", async () => {
279
+ for (let i = 0; i < 7; i++) {
280
+ await controller.createIntent({ amount: (i + 1) * 100 });
281
+ }
282
+ const page1 = await controller.listIntents({ take: 3, skip: 0 });
283
+ const page2 = await controller.listIntents({ take: 3, skip: 3 });
284
+ const page3 = await controller.listIntents({ take: 3, skip: 6 });
285
+ expect(page1).toHaveLength(3);
286
+ expect(page2).toHaveLength(3);
287
+ expect(page3).toHaveLength(1);
288
+ const allIds = [
289
+ ...page1.map((i) => i.id),
290
+ ...page2.map((i) => i.id),
291
+ ...page3.map((i) => i.id),
292
+ ];
293
+ expect(new Set(allIds).size).toBe(7);
294
+ });
295
+
296
+ it("returns all with empty params object", async () => {
297
+ await controller.createIntent({ amount: 100 });
298
+ await controller.createIntent({ amount: 200 });
299
+ const result = await controller.listIntents({});
300
+ expect(result).toHaveLength(2);
301
+ });
302
+
303
+ it("returns all with undefined params", async () => {
304
+ await controller.createIntent({ amount: 100 });
305
+ const result = await controller.listIntents();
306
+ expect(result).toHaveLength(1);
307
+ });
308
+
309
+ it("combines pagination with filter", async () => {
310
+ for (let i = 0; i < 5; i++) {
311
+ await controller.createIntent({
312
+ amount: (i + 1) * 100,
313
+ customerId: "cust_page",
314
+ });
315
+ }
316
+ await controller.createIntent({
317
+ amount: 999,
318
+ customerId: "cust_other",
319
+ });
320
+ const result = await controller.listIntents({
321
+ customerId: "cust_page",
322
+ take: 2,
323
+ skip: 1,
324
+ });
325
+ expect(result).toHaveLength(2);
326
+ for (const r of result) {
327
+ expect(r.customerId).toBe("cust_page");
328
+ }
329
+ });
330
+
331
+ it("returns empty array when no intents exist", async () => {
332
+ const result = await controller.listIntents();
333
+ expect(result).toHaveLength(0);
334
+ });
335
+ });
336
+
337
+ // ── savePaymentMethod edge cases ────────────────────────────────────
338
+
339
+ describe("savePaymentMethod edge cases", () => {
340
+ it("each method gets a unique id", async () => {
341
+ const ids = new Set<string>();
342
+ for (let i = 0; i < 10; i++) {
343
+ const method = await controller.savePaymentMethod({
344
+ customerId: "cust_1",
345
+ providerMethodId: `pm_${i}`,
346
+ });
347
+ ids.add(method.id);
348
+ }
349
+ expect(ids.size).toBe(10);
350
+ });
351
+
352
+ it("createdAt and updatedAt match on creation", async () => {
353
+ const method = await controller.savePaymentMethod({
354
+ customerId: "cust_1",
355
+ providerMethodId: "pm_1",
356
+ });
357
+ expect(method.createdAt.getTime()).toBe(method.updatedAt.getTime());
358
+ });
359
+
360
+ it("handles special characters in providerMethodId", async () => {
361
+ const method = await controller.savePaymentMethod({
362
+ customerId: "cust_1",
363
+ providerMethodId: "pm_stripe:tok_visa/test+special",
364
+ });
365
+ expect(method.providerMethodId).toBe("pm_stripe:tok_visa/test+special");
366
+ });
367
+
368
+ it("handles expiryMonth boundary values", async () => {
369
+ const jan = await controller.savePaymentMethod({
370
+ customerId: "cust_1",
371
+ providerMethodId: "pm_jan",
372
+ expiryMonth: 1,
373
+ });
374
+ const dec = await controller.savePaymentMethod({
375
+ customerId: "cust_1",
376
+ providerMethodId: "pm_dec",
377
+ expiryMonth: 12,
378
+ });
379
+ expect(jan.expiryMonth).toBe(1);
380
+ expect(dec.expiryMonth).toBe(12);
381
+ });
382
+
383
+ it("clearing defaults does not affect non-default methods", async () => {
384
+ const nonDefault = await controller.savePaymentMethod({
385
+ customerId: "cust_1",
386
+ providerMethodId: "pm_1",
387
+ isDefault: false,
388
+ });
389
+ await controller.savePaymentMethod({
390
+ customerId: "cust_1",
391
+ providerMethodId: "pm_2",
392
+ isDefault: true,
393
+ });
394
+ // Adding another default should clear pm_2 but not pm_1
395
+ await controller.savePaymentMethod({
396
+ customerId: "cust_1",
397
+ providerMethodId: "pm_3",
398
+ isDefault: true,
399
+ });
400
+ const check = await controller.getPaymentMethod(nonDefault.id);
401
+ expect(check?.isDefault).toBe(false);
402
+ });
403
+
404
+ it("stores under paymentMethod entity type", async () => {
405
+ await controller.savePaymentMethod({
406
+ customerId: "cust_1",
407
+ providerMethodId: "pm_1",
408
+ });
409
+ expect(mockData.size("paymentMethod")).toBe(1);
410
+ });
411
+ });
412
+
413
+ // ── deletePaymentMethod edge cases ──────────────────────────────────
414
+
415
+ describe("deletePaymentMethod edge cases", () => {
416
+ it("double delete returns false on second attempt", async () => {
417
+ const method = await controller.savePaymentMethod({
418
+ customerId: "cust_1",
419
+ providerMethodId: "pm_1",
420
+ });
421
+ expect(await controller.deletePaymentMethod(method.id)).toBe(true);
422
+ expect(await controller.deletePaymentMethod(method.id)).toBe(false);
423
+ });
424
+
425
+ it("deleting one method does not affect others", async () => {
426
+ const m1 = await controller.savePaymentMethod({
427
+ customerId: "cust_1",
428
+ providerMethodId: "pm_1",
429
+ });
430
+ const m2 = await controller.savePaymentMethod({
431
+ customerId: "cust_1",
432
+ providerMethodId: "pm_2",
433
+ });
434
+ await controller.deletePaymentMethod(m1.id);
435
+ const remaining = await controller.getPaymentMethod(m2.id);
436
+ expect(remaining).not.toBeNull();
437
+ expect(remaining?.providerMethodId).toBe("pm_2");
438
+ });
439
+
440
+ it("returns false for empty string id", async () => {
441
+ const result = await controller.deletePaymentMethod("");
442
+ expect(result).toBe(false);
443
+ });
444
+
445
+ it("deleted method no longer appears in listPaymentMethods", async () => {
446
+ const m1 = await controller.savePaymentMethod({
447
+ customerId: "cust_1",
448
+ providerMethodId: "pm_1",
449
+ });
450
+ await controller.savePaymentMethod({
451
+ customerId: "cust_1",
452
+ providerMethodId: "pm_2",
453
+ });
454
+ await controller.deletePaymentMethod(m1.id);
455
+ const methods = await controller.listPaymentMethods("cust_1");
456
+ expect(methods).toHaveLength(1);
457
+ expect(methods[0].providerMethodId).toBe("pm_2");
458
+ });
459
+
460
+ it("removes from data store", async () => {
461
+ const method = await controller.savePaymentMethod({
462
+ customerId: "cust_1",
463
+ providerMethodId: "pm_1",
464
+ });
465
+ expect(mockData.size("paymentMethod")).toBe(1);
466
+ await controller.deletePaymentMethod(method.id);
467
+ expect(mockData.size("paymentMethod")).toBe(0);
468
+ });
469
+ });
470
+
471
+ // ── listPaymentMethods edge cases ───────────────────────────────────
472
+
473
+ describe("listPaymentMethods edge cases", () => {
474
+ it("does not return methods from other customers", async () => {
475
+ await controller.savePaymentMethod({
476
+ customerId: "cust_1",
477
+ providerMethodId: "pm_1",
478
+ });
479
+ await controller.savePaymentMethod({
480
+ customerId: "cust_2",
481
+ providerMethodId: "pm_2",
482
+ });
483
+ const methods = await controller.listPaymentMethods("cust_1");
484
+ expect(methods).toHaveLength(1);
485
+ expect(methods[0].customerId).toBe("cust_1");
486
+ });
487
+
488
+ it("handles many methods for one customer", async () => {
489
+ for (let i = 0; i < 25; i++) {
490
+ await controller.savePaymentMethod({
491
+ customerId: "cust_1",
492
+ providerMethodId: `pm_${i}`,
493
+ });
494
+ }
495
+ const methods = await controller.listPaymentMethods("cust_1");
496
+ expect(methods).toHaveLength(25);
497
+ });
498
+ });
499
+
500
+ // ── createRefund edge cases ─────────────────────────────────────────
501
+
502
+ describe("createRefund edge cases", () => {
503
+ it("refund amount equal to intent amount creates full refund", async () => {
504
+ const intent = await controller.createIntent({ amount: 5000 });
505
+ await controller.confirmIntent(intent.id);
506
+ const refund = await controller.createRefund({
507
+ intentId: intent.id,
508
+ amount: 5000,
509
+ });
510
+ expect(refund.amount).toBe(5000);
511
+ });
512
+
513
+ it("rejects refund with zero amount", async () => {
514
+ const intent = await controller.createIntent({ amount: 5000 });
515
+ await controller.confirmIntent(intent.id);
516
+ await expect(
517
+ controller.createRefund({
518
+ intentId: intent.id,
519
+ amount: 0,
520
+ }),
521
+ ).rejects.toThrow("Refund amount must be positive");
522
+ });
523
+
524
+ it("refund with very long reason string", async () => {
525
+ const longReason = "R".repeat(5000);
526
+ const intent = await controller.createIntent({ amount: 1000 });
527
+ await controller.confirmIntent(intent.id);
528
+ const refund = await controller.createRefund({
529
+ intentId: intent.id,
530
+ reason: longReason,
531
+ });
532
+ expect(refund.reason).toBe(longReason);
533
+ });
534
+
535
+ it("refund with special characters in reason", async () => {
536
+ const intent = await controller.createIntent({ amount: 1000 });
537
+ await controller.confirmIntent(intent.id);
538
+ const refund = await controller.createRefund({
539
+ intentId: intent.id,
540
+ reason: 'Customer said: "I don\'t want it!" <script>alert(1)</script>',
541
+ });
542
+ expect(refund.reason).toBe(
543
+ 'Customer said: "I don\'t want it!" <script>alert(1)</script>',
544
+ );
545
+ });
546
+
547
+ it("multiple refunds are all stored under refund entity type", async () => {
548
+ const intent = await controller.createIntent({ amount: 10000 });
549
+ await controller.confirmIntent(intent.id);
550
+ await controller.createRefund({
551
+ intentId: intent.id,
552
+ amount: 1000,
553
+ });
554
+ await controller.createRefund({
555
+ intentId: intent.id,
556
+ amount: 2000,
557
+ });
558
+ await controller.createRefund({
559
+ intentId: intent.id,
560
+ amount: 3000,
561
+ });
562
+ expect(mockData.size("refund")).toBe(3);
563
+ });
564
+
565
+ it("partial refund on already-refunded intent respects cap", async () => {
566
+ const intent = await controller.createIntent({ amount: 10000 });
567
+ await controller.confirmIntent(intent.id);
568
+ await controller.createRefund({
569
+ intentId: intent.id,
570
+ amount: 5000,
571
+ });
572
+ // Second refund within remaining cap should succeed
573
+ const r2 = await controller.createRefund({
574
+ intentId: intent.id,
575
+ amount: 3000,
576
+ });
577
+ expect(r2.amount).toBe(3000);
578
+ expect(r2.status).toBe("succeeded");
579
+ });
580
+
581
+ it("rejects refund exceeding remaining refundable amount", async () => {
582
+ const intent = await controller.createIntent({ amount: 10000 });
583
+ await controller.confirmIntent(intent.id);
584
+ await controller.createRefund({
585
+ intentId: intent.id,
586
+ amount: 7000,
587
+ });
588
+ await expect(
589
+ controller.createRefund({
590
+ intentId: intent.id,
591
+ amount: 5000,
592
+ }),
593
+ ).rejects.toThrow(
594
+ "Refund amount 5000 exceeds remaining refundable amount 3000",
595
+ );
596
+ });
597
+
598
+ it("provider createRefund receives correct parameters", async () => {
599
+ const mockProvider: PaymentProvider = {
600
+ createIntent: vi.fn().mockResolvedValue({
601
+ providerIntentId: "pi_check_params",
602
+ status: "succeeded",
603
+ providerMetadata: {},
604
+ }),
605
+ confirmIntent: vi.fn().mockResolvedValue({
606
+ status: "succeeded",
607
+ providerMetadata: {},
608
+ }),
609
+ cancelIntent: vi.fn(),
610
+ createRefund: vi.fn().mockResolvedValue({
611
+ providerRefundId: "re_check",
612
+ status: "succeeded",
613
+ }),
614
+ };
615
+ const ctrl = createPaymentController(mockData, mockProvider);
616
+ const intent = await ctrl.createIntent({ amount: 5000 });
617
+ await ctrl.confirmIntent(intent.id);
618
+ await ctrl.createRefund({
619
+ intentId: intent.id,
620
+ amount: 2000,
621
+ reason: "Damaged item",
622
+ });
623
+ expect(mockProvider.createRefund).toHaveBeenCalledWith({
624
+ providerIntentId: "pi_check_params",
625
+ amount: 2000,
626
+ reason: "Damaged item",
627
+ });
628
+ });
629
+ });
630
+
631
+ // ── listRefunds edge cases ──────────────────────────────────────────
632
+
633
+ describe("listRefunds edge cases", () => {
634
+ it("returns empty array for nonexistent intent id", async () => {
635
+ const refunds = await controller.listRefunds("nonexistent");
636
+ expect(refunds).toHaveLength(0);
637
+ });
638
+
639
+ it("does not return refunds from other intents", async () => {
640
+ const i1 = await controller.createIntent({ amount: 1000 });
641
+ await controller.confirmIntent(i1.id);
642
+ const i2 = await controller.createIntent({ amount: 2000 });
643
+ await controller.confirmIntent(i2.id);
644
+ await controller.createRefund({ intentId: i1.id, amount: 500 });
645
+ await controller.createRefund({ intentId: i2.id, amount: 1000 });
646
+ const refunds = await controller.listRefunds(i1.id);
647
+ expect(refunds).toHaveLength(1);
648
+ expect(refunds[0].amount).toBe(500);
649
+ });
650
+ });
651
+
652
+ // ── handleWebhookEvent edge cases ───────────────────────────────────
653
+
654
+ describe("handleWebhookEvent edge cases", () => {
655
+ it("multiple webhook events update status sequentially", async () => {
656
+ const mockProvider: PaymentProvider = {
657
+ createIntent: vi.fn().mockResolvedValue({
658
+ providerIntentId: "pi_multi_hook",
659
+ status: "pending",
660
+ providerMetadata: {},
661
+ }),
662
+ confirmIntent: vi.fn(),
663
+ cancelIntent: vi.fn(),
664
+ createRefund: vi.fn(),
665
+ };
666
+ const ctrl = createPaymentController(mockData, mockProvider);
667
+ await ctrl.createIntent({ amount: 5000 });
668
+
669
+ // pending -> processing
670
+ let result = await ctrl.handleWebhookEvent({
671
+ providerIntentId: "pi_multi_hook",
672
+ status: "processing",
673
+ });
674
+ expect(result?.status).toBe("processing");
675
+
676
+ // processing -> succeeded
677
+ result = await ctrl.handleWebhookEvent({
678
+ providerIntentId: "pi_multi_hook",
679
+ status: "succeeded",
680
+ });
681
+ expect(result?.status).toBe("succeeded");
682
+ });
683
+
684
+ it("accumulates providerMetadata across multiple webhook events", async () => {
685
+ const mockProvider: PaymentProvider = {
686
+ createIntent: vi.fn().mockResolvedValue({
687
+ providerIntentId: "pi_accum",
688
+ status: "pending",
689
+ providerMetadata: { step: "created" },
690
+ }),
691
+ confirmIntent: vi.fn(),
692
+ cancelIntent: vi.fn(),
693
+ createRefund: vi.fn(),
694
+ };
695
+ const ctrl = createPaymentController(mockData, mockProvider);
696
+ await ctrl.createIntent({ amount: 1000 });
697
+
698
+ await ctrl.handleWebhookEvent({
699
+ providerIntentId: "pi_accum",
700
+ status: "processing",
701
+ providerMetadata: { step: "processing", attempts: 1 },
702
+ });
703
+
704
+ const result = await ctrl.handleWebhookEvent({
705
+ providerIntentId: "pi_accum",
706
+ status: "succeeded",
707
+ providerMetadata: { step: "succeeded", finalizedAt: "2026-01-01" },
708
+ });
709
+ // The last providerMetadata overrides previous keys and adds new ones
710
+ expect(result?.providerMetadata).toEqual({
711
+ step: "succeeded",
712
+ attempts: 1,
713
+ finalizedAt: "2026-01-01",
714
+ });
715
+ });
716
+
717
+ it("webhook event to refunded status", async () => {
718
+ const mockProvider: PaymentProvider = {
719
+ createIntent: vi.fn().mockResolvedValue({
720
+ providerIntentId: "pi_hook_refund",
721
+ status: "succeeded",
722
+ providerMetadata: {},
723
+ }),
724
+ confirmIntent: vi.fn(),
725
+ cancelIntent: vi.fn(),
726
+ createRefund: vi.fn(),
727
+ };
728
+ const ctrl = createPaymentController(mockData, mockProvider);
729
+ await ctrl.createIntent({ amount: 3000 });
730
+
731
+ const result = await ctrl.handleWebhookEvent({
732
+ providerIntentId: "pi_hook_refund",
733
+ status: "refunded",
734
+ });
735
+ expect(result?.status).toBe("refunded");
736
+ });
737
+
738
+ it("webhook event updates persisted data", async () => {
739
+ const mockProvider: PaymentProvider = {
740
+ createIntent: vi.fn().mockResolvedValue({
741
+ providerIntentId: "pi_persist",
742
+ status: "pending",
743
+ providerMetadata: {},
744
+ }),
745
+ confirmIntent: vi.fn(),
746
+ cancelIntent: vi.fn(),
747
+ createRefund: vi.fn(),
748
+ };
749
+ const ctrl = createPaymentController(mockData, mockProvider);
750
+ const intent = await ctrl.createIntent({ amount: 1000 });
751
+
752
+ await ctrl.handleWebhookEvent({
753
+ providerIntentId: "pi_persist",
754
+ status: "succeeded",
755
+ });
756
+
757
+ // Verify via getIntent that the change is persisted
758
+ const fetched = await ctrl.getIntent(intent.id);
759
+ expect(fetched?.status).toBe("succeeded");
760
+ });
761
+ });
762
+
763
+ // ── handleWebhookRefund edge cases ──────────────────────────────────
764
+
765
+ describe("handleWebhookRefund edge cases", () => {
766
+ it("webhook refund creates retrievable refund record", async () => {
767
+ const mockProvider: PaymentProvider = {
768
+ createIntent: vi.fn().mockResolvedValue({
769
+ providerIntentId: "pi_wr_get",
770
+ status: "succeeded",
771
+ providerMetadata: {},
772
+ }),
773
+ confirmIntent: vi.fn(),
774
+ cancelIntent: vi.fn(),
775
+ createRefund: vi.fn(),
776
+ };
777
+ const ctrl = createPaymentController(mockData, mockProvider);
778
+ await ctrl.createIntent({ amount: 5000 });
779
+
780
+ const result = await ctrl.handleWebhookRefund({
781
+ providerIntentId: "pi_wr_get",
782
+ providerRefundId: "re_get",
783
+ amount: 3000,
784
+ });
785
+
786
+ // The refund should be retrievable
787
+ const refund = result ? await ctrl.getRefund(result.refund.id) : null;
788
+ expect(refund).not.toBeNull();
789
+ expect(refund?.amount).toBe(3000);
790
+ });
791
+
792
+ it("webhook refund shows up in listRefunds", async () => {
793
+ const mockProvider: PaymentProvider = {
794
+ createIntent: vi.fn().mockResolvedValue({
795
+ providerIntentId: "pi_wr_list",
796
+ status: "succeeded",
797
+ providerMetadata: {},
798
+ }),
799
+ confirmIntent: vi.fn(),
800
+ cancelIntent: vi.fn(),
801
+ createRefund: vi.fn(),
802
+ };
803
+ const ctrl = createPaymentController(mockData, mockProvider);
804
+ const intent = await ctrl.createIntent({ amount: 5000 });
805
+
806
+ await ctrl.handleWebhookRefund({
807
+ providerIntentId: "pi_wr_list",
808
+ providerRefundId: "re_list",
809
+ amount: 2000,
810
+ });
811
+
812
+ const refunds = await ctrl.listRefunds(intent.id);
813
+ expect(refunds).toHaveLength(1);
814
+ expect(refunds[0].providerRefundId).toBe("re_list");
815
+ });
816
+
817
+ it("multiple webhook refunds on same intent", async () => {
818
+ const mockProvider: PaymentProvider = {
819
+ createIntent: vi.fn().mockResolvedValue({
820
+ providerIntentId: "pi_wr_multi",
821
+ status: "succeeded",
822
+ providerMetadata: {},
823
+ }),
824
+ confirmIntent: vi.fn(),
825
+ cancelIntent: vi.fn(),
826
+ createRefund: vi.fn(),
827
+ };
828
+ const ctrl = createPaymentController(mockData, mockProvider);
829
+ const intent = await ctrl.createIntent({ amount: 10000 });
830
+
831
+ await ctrl.handleWebhookRefund({
832
+ providerIntentId: "pi_wr_multi",
833
+ providerRefundId: "re_1",
834
+ amount: 3000,
835
+ });
836
+ await ctrl.handleWebhookRefund({
837
+ providerIntentId: "pi_wr_multi",
838
+ providerRefundId: "re_2",
839
+ amount: 4000,
840
+ });
841
+
842
+ const refunds = await ctrl.listRefunds(intent.id);
843
+ expect(refunds).toHaveLength(2);
844
+ const total = refunds.reduce((s, r) => s + r.amount, 0);
845
+ expect(total).toBe(7000);
846
+ });
847
+
848
+ it("webhook refund without reason leaves it undefined", async () => {
849
+ const mockProvider: PaymentProvider = {
850
+ createIntent: vi.fn().mockResolvedValue({
851
+ providerIntentId: "pi_wr_no_reason",
852
+ status: "succeeded",
853
+ providerMetadata: {},
854
+ }),
855
+ confirmIntent: vi.fn(),
856
+ cancelIntent: vi.fn(),
857
+ createRefund: vi.fn(),
858
+ };
859
+ const ctrl = createPaymentController(mockData, mockProvider);
860
+ await ctrl.createIntent({ amount: 2000 });
861
+
862
+ const result = await ctrl.handleWebhookRefund({
863
+ providerIntentId: "pi_wr_no_reason",
864
+ providerRefundId: "re_no_reason",
865
+ });
866
+ expect(result?.refund.reason).toBeUndefined();
867
+ });
868
+ });
869
+
870
+ // ── Data store consistency ───────────────────────────────────────────
871
+
872
+ describe("data store consistency", () => {
873
+ it("different entity types do not interfere", async () => {
874
+ await controller.createIntent({ amount: 1000 });
875
+ await controller.savePaymentMethod({
876
+ customerId: "cust_1",
877
+ providerMethodId: "pm_1",
878
+ });
879
+ const intent = await controller.createIntent({ amount: 2000 });
880
+ await controller.confirmIntent(intent.id);
881
+ await controller.createRefund({ intentId: intent.id });
882
+
883
+ expect(mockData.size("paymentIntent")).toBe(2);
884
+ expect(mockData.size("paymentMethod")).toBe(1);
885
+ expect(mockData.size("refund")).toBe(1);
886
+ });
887
+
888
+ it("store is empty on fresh controller", async () => {
889
+ expect(mockData.size("paymentIntent")).toBe(0);
890
+ expect(mockData.size("paymentMethod")).toBe(0);
891
+ expect(mockData.size("refund")).toBe(0);
892
+ });
893
+ });
894
+
895
+ // ── Complex lifecycle ───────────────────────────────────────────────
896
+
897
+ describe("complex lifecycle scenarios", () => {
898
+ it("full payment lifecycle: create, confirm, refund, verify", async () => {
899
+ // Create
900
+ const intent = await controller.createIntent({
901
+ amount: 10000,
902
+ customerId: "cust_lifecycle",
903
+ currency: "EUR",
904
+ orderId: "ord_lifecycle",
905
+ });
906
+ expect(intent.status).toBe("pending");
907
+
908
+ // Confirm
909
+ const confirmed = await controller.confirmIntent(intent.id);
910
+ expect(confirmed?.status).toBe("succeeded");
911
+
912
+ // Partial refund
913
+ const r1 = await controller.createRefund({
914
+ intentId: intent.id,
915
+ amount: 3000,
916
+ reason: "Item damaged",
917
+ });
918
+ expect(r1.amount).toBe(3000);
919
+
920
+ // Check intent marked refunded
921
+ const afterRefund = await controller.getIntent(intent.id);
922
+ expect(afterRefund?.status).toBe("refunded");
923
+
924
+ // Another partial refund (within cap: 10000 - 3000 = 7000 remaining)
925
+ const r2 = await controller.createRefund({
926
+ intentId: intent.id,
927
+ amount: 2000,
928
+ reason: "Item missing",
929
+ });
930
+ expect(r2.amount).toBe(2000);
931
+
932
+ // Refund exceeding cap should fail (5000 remaining)
933
+ await expect(
934
+ controller.createRefund({
935
+ intentId: intent.id,
936
+ amount: 6000,
937
+ }),
938
+ ).rejects.toThrow("exceeds remaining refundable amount");
939
+
940
+ // List all refunds
941
+ const refunds = await controller.listRefunds(intent.id);
942
+ expect(refunds).toHaveLength(2);
943
+ });
944
+
945
+ it("multiple customers with intents, methods, and refunds", async () => {
946
+ // Customer A
947
+ const intentA = await controller.createIntent({
948
+ amount: 5000,
949
+ customerId: "cust_a",
950
+ });
951
+ await controller.savePaymentMethod({
952
+ customerId: "cust_a",
953
+ providerMethodId: "pm_a1",
954
+ isDefault: true,
955
+ });
956
+
957
+ // Customer B
958
+ const intentB = await controller.createIntent({
959
+ amount: 7500,
960
+ customerId: "cust_b",
961
+ });
962
+ await controller.savePaymentMethod({
963
+ customerId: "cust_b",
964
+ providerMethodId: "pm_b1",
965
+ isDefault: true,
966
+ });
967
+
968
+ // Confirm both
969
+ await controller.confirmIntent(intentA.id);
970
+ await controller.confirmIntent(intentB.id);
971
+
972
+ // Full refund A only (defaults to intent amount)
973
+ await controller.createRefund({ intentId: intentA.id });
974
+
975
+ // Verify states
976
+ const aList = await controller.listIntents({
977
+ customerId: "cust_a",
978
+ });
979
+ expect(aList).toHaveLength(1);
980
+ expect(aList[0].status).toBe("refunded");
981
+
982
+ const bList = await controller.listIntents({
983
+ customerId: "cust_b",
984
+ });
985
+ expect(bList).toHaveLength(1);
986
+ expect(bList[0].status).toBe("succeeded");
987
+
988
+ // Methods are independent
989
+ expect(await controller.listPaymentMethods("cust_a")).toHaveLength(1);
990
+ expect(await controller.listPaymentMethods("cust_b")).toHaveLength(1);
991
+
992
+ // Can't refund A again (fully refunded)
993
+ await expect(
994
+ controller.createRefund({ intentId: intentA.id }),
995
+ ).rejects.toThrow("exceeds remaining refundable amount");
996
+ });
997
+
998
+ it("webhook event and manual operations interleave correctly", async () => {
999
+ const mockProvider: PaymentProvider = {
1000
+ createIntent: vi.fn().mockResolvedValue({
1001
+ providerIntentId: "pi_interleave",
1002
+ status: "pending",
1003
+ providerMetadata: {},
1004
+ }),
1005
+ confirmIntent: vi.fn().mockResolvedValue({
1006
+ status: "succeeded",
1007
+ providerMetadata: {},
1008
+ }),
1009
+ cancelIntent: vi.fn(),
1010
+ createRefund: vi.fn().mockResolvedValue({
1011
+ providerRefundId: "re_interleave",
1012
+ status: "succeeded",
1013
+ }),
1014
+ };
1015
+ const ctrl = createPaymentController(mockData, mockProvider);
1016
+ const intent = await ctrl.createIntent({ amount: 8000 });
1017
+
1018
+ // Webhook arrives with processing status
1019
+ await ctrl.handleWebhookEvent({
1020
+ providerIntentId: "pi_interleave",
1021
+ status: "processing",
1022
+ providerMetadata: { step: "3ds" },
1023
+ });
1024
+
1025
+ // Manually confirm
1026
+ const confirmed = await ctrl.confirmIntent(intent.id);
1027
+ expect(confirmed?.status).toBe("succeeded");
1028
+
1029
+ // Refund via webhook
1030
+ const result = await ctrl.handleWebhookRefund({
1031
+ providerIntentId: "pi_interleave",
1032
+ providerRefundId: "re_wh",
1033
+ amount: 4000,
1034
+ });
1035
+ expect(result?.intent.status).toBe("refunded");
1036
+ expect(result?.refund.amount).toBe(4000);
1037
+
1038
+ // listIntents shows refunded
1039
+ const list = await ctrl.listIntents({ status: "refunded" });
1040
+ expect(list).toHaveLength(1);
1041
+ });
1042
+ });
1043
+ });