@86d-app/payments 0.0.3

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.
@@ -0,0 +1,589 @@
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("createPaymentController", () => {
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 ─────────────────────────────────────────────────────
16
+
17
+ describe("createIntent", () => {
18
+ it("creates a payment intent with default values", async () => {
19
+ const intent = await controller.createIntent({ amount: 5000 });
20
+ expect(intent.id).toBeDefined();
21
+ expect(intent.amount).toBe(5000);
22
+ expect(intent.currency).toBe("USD");
23
+ expect(intent.status).toBe("pending");
24
+ expect(intent.metadata).toEqual({});
25
+ expect(intent.providerMetadata).toEqual({});
26
+ expect(intent.createdAt).toBeInstanceOf(Date);
27
+ });
28
+
29
+ it("creates a payment intent with custom currency", async () => {
30
+ const intent = await controller.createIntent({
31
+ amount: 1000,
32
+ currency: "EUR",
33
+ });
34
+ expect(intent.currency).toBe("EUR");
35
+ });
36
+
37
+ it("creates a payment intent with customer and order", async () => {
38
+ const intent = await controller.createIntent({
39
+ amount: 2500,
40
+ customerId: "cust_1",
41
+ email: "test@example.com",
42
+ orderId: "order_1",
43
+ checkoutSessionId: "sess_1",
44
+ metadata: { source: "web" },
45
+ });
46
+ expect(intent.customerId).toBe("cust_1");
47
+ expect(intent.email).toBe("test@example.com");
48
+ expect(intent.orderId).toBe("order_1");
49
+ expect(intent.checkoutSessionId).toBe("sess_1");
50
+ expect(intent.metadata).toEqual({ source: "web" });
51
+ });
52
+
53
+ it("delegates to provider when one is supplied", async () => {
54
+ const mockProvider: PaymentProvider = {
55
+ createIntent: vi.fn().mockResolvedValue({
56
+ providerIntentId: "pi_stripe_123",
57
+ status: "processing",
58
+ providerMetadata: { clientSecret: "secret_abc" },
59
+ }),
60
+ confirmIntent: vi.fn(),
61
+ cancelIntent: vi.fn(),
62
+ createRefund: vi.fn(),
63
+ };
64
+ const ctrl = createPaymentController(mockData, mockProvider);
65
+ const intent = await ctrl.createIntent({ amount: 3000 });
66
+ expect(intent.providerIntentId).toBe("pi_stripe_123");
67
+ expect(intent.status).toBe("processing");
68
+ expect(intent.providerMetadata).toEqual({
69
+ clientSecret: "secret_abc",
70
+ });
71
+ expect(mockProvider.createIntent).toHaveBeenCalledOnce();
72
+ });
73
+
74
+ it("persists the intent to the data store", async () => {
75
+ const intent = await controller.createIntent({ amount: 1000 });
76
+ const stored = await controller.getIntent(intent.id);
77
+ expect(stored).not.toBeNull();
78
+ expect(stored?.amount).toBe(1000);
79
+ });
80
+ });
81
+
82
+ // ── getIntent ────────────────────────────────────────────────────────
83
+
84
+ describe("getIntent", () => {
85
+ it("returns an existing intent", async () => {
86
+ const created = await controller.createIntent({ amount: 4200 });
87
+ const found = await controller.getIntent(created.id);
88
+ expect(found).not.toBeNull();
89
+ expect(found?.id).toBe(created.id);
90
+ });
91
+
92
+ it("returns null for non-existent intent", async () => {
93
+ const found = await controller.getIntent("nonexistent");
94
+ expect(found).toBeNull();
95
+ });
96
+ });
97
+
98
+ // ── confirmIntent ────────────────────────────────────────────────────
99
+
100
+ describe("confirmIntent", () => {
101
+ it("marks intent as succeeded", async () => {
102
+ const intent = await controller.createIntent({ amount: 5000 });
103
+ const confirmed = await controller.confirmIntent(intent.id);
104
+ expect(confirmed?.status).toBe("succeeded");
105
+ });
106
+
107
+ it("returns null for non-existent intent", async () => {
108
+ const result = await controller.confirmIntent("missing_id");
109
+ expect(result).toBeNull();
110
+ });
111
+
112
+ it("returns already-succeeded intent unchanged", async () => {
113
+ const intent = await controller.createIntent({ amount: 5000 });
114
+ await controller.confirmIntent(intent.id);
115
+ const again = await controller.confirmIntent(intent.id);
116
+ expect(again?.status).toBe("succeeded");
117
+ });
118
+
119
+ it("delegates to provider when available", async () => {
120
+ const mockProvider: PaymentProvider = {
121
+ createIntent: vi.fn().mockResolvedValue({
122
+ providerIntentId: "pi_ext",
123
+ status: "pending",
124
+ providerMetadata: {},
125
+ }),
126
+ confirmIntent: vi.fn().mockResolvedValue({
127
+ status: "succeeded",
128
+ providerMetadata: { confirmed: true },
129
+ }),
130
+ cancelIntent: vi.fn(),
131
+ createRefund: vi.fn(),
132
+ };
133
+ const ctrl = createPaymentController(mockData, mockProvider);
134
+ const intent = await ctrl.createIntent({ amount: 1000 });
135
+ const confirmed = await ctrl.confirmIntent(intent.id);
136
+ expect(confirmed?.status).toBe("succeeded");
137
+ expect(confirmed?.providerMetadata).toEqual({ confirmed: true });
138
+ });
139
+ });
140
+
141
+ // ── cancelIntent ─────────────────────────────────────────────────────
142
+
143
+ describe("cancelIntent", () => {
144
+ it("marks intent as cancelled", async () => {
145
+ const intent = await controller.createIntent({ amount: 2000 });
146
+ const cancelled = await controller.cancelIntent(intent.id);
147
+ expect(cancelled?.status).toBe("cancelled");
148
+ });
149
+
150
+ it("returns null for non-existent intent", async () => {
151
+ const result = await controller.cancelIntent("nope");
152
+ expect(result).toBeNull();
153
+ });
154
+
155
+ it("returns already-cancelled intent unchanged", async () => {
156
+ const intent = await controller.createIntent({ amount: 2000 });
157
+ await controller.cancelIntent(intent.id);
158
+ const again = await controller.cancelIntent(intent.id);
159
+ expect(again?.status).toBe("cancelled");
160
+ });
161
+
162
+ it("delegates to provider when available", async () => {
163
+ const mockProvider: PaymentProvider = {
164
+ createIntent: vi.fn().mockResolvedValue({
165
+ providerIntentId: "pi_ext",
166
+ status: "pending",
167
+ providerMetadata: {},
168
+ }),
169
+ confirmIntent: vi.fn(),
170
+ cancelIntent: vi.fn().mockResolvedValue({
171
+ providerMetadata: { voided: true },
172
+ }),
173
+ createRefund: vi.fn(),
174
+ };
175
+ const ctrl = createPaymentController(mockData, mockProvider);
176
+ const intent = await ctrl.createIntent({ amount: 1000 });
177
+ const cancelled = await ctrl.cancelIntent(intent.id);
178
+ expect(cancelled?.status).toBe("cancelled");
179
+ expect(cancelled?.providerMetadata).toEqual({ voided: true });
180
+ });
181
+ });
182
+
183
+ // ── listIntents ──────────────────────────────────────────────────────
184
+
185
+ describe("listIntents", () => {
186
+ it("lists all intents when no filters given", async () => {
187
+ await controller.createIntent({ amount: 100 });
188
+ await controller.createIntent({ amount: 200 });
189
+ const all = await controller.listIntents();
190
+ expect(all).toHaveLength(2);
191
+ });
192
+
193
+ it("filters by customerId", async () => {
194
+ await controller.createIntent({
195
+ amount: 100,
196
+ customerId: "cust_a",
197
+ });
198
+ await controller.createIntent({
199
+ amount: 200,
200
+ customerId: "cust_b",
201
+ });
202
+ const results = await controller.listIntents({
203
+ customerId: "cust_a",
204
+ });
205
+ expect(results).toHaveLength(1);
206
+ expect(results[0].customerId).toBe("cust_a");
207
+ });
208
+
209
+ it("filters by status", async () => {
210
+ const intent = await controller.createIntent({ amount: 100 });
211
+ await controller.confirmIntent(intent.id);
212
+ await controller.createIntent({ amount: 200 });
213
+ const succeeded = await controller.listIntents({
214
+ status: "succeeded",
215
+ });
216
+ expect(succeeded).toHaveLength(1);
217
+ });
218
+
219
+ it("filters by orderId", async () => {
220
+ await controller.createIntent({
221
+ amount: 100,
222
+ orderId: "ord_1",
223
+ });
224
+ await controller.createIntent({ amount: 200 });
225
+ const results = await controller.listIntents({ orderId: "ord_1" });
226
+ expect(results).toHaveLength(1);
227
+ });
228
+
229
+ it("supports take and skip", async () => {
230
+ await controller.createIntent({ amount: 100 });
231
+ await controller.createIntent({ amount: 200 });
232
+ await controller.createIntent({ amount: 300 });
233
+ const page = await controller.listIntents({ take: 2, skip: 1 });
234
+ expect(page).toHaveLength(2);
235
+ });
236
+ });
237
+
238
+ // ── savePaymentMethod ────────────────────────────────────────────────
239
+
240
+ describe("savePaymentMethod", () => {
241
+ it("saves a payment method", async () => {
242
+ const method = await controller.savePaymentMethod({
243
+ customerId: "cust_1",
244
+ providerMethodId: "pm_stripe_abc",
245
+ type: "card",
246
+ last4: "4242",
247
+ brand: "visa",
248
+ expiryMonth: 12,
249
+ expiryYear: 2025,
250
+ });
251
+ expect(method.id).toBeDefined();
252
+ expect(method.customerId).toBe("cust_1");
253
+ expect(method.last4).toBe("4242");
254
+ expect(method.isDefault).toBe(false);
255
+ });
256
+
257
+ it("sets isDefault and clears other defaults", async () => {
258
+ const first = await controller.savePaymentMethod({
259
+ customerId: "cust_1",
260
+ providerMethodId: "pm_1",
261
+ isDefault: true,
262
+ });
263
+ expect(first.isDefault).toBe(true);
264
+
265
+ const second = await controller.savePaymentMethod({
266
+ customerId: "cust_1",
267
+ providerMethodId: "pm_2",
268
+ isDefault: true,
269
+ });
270
+ expect(second.isDefault).toBe(true);
271
+
272
+ // First should now be non-default
273
+ const updated = await controller.getPaymentMethod(first.id);
274
+ expect(updated?.isDefault).toBe(false);
275
+ });
276
+
277
+ it("defaults type to card", async () => {
278
+ const method = await controller.savePaymentMethod({
279
+ customerId: "cust_1",
280
+ providerMethodId: "pm_1",
281
+ });
282
+ expect(method.type).toBe("card");
283
+ });
284
+ });
285
+
286
+ // ── getPaymentMethod / listPaymentMethods ────────────────────────────
287
+
288
+ describe("getPaymentMethod", () => {
289
+ it("returns a saved payment method", async () => {
290
+ const saved = await controller.savePaymentMethod({
291
+ customerId: "cust_1",
292
+ providerMethodId: "pm_1",
293
+ });
294
+ const found = await controller.getPaymentMethod(saved.id);
295
+ expect(found?.providerMethodId).toBe("pm_1");
296
+ });
297
+
298
+ it("returns null for non-existent method", async () => {
299
+ const found = await controller.getPaymentMethod("nonexistent");
300
+ expect(found).toBeNull();
301
+ });
302
+ });
303
+
304
+ describe("listPaymentMethods", () => {
305
+ it("lists payment methods for a customer", async () => {
306
+ await controller.savePaymentMethod({
307
+ customerId: "cust_1",
308
+ providerMethodId: "pm_1",
309
+ });
310
+ await controller.savePaymentMethod({
311
+ customerId: "cust_1",
312
+ providerMethodId: "pm_2",
313
+ });
314
+ await controller.savePaymentMethod({
315
+ customerId: "cust_2",
316
+ providerMethodId: "pm_3",
317
+ });
318
+ const methods = await controller.listPaymentMethods("cust_1");
319
+ expect(methods).toHaveLength(2);
320
+ });
321
+ });
322
+
323
+ // ── deletePaymentMethod ──────────────────────────────────────────────
324
+
325
+ describe("deletePaymentMethod", () => {
326
+ it("deletes an existing payment method", async () => {
327
+ const method = await controller.savePaymentMethod({
328
+ customerId: "cust_1",
329
+ providerMethodId: "pm_1",
330
+ });
331
+ const result = await controller.deletePaymentMethod(method.id);
332
+ expect(result).toBe(true);
333
+ const found = await controller.getPaymentMethod(method.id);
334
+ expect(found).toBeNull();
335
+ });
336
+
337
+ it("returns false for non-existent method", async () => {
338
+ const result = await controller.deletePaymentMethod("nope");
339
+ expect(result).toBe(false);
340
+ });
341
+ });
342
+
343
+ // ── createRefund ─────────────────────────────────────────────────────
344
+
345
+ describe("createRefund", () => {
346
+ it("creates a full refund", async () => {
347
+ const intent = await controller.createIntent({ amount: 5000 });
348
+ const refund = await controller.createRefund({
349
+ intentId: intent.id,
350
+ });
351
+ expect(refund.amount).toBe(5000);
352
+ expect(refund.status).toBe("succeeded");
353
+ expect(refund.paymentIntentId).toBe(intent.id);
354
+
355
+ // Intent should be marked as refunded
356
+ const updated = await controller.getIntent(intent.id);
357
+ expect(updated?.status).toBe("refunded");
358
+ });
359
+
360
+ it("creates a partial refund", async () => {
361
+ const intent = await controller.createIntent({ amount: 5000 });
362
+ const refund = await controller.createRefund({
363
+ intentId: intent.id,
364
+ amount: 2000,
365
+ reason: "Partial return",
366
+ });
367
+ expect(refund.amount).toBe(2000);
368
+ expect(refund.reason).toBe("Partial return");
369
+ });
370
+
371
+ it("throws for non-existent intent", async () => {
372
+ await expect(
373
+ controller.createRefund({ intentId: "missing" }),
374
+ ).rejects.toThrow("Payment intent not found");
375
+ });
376
+
377
+ it("delegates to provider when available", async () => {
378
+ const mockProvider: PaymentProvider = {
379
+ createIntent: vi.fn().mockResolvedValue({
380
+ providerIntentId: "pi_ext",
381
+ status: "succeeded",
382
+ providerMetadata: {},
383
+ }),
384
+ confirmIntent: vi.fn(),
385
+ cancelIntent: vi.fn(),
386
+ createRefund: vi.fn().mockResolvedValue({
387
+ providerRefundId: "re_ext_123",
388
+ status: "succeeded",
389
+ }),
390
+ };
391
+ const ctrl = createPaymentController(mockData, mockProvider);
392
+ const intent = await ctrl.createIntent({ amount: 3000 });
393
+ const refund = await ctrl.createRefund({ intentId: intent.id });
394
+ expect(refund.providerRefundId).toBe("re_ext_123");
395
+ expect(mockProvider.createRefund).toHaveBeenCalledOnce();
396
+ });
397
+
398
+ it("generates local refund ID for local-only intents", async () => {
399
+ const intent = await controller.createIntent({ amount: 2000 });
400
+ const refund = await controller.createRefund({
401
+ intentId: intent.id,
402
+ });
403
+ expect(refund.providerRefundId).toMatch(/^local_re_/);
404
+ });
405
+
406
+ 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
+ const mockProvider: PaymentProvider = {
410
+ createIntent: vi.fn().mockResolvedValue({
411
+ providerIntentId: "pi_stripe_orphan",
412
+ status: "succeeded",
413
+ providerMetadata: {},
414
+ }),
415
+ confirmIntent: vi.fn(),
416
+ cancelIntent: vi.fn(),
417
+ createRefund: vi.fn(),
418
+ };
419
+ // Create intent WITH provider
420
+ const ctrlWithProvider = createPaymentController(mockData, mockProvider);
421
+ const intent = await ctrlWithProvider.createIntent({ amount: 5000 });
422
+
423
+ // Try to refund WITHOUT provider (simulates misconfiguration)
424
+ const ctrlWithout = createPaymentController(mockData);
425
+ await expect(
426
+ ctrlWithout.createRefund({ intentId: intent.id }),
427
+ ).rejects.toThrow(
428
+ "Cannot refund: payment was created through a provider but no provider is configured",
429
+ );
430
+ });
431
+ });
432
+
433
+ // ── getRefund / listRefunds ──────────────────────────────────────────
434
+
435
+ describe("getRefund", () => {
436
+ it("returns an existing refund", async () => {
437
+ const intent = await controller.createIntent({ amount: 1000 });
438
+ const refund = await controller.createRefund({
439
+ intentId: intent.id,
440
+ });
441
+ const found = await controller.getRefund(refund.id);
442
+ expect(found?.id).toBe(refund.id);
443
+ });
444
+
445
+ it("returns null for non-existent refund", async () => {
446
+ const found = await controller.getRefund("missing");
447
+ expect(found).toBeNull();
448
+ });
449
+ });
450
+
451
+ describe("listRefunds", () => {
452
+ it("lists refunds for an intent", async () => {
453
+ const intent = await controller.createIntent({ amount: 5000 });
454
+ await controller.createRefund({
455
+ intentId: intent.id,
456
+ amount: 1000,
457
+ });
458
+ await controller.createRefund({
459
+ intentId: intent.id,
460
+ amount: 2000,
461
+ });
462
+ const refunds = await controller.listRefunds(intent.id);
463
+ expect(refunds).toHaveLength(2);
464
+ });
465
+
466
+ it("returns empty array for intent with no refunds", async () => {
467
+ const intent = await controller.createIntent({ amount: 1000 });
468
+ const refunds = await controller.listRefunds(intent.id);
469
+ expect(refunds).toHaveLength(0);
470
+ });
471
+ });
472
+
473
+ // ── handleWebhookEvent ───────────────────────────────────────────────
474
+
475
+ describe("handleWebhookEvent", () => {
476
+ it("updates intent status by providerIntentId", async () => {
477
+ const mockProvider: PaymentProvider = {
478
+ createIntent: vi.fn().mockResolvedValue({
479
+ providerIntentId: "pi_hook_1",
480
+ status: "pending",
481
+ providerMetadata: {},
482
+ }),
483
+ confirmIntent: vi.fn(),
484
+ cancelIntent: vi.fn(),
485
+ createRefund: vi.fn(),
486
+ };
487
+ const ctrl = createPaymentController(mockData, mockProvider);
488
+ await ctrl.createIntent({ amount: 5000 });
489
+
490
+ const result = await ctrl.handleWebhookEvent({
491
+ providerIntentId: "pi_hook_1",
492
+ status: "succeeded",
493
+ providerMetadata: { webhook: true },
494
+ });
495
+ expect(result?.status).toBe("succeeded");
496
+ expect(result?.providerMetadata).toMatchObject({ webhook: true });
497
+ });
498
+
499
+ it("returns null when no matching intent", async () => {
500
+ const result = await controller.handleWebhookEvent({
501
+ providerIntentId: "pi_unknown",
502
+ status: "succeeded",
503
+ });
504
+ expect(result).toBeNull();
505
+ });
506
+
507
+ it("skips update when status already matches", async () => {
508
+ const mockProvider: PaymentProvider = {
509
+ createIntent: vi.fn().mockResolvedValue({
510
+ providerIntentId: "pi_dup",
511
+ status: "succeeded",
512
+ providerMetadata: {},
513
+ }),
514
+ confirmIntent: vi.fn(),
515
+ cancelIntent: vi.fn(),
516
+ createRefund: vi.fn(),
517
+ };
518
+ const ctrl = createPaymentController(mockData, mockProvider);
519
+ await ctrl.createIntent({ amount: 1000 });
520
+
521
+ const result = await ctrl.handleWebhookEvent({
522
+ providerIntentId: "pi_dup",
523
+ status: "succeeded",
524
+ });
525
+ // Returns intent as-is without updating
526
+ expect(result?.status).toBe("succeeded");
527
+ });
528
+ });
529
+
530
+ // ── handleWebhookRefund ──────────────────────────────────────────────
531
+
532
+ describe("handleWebhookRefund", () => {
533
+ it("creates a refund record from webhook", async () => {
534
+ const mockProvider: PaymentProvider = {
535
+ createIntent: vi.fn().mockResolvedValue({
536
+ providerIntentId: "pi_refhook",
537
+ status: "succeeded",
538
+ providerMetadata: {},
539
+ }),
540
+ confirmIntent: vi.fn(),
541
+ cancelIntent: vi.fn(),
542
+ createRefund: vi.fn(),
543
+ };
544
+ const ctrl = createPaymentController(mockData, mockProvider);
545
+ await ctrl.createIntent({ amount: 5000 });
546
+
547
+ const result = await ctrl.handleWebhookRefund({
548
+ providerIntentId: "pi_refhook",
549
+ providerRefundId: "re_hook_1",
550
+ amount: 3000,
551
+ reason: "Customer request",
552
+ });
553
+ expect(result).not.toBeNull();
554
+ expect(result?.refund.amount).toBe(3000);
555
+ expect(result?.refund.providerRefundId).toBe("re_hook_1");
556
+ expect(result?.refund.reason).toBe("Customer request");
557
+ expect(result?.intent.status).toBe("refunded");
558
+ });
559
+
560
+ it("returns null when no matching intent", async () => {
561
+ const result = await controller.handleWebhookRefund({
562
+ providerIntentId: "pi_notfound",
563
+ providerRefundId: "re_hook",
564
+ });
565
+ expect(result).toBeNull();
566
+ });
567
+
568
+ it("defaults refund amount to intent amount", async () => {
569
+ const mockProvider: PaymentProvider = {
570
+ createIntent: vi.fn().mockResolvedValue({
571
+ providerIntentId: "pi_full_ref",
572
+ status: "succeeded",
573
+ providerMetadata: {},
574
+ }),
575
+ confirmIntent: vi.fn(),
576
+ cancelIntent: vi.fn(),
577
+ createRefund: vi.fn(),
578
+ };
579
+ const ctrl = createPaymentController(mockData, mockProvider);
580
+ await ctrl.createIntent({ amount: 8000 });
581
+
582
+ const result = await ctrl.handleWebhookRefund({
583
+ providerIntentId: "pi_full_ref",
584
+ providerRefundId: "re_full",
585
+ });
586
+ expect(result?.refund.amount).toBe(8000);
587
+ });
588
+ });
589
+ });
@@ -0,0 +1 @@
1
+ export { PaymentsAdmin } from "./payments-admin";
@@ -0,0 +1,27 @@
1
+ <div>
2
+ <div className="mb-6">
3
+ <h1 className="font-bold text-2xl text-foreground">Payments</h1>
4
+ <p className="mt-1 text-muted-foreground text-sm">
5
+ Track payment intents and refunds
6
+ </p>
7
+ </div>
8
+
9
+ <div className="mb-6 flex gap-1 border-border border-b">
10
+ {["intents", "refunds"].map((t) => (
11
+ <button
12
+ key={t}
13
+ type="button"
14
+ onClick={() => props.onTabChange(t)}
15
+ className={`-mb-px border-b-2 px-4 py-2 font-medium text-sm capitalize transition-colors ${
16
+ props.tab === t
17
+ ? "border-foreground text-foreground"
18
+ : "border-transparent text-muted-foreground hover:text-foreground"
19
+ }`}
20
+ >
21
+ {t}
22
+ </button>
23
+ ))}
24
+ </div>
25
+
26
+ {props.tabContent}
27
+ </div>