create-charcole 2.2.2 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/CHANGELOG.md +33 -2
  2. package/README.md +92 -5
  3. package/bin/index.js +121 -1
  4. package/package.json +1 -1
  5. package/packages/payments/CHANGELOG.md +14 -0
  6. package/packages/payments/README.md +222 -0
  7. package/packages/payments/charcoles-payments-1.0.0.tgz +0 -0
  8. package/packages/payments/package.json +61 -0
  9. package/packages/payments/smoke-test.js +20 -0
  10. package/packages/payments/src/__tests__/LemonSqueezyAdapter.test.js +236 -0
  11. package/packages/payments/src/__tests__/StripeAdapter.test.js +139 -0
  12. package/packages/payments/src/__tests__/payments.service.test.js +131 -0
  13. package/packages/payments/src/__tests__/webhookUtils.test.js +47 -0
  14. package/packages/payments/src/adapters/LemonSqueezyAdapter.js +150 -0
  15. package/packages/payments/src/adapters/PaymentAdapter.js +109 -0
  16. package/packages/payments/src/adapters/StripeAdapter.js +114 -0
  17. package/packages/payments/src/controllers/payments.controller.js +48 -0
  18. package/packages/payments/src/errors/PaymentError.js +8 -0
  19. package/packages/payments/src/helpers/webhookUtils.js +27 -0
  20. package/packages/payments/src/index.d.ts +87 -0
  21. package/packages/payments/src/index.js +6 -0
  22. package/packages/payments/src/routes/payments.routes.js +41 -0
  23. package/packages/payments/src/schemas/payments.schemas.js +24 -0
  24. package/packages/payments/src/services/payments.service.js +68 -0
  25. package/plan-2.3.0.md +1756 -0
  26. package/template/js/.env.example +19 -1
  27. package/template/js/README.md +133 -5
  28. package/template/js/basePackage.json +1 -1
  29. package/template/js/src/app.js +17 -0
  30. package/template/js/src/config/env.js +8 -0
  31. package/template/js/src/lib/swagger/SWAGGER_GUIDE.md +34 -0
  32. package/template/js/src/modules/payments/__tests__/payments.controller.test.js +342 -0
  33. package/template/js/src/modules/payments/__tests__/payments.routes.test.js +256 -0
  34. package/template/js/src/modules/payments/__tests__/payments.schemas.test.js +94 -0
  35. package/template/js/src/modules/payments/__tests__/payments.service.test.js +141 -0
  36. package/template/js/src/modules/payments/package.json +7 -0
  37. package/template/js/src/modules/payments/payments.adapter.js +47 -0
  38. package/template/js/src/modules/payments/payments.constants.js +20 -0
  39. package/template/js/src/modules/payments/payments.controller.js +85 -0
  40. package/template/js/src/modules/payments/payments.routes.js +125 -0
  41. package/template/js/src/modules/payments/payments.schemas.js +28 -0
  42. package/template/js/src/modules/payments/payments.service.js +34 -0
  43. package/template/js/src/modules/swagger/package.json +1 -1
  44. package/template/js/src/routes/index.js +16 -0
  45. package/template/ts/.env.example +17 -1
  46. package/template/ts/README.md +135 -5
  47. package/template/ts/basePackage.json +1 -1
  48. package/template/ts/src/app.ts +13 -0
  49. package/template/ts/src/config/env.ts +7 -0
  50. package/template/ts/src/lib/swagger/SWAGGER_GUIDE.md +34 -0
  51. package/template/ts/src/modules/payments/__tests__/payments.controller.test.ts +282 -0
  52. package/template/ts/src/modules/payments/__tests__/payments.routes.test.ts +256 -0
  53. package/template/ts/src/modules/payments/__tests__/payments.schemas.test.ts +94 -0
  54. package/template/ts/src/modules/payments/__tests__/payments.service.test.ts +135 -0
  55. package/template/ts/src/modules/payments/package.json +7 -0
  56. package/template/ts/src/modules/payments/payments.adapter.ts +74 -0
  57. package/template/ts/src/modules/payments/payments.constants.ts +18 -0
  58. package/template/ts/src/modules/payments/payments.controller.ts +104 -0
  59. package/template/ts/src/modules/payments/payments.routes.ts +125 -0
  60. package/template/ts/src/modules/payments/payments.schemas.ts +31 -0
  61. package/template/ts/src/modules/payments/payments.service.ts +51 -0
  62. package/template/ts/src/modules/payments/payments.types.ts +56 -0
  63. package/template/ts/src/modules/swagger/package.json +1 -1
  64. package/template/ts/src/routes/index.ts +8 -0
@@ -0,0 +1,282 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+
3
+ vi.mock('../payments.service.ts', () => ({
4
+ createPayment: vi.fn(),
5
+ refundPayment: vi.fn(),
6
+ getPaymentStatus: vi.fn(),
7
+ processWebhook: vi.fn(),
8
+ }))
9
+
10
+ vi.mock('../../../utils/logger.ts', () => ({
11
+ logger: {
12
+ info: vi.fn(),
13
+ warn: vi.fn(),
14
+ error: vi.fn(),
15
+ },
16
+ }))
17
+
18
+ import * as controller from '../payments.controller.ts'
19
+ import * as service from '../payments.service.ts'
20
+
21
+ function buildMocks(overrides: Record<string, unknown> = {}) {
22
+ const req = {
23
+ body: {} as unknown,
24
+ params: {} as Record<string, string>,
25
+ headers: {} as Record<string, unknown>,
26
+ ...overrides,
27
+ }
28
+
29
+ const res = {
30
+ status: vi.fn().mockReturnThis(),
31
+ json: vi.fn().mockReturnThis(),
32
+ }
33
+
34
+ const next = vi.fn()
35
+
36
+ return { req, res, next }
37
+ }
38
+
39
+ beforeEach(() => {
40
+ vi.clearAllMocks()
41
+ delete process.env.PAYMENT_PROVIDER
42
+ })
43
+
44
+ describe('createPayment controller', () => {
45
+ it('calls service.createPayment with validated body and responds 201', async () => {
46
+ const result = {
47
+ id: 'pi_test_123',
48
+ clientSecret: 'secret_abc',
49
+ status: 'requires_payment_method',
50
+ amount: 999,
51
+ currency: 'usd',
52
+ metadata: {},
53
+ }
54
+ service.createPayment.mockResolvedValue(result)
55
+
56
+ const { req, res, next } = buildMocks({ body: { amount: 999, currency: 'usd' } })
57
+
58
+ await controller.createPayment(req as any, res as any, next)
59
+
60
+ expect(service.createPayment).toHaveBeenCalledWith({ amount: 999, currency: 'usd', metadata: {} })
61
+ expect(res.status).toHaveBeenCalledWith(201)
62
+ expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ success: true, data: result }))
63
+ expect(next).not.toHaveBeenCalled()
64
+ })
65
+
66
+ it('calls next(err) when schema validation fails', async () => {
67
+ const { req, res, next } = buildMocks({ body: { amount: 'not-a-number', currency: 'usd' } })
68
+
69
+ await controller.createPayment(req as any, res as any, next)
70
+
71
+ expect(next).toHaveBeenCalled()
72
+ expect(res.json).not.toHaveBeenCalled()
73
+ })
74
+
75
+ it('calls next(err) when service.createPayment rejects', async () => {
76
+ const error = new Error('stripe down')
77
+ service.createPayment.mockRejectedValue(error)
78
+
79
+ const { req, res, next } = buildMocks({ body: { amount: 999, currency: 'usd' } })
80
+
81
+ await controller.createPayment(req as any, res as any, next)
82
+
83
+ expect(next).toHaveBeenCalledWith(error)
84
+ })
85
+
86
+ it('currency is lowercased by the schema', async () => {
87
+ const result = {
88
+ id: 'pi_test_123',
89
+ clientSecret: 'secret_abc',
90
+ status: 'requires_payment_method',
91
+ amount: 100,
92
+ currency: 'usd',
93
+ metadata: {},
94
+ }
95
+ service.createPayment.mockResolvedValue(result)
96
+
97
+ const { req, res, next } = buildMocks({ body: { amount: 100, currency: 'USD' } })
98
+
99
+ await controller.createPayment(req as any, res as any, next)
100
+
101
+ expect(service.createPayment).toHaveBeenCalledWith({ amount: 100, currency: 'usd', metadata: {} })
102
+ expect(res.status).toHaveBeenCalledWith(201)
103
+ expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ success: true, data: result }))
104
+ expect(next).not.toHaveBeenCalled()
105
+ })
106
+ })
107
+
108
+ describe('refundPayment controller', () => {
109
+ it('calls service.refundPayment with paymentId and optional amount, responds 200', async () => {
110
+ const result = { id: 're_test_123', status: 'succeeded', amount: 500 }
111
+ service.refundPayment.mockResolvedValue(result)
112
+
113
+ const { req, res, next } = buildMocks({ body: { paymentId: 'pi_123', amount: 500 } })
114
+
115
+ await controller.refundPayment(req as any, res as any, next)
116
+
117
+ expect(service.refundPayment).toHaveBeenCalledWith({ paymentId: 'pi_123', amount: 500 })
118
+ expect(res.status).toHaveBeenCalledWith(200)
119
+ expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ success: true, data: result }))
120
+ expect(next).not.toHaveBeenCalled()
121
+ })
122
+
123
+ it('calls service.refundPayment with just paymentId when amount is omitted', async () => {
124
+ const result = { id: 're_test_456', status: 'succeeded', amount: 0 }
125
+ service.refundPayment.mockResolvedValue(result)
126
+
127
+ const { req, res, next } = buildMocks({ body: { paymentId: 'pi_123' } })
128
+
129
+ await controller.refundPayment(req as any, res as any, next)
130
+
131
+ expect(service.refundPayment).toHaveBeenCalledWith({ paymentId: 'pi_123', amount: undefined })
132
+ expect(res.status).toHaveBeenCalledWith(200)
133
+ expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ success: true, data: result }))
134
+ expect(next).not.toHaveBeenCalled()
135
+ })
136
+
137
+ it('calls next(err) when paymentId is missing from body', async () => {
138
+ const { req, res, next } = buildMocks({ body: { amount: 500 } })
139
+
140
+ await controller.refundPayment(req as any, res as any, next)
141
+
142
+ expect(next).toHaveBeenCalled()
143
+ expect(res.json).not.toHaveBeenCalled()
144
+ })
145
+
146
+ it('calls next(err) when service.refundPayment rejects', async () => {
147
+ const error = new Error('refund failed')
148
+ service.refundPayment.mockRejectedValue(error)
149
+
150
+ const { req, res, next } = buildMocks({ body: { paymentId: 'pi_123', amount: 500 } })
151
+
152
+ await controller.refundPayment(req as any, res as any, next)
153
+
154
+ expect(next).toHaveBeenCalledWith(error)
155
+ })
156
+ })
157
+
158
+ describe('getPaymentStatus controller', () => {
159
+ it('calls service.getPaymentStatus with req.params.paymentId and responds with result', async () => {
160
+ const result = { id: 'pi_123', status: 'paid', amount: 999, currency: 'usd', metadata: {} }
161
+ service.getPaymentStatus.mockResolvedValue(result)
162
+
163
+ const { req, res, next } = buildMocks({ params: { paymentId: 'pi_123' } })
164
+
165
+ await controller.getPaymentStatus(req as any, res as any, next)
166
+
167
+ expect(service.getPaymentStatus).toHaveBeenCalledWith('pi_123')
168
+ expect(res.status).toHaveBeenCalledWith(200)
169
+ expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ success: true, data: result }))
170
+ expect(next).not.toHaveBeenCalled()
171
+ })
172
+
173
+ it('calls next(err) when service rejects', async () => {
174
+ const error = new Error('status lookup failed')
175
+ service.getPaymentStatus.mockRejectedValue(error)
176
+
177
+ const { req, res, next } = buildMocks({ params: { paymentId: 'pi_123' } })
178
+
179
+ await controller.getPaymentStatus(req as any, res as any, next)
180
+
181
+ expect(next).toHaveBeenCalledWith(error)
182
+ })
183
+ })
184
+
185
+ describe('handleWebhook controller', () => {
186
+ it('reads stripe-signature header when PAYMENT_PROVIDER is stripe', async () => {
187
+ process.env.PAYMENT_PROVIDER = 'stripe'
188
+ const result = { event: 'payment_intent.succeeded', data: {}, duplicate: false }
189
+ service.processWebhook.mockResolvedValue(result)
190
+
191
+ const { req, res, next } = buildMocks({
192
+ headers: { 'stripe-signature': 'test-sig' },
193
+ body: Buffer.from('{"id":"evt_123"}'),
194
+ })
195
+
196
+ await controller.handleWebhook(req as any, res as any, next)
197
+
198
+ expect(service.processWebhook).toHaveBeenCalledWith(req.body, 'test-sig')
199
+ expect(res.status).toHaveBeenCalledWith(200)
200
+ expect(res.json).toHaveBeenCalledWith({ received: true })
201
+ expect(next).not.toHaveBeenCalled()
202
+ })
203
+
204
+ it('reads x-signature header when PAYMENT_PROVIDER is lemonsqueezy', async () => {
205
+ process.env.PAYMENT_PROVIDER = 'lemonsqueezy'
206
+ const result = { event: 'order_created', data: {}, duplicate: false }
207
+ service.processWebhook.mockResolvedValue(result)
208
+
209
+ const { req, res, next } = buildMocks({
210
+ headers: { 'x-signature': 'hmac-sig' },
211
+ body: Buffer.from('{"id":"evt_456"}'),
212
+ })
213
+
214
+ await controller.handleWebhook(req as any, res as any, next)
215
+
216
+ expect(service.processWebhook).toHaveBeenCalledWith(req.body, 'hmac-sig')
217
+ expect(res.status).toHaveBeenCalledWith(200)
218
+ expect(res.json).toHaveBeenCalledWith({ received: true })
219
+ expect(next).not.toHaveBeenCalled()
220
+ })
221
+
222
+ it('responds 200 { received: true, duplicate: true } for duplicate events', async () => {
223
+ process.env.PAYMENT_PROVIDER = 'stripe'
224
+ const result = { event: 'payment_intent.succeeded', data: { id: 'evt_789' }, duplicate: true }
225
+ service.processWebhook.mockResolvedValue(result)
226
+
227
+ const { req, res, next } = buildMocks({
228
+ headers: { 'stripe-signature': 'test-sig' },
229
+ body: Buffer.from('{"id":"evt_789"}'),
230
+ })
231
+
232
+ await controller.handleWebhook(req as any, res as any, next)
233
+
234
+ expect(res.status).toHaveBeenCalledWith(200)
235
+ expect(res.json).toHaveBeenCalledWith({ received: true, duplicate: true })
236
+ expect(next).not.toHaveBeenCalled()
237
+ })
238
+
239
+ it('responds 400 when signature header is missing', async () => {
240
+ process.env.PAYMENT_PROVIDER = 'stripe'
241
+ const { req, res, next } = buildMocks({ body: Buffer.from('{"id":"evt_000"}') })
242
+
243
+ await controller.handleWebhook(req as any, res as any, next)
244
+
245
+ expect(res.status).toHaveBeenCalledWith(400)
246
+ expect(res.json).toHaveBeenCalledWith({ error: 'Missing webhook signature header' })
247
+ expect(next).not.toHaveBeenCalled()
248
+ })
249
+
250
+ it('calls next(err) when processWebhook rejects', async () => {
251
+ process.env.PAYMENT_PROVIDER = 'stripe'
252
+ const error = new Error('invalid signature')
253
+ service.processWebhook.mockRejectedValue(error)
254
+
255
+ const { req, res, next } = buildMocks({
256
+ headers: { 'stripe-signature': 'test-sig' },
257
+ body: Buffer.from('{"id":"evt_999"}'),
258
+ })
259
+
260
+ await controller.handleWebhook(req as any, res as any, next)
261
+
262
+ expect(next).toHaveBeenCalledWith(error)
263
+ })
264
+
265
+ it('passes req.body as raw Buffer to processWebhook', async () => {
266
+ process.env.PAYMENT_PROVIDER = 'stripe'
267
+ const result = { event: 'payment_intent.succeeded', data: {}, duplicate: false }
268
+ service.processWebhook.mockResolvedValue(result)
269
+
270
+ const { req, res, next } = buildMocks({
271
+ headers: { 'stripe-signature': 'test-sig' },
272
+ body: Buffer.from('{"id":"evt_buffer"}'),
273
+ })
274
+
275
+ await controller.handleWebhook(req as any, res as any, next)
276
+
277
+ const rawArg = service.processWebhook.mock.calls[0][0]
278
+ expect(Buffer.isBuffer(rawArg)).toBe(true)
279
+ expect(rawArg.toString()).toBe('{"id":"evt_buffer"}')
280
+ expect(next).not.toHaveBeenCalled()
281
+ })
282
+ })
@@ -0,0 +1,256 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import express from 'express'
3
+ import request from 'supertest'
4
+
5
+ vi.mock('../payments.service.ts', () => ({
6
+ createPayment: vi.fn(),
7
+ refundPayment: vi.fn(),
8
+ getPaymentStatus: vi.fn(),
9
+ processWebhook: vi.fn(),
10
+ }))
11
+
12
+ vi.mock('../../../../utils/logger.ts', () => ({
13
+ logger: {
14
+ info: vi.fn(),
15
+ error: vi.fn(),
16
+ warn: vi.fn(),
17
+ },
18
+ }))
19
+
20
+ import paymentsRouter from '../payments.routes.ts'
21
+ import * as service from '../payments.service.ts'
22
+
23
+ function buildTestApp() {
24
+ const app = express()
25
+ app.use('/payments/webhook', express.raw({ type: 'application/json' }))
26
+ app.use(express.json())
27
+ app.use('/payments', paymentsRouter)
28
+ app.use((err, _req, res, _next) => {
29
+ res.status(err.statusCode ?? 500).json({ error: err.message, code: err.code })
30
+ })
31
+ return app
32
+ }
33
+
34
+ let app: express.Express
35
+
36
+ beforeEach(() => {
37
+ vi.clearAllMocks()
38
+ process.env.PAYMENT_PROVIDER = 'stripe'
39
+ app = buildTestApp()
40
+ })
41
+
42
+ afterEach(() => {
43
+ delete process.env.PAYMENT_PROVIDER
44
+ })
45
+
46
+ describe('payments.routes', () => {
47
+ describe('POST /payments/create-intent', () => {
48
+ it('returns 201 with payment data on valid Stripe request', async () => {
49
+ const result = {
50
+ id: 'pi_test',
51
+ clientSecret: 'secret_abc',
52
+ status: 'requires_payment_method',
53
+ amount: 999,
54
+ currency: 'usd',
55
+ }
56
+ service.createPayment.mockResolvedValue(result)
57
+
58
+ const response = await request(app)
59
+ .post('/payments/create-intent')
60
+ .send({ amount: 999, currency: 'usd' })
61
+
62
+ expect(response.status).toBe(201)
63
+ expect(response.body.data.id).toBe('pi_test')
64
+ expect(response.body.data.clientSecret).toBe('secret_abc')
65
+ expect(service.createPayment).toHaveBeenCalledWith({ amount: 999, currency: 'usd', metadata: {} })
66
+ })
67
+
68
+ it('returns 201 with checkoutUrl on valid LemonSqueezy request', async () => {
69
+ const result = {
70
+ id: 'ls_test',
71
+ checkoutUrl: 'https://store.lemonsqueezy.com/checkout/buy',
72
+ status: 'created',
73
+ amount: 999,
74
+ currency: 'usd',
75
+ }
76
+ service.createPayment.mockResolvedValue(result)
77
+
78
+ const response = await request(app)
79
+ .post('/payments/create-intent')
80
+ .send({ amount: 999, currency: 'usd', metadata: { variantId: '12345' } })
81
+
82
+ expect(response.status).toBe(201)
83
+ expect(response.body.data.checkoutUrl).toBe('https://store.lemonsqueezy.com/checkout/buy')
84
+ expect(service.createPayment).toHaveBeenCalledWith({
85
+ amount: 999,
86
+ currency: 'usd',
87
+ metadata: { variantId: '12345' },
88
+ })
89
+ })
90
+
91
+ it('returns 400 when amount is missing', async () => {
92
+ const response = await request(app).post('/payments/create-intent').send({ currency: 'usd' })
93
+
94
+ expect(response.status).toBe(400)
95
+ expect(response.body.error).toBeDefined()
96
+ })
97
+
98
+ it('returns 400 when amount is a float', async () => {
99
+ const response = await request(app).post('/payments/create-intent').send({ amount: 9.99, currency: 'usd' })
100
+
101
+ expect(response.status).toBe(400)
102
+ expect(response.body.error).toBeDefined()
103
+ })
104
+
105
+ it('returns 400 when currency is wrong length', async () => {
106
+ const response = await request(app).post('/payments/create-intent').send({ amount: 100, currency: 'us' })
107
+
108
+ expect(response.status).toBe(400)
109
+ expect(response.body.error).toBeDefined()
110
+ })
111
+
112
+ it('returns 500 when service throws', async () => {
113
+ const error = new Error('service failure') as any
114
+ error.statusCode = 500
115
+ service.createPayment.mockRejectedValue(error)
116
+
117
+ const response = await request(app).post('/payments/create-intent').send({ amount: 999, currency: 'usd' })
118
+
119
+ expect(response.status).toBe(500)
120
+ expect(response.body.error).toBe('service failure')
121
+ })
122
+ })
123
+
124
+ describe('POST /payments/refund', () => {
125
+ it('returns 200 with refund data on valid request', async () => {
126
+ const result = { id: 're_123', status: 'succeeded', amount: 500 }
127
+ service.refundPayment.mockResolvedValue(result)
128
+
129
+ const response = await request(app).post('/payments/refund').send({ paymentId: 'pi_123', amount: 500 })
130
+
131
+ expect(response.status).toBe(200)
132
+ expect(response.body.data).toEqual(result)
133
+ expect(service.refundPayment).toHaveBeenCalledWith({ paymentId: 'pi_123', amount: 500 })
134
+ })
135
+
136
+ it('returns 200 when amount is omitted (full refund)', async () => {
137
+ const result = { id: 're_124', status: 'succeeded', amount: 0 }
138
+ service.refundPayment.mockResolvedValue(result)
139
+
140
+ const response = await request(app).post('/payments/refund').send({ paymentId: 'pi_123' })
141
+
142
+ expect(response.status).toBe(200)
143
+ expect(response.body.data).toEqual(result)
144
+ expect(service.refundPayment).toHaveBeenCalledWith({ paymentId: 'pi_123', amount: undefined })
145
+ })
146
+
147
+ it('returns 400 when paymentId is missing', async () => {
148
+ const response = await request(app).post('/payments/refund').send({ amount: 500 })
149
+
150
+ expect(response.status).toBe(400)
151
+ expect(response.body.error).toBeDefined()
152
+ })
153
+
154
+ it('returns error status when service rejects', async () => {
155
+ const error = new Error('refund failed') as any
156
+ error.statusCode = 500
157
+ service.refundPayment.mockRejectedValue(error)
158
+
159
+ const response = await request(app).post('/payments/refund').send({ paymentId: 'pi_123', amount: 500 })
160
+
161
+ expect(response.status).toBe(500)
162
+ expect(response.body.error).toBe('refund failed')
163
+ })
164
+ })
165
+
166
+ describe('GET /payments/status/:paymentId', () => {
167
+ it('returns 200 with payment status for a valid paymentId', async () => {
168
+ const result = { id: 'pi_test_123', status: 'paid', amount: 999, currency: 'usd', metadata: {} }
169
+ service.getPaymentStatus.mockResolvedValue(result)
170
+
171
+ const response = await request(app).get('/payments/status/pi_test_123')
172
+
173
+ expect(response.status).toBe(200)
174
+ expect(response.body.data.status).toBe('paid')
175
+ expect(service.getPaymentStatus).toHaveBeenCalledWith('pi_test_123')
176
+ })
177
+
178
+ it('passes the paymentId from URL params to service', async () => {
179
+ const result = { id: 'pi_specific_id', status: 'paid', amount: 999, currency: 'usd', metadata: {} }
180
+ service.getPaymentStatus.mockResolvedValue(result)
181
+
182
+ const response = await request(app).get('/payments/status/pi_specific_id')
183
+
184
+ expect(response.status).toBe(200)
185
+ expect(service.getPaymentStatus).toHaveBeenCalledWith('pi_specific_id')
186
+ expect(response.body.data.id).toBe('pi_specific_id')
187
+ })
188
+
189
+ it('returns error status when service rejects', async () => {
190
+ const error = new Error('status error') as any
191
+ error.statusCode = 500
192
+ service.getPaymentStatus.mockRejectedValue(error)
193
+
194
+ const response = await request(app).get('/payments/status/pi_error')
195
+
196
+ expect(response.status).toBe(500)
197
+ expect(response.body.error).toBe('status error')
198
+ })
199
+ })
200
+
201
+ describe('POST /payments/webhook', () => {
202
+ it('returns 200 { received: true } for valid Stripe webhook', async () => {
203
+ service.processWebhook.mockResolvedValue({ event: 'payment_intent.succeeded', data: {}, duplicate: false })
204
+
205
+ const response = await request(app)
206
+ .post('/payments/webhook')
207
+ .set('Content-Type', 'application/json')
208
+ .set('stripe-signature', 'stripe-sig')
209
+ .send(Buffer.from('{"id":"evt_test_001"}'))
210
+
211
+ expect(response.status).toBe(200)
212
+ expect(response.body.received).toBe(true)
213
+ expect(service.processWebhook).toHaveBeenCalled()
214
+ const rawArg = service.processWebhook.mock.calls[0][0]
215
+ expect(Buffer.isBuffer(rawArg)).toBe(true)
216
+ expect(rawArg.toString()).toBe('{"id":"evt_test_001"}')
217
+ })
218
+
219
+ it('returns 200 { received: true, duplicate: true } for duplicate event', async () => {
220
+ service.processWebhook.mockResolvedValue({ event: 'payment_intent.succeeded', data: {}, duplicate: true })
221
+
222
+ const response = await request(app)
223
+ .post('/payments/webhook')
224
+ .set('Content-Type', 'application/json')
225
+ .set('stripe-signature', 'stripe-sig')
226
+ .send(Buffer.from('{"id":"evt_test_002"}'))
227
+
228
+ expect(response.status).toBe(200)
229
+ expect(response.body.received).toBe(true)
230
+ expect(response.body.duplicate).toBe(true)
231
+ })
232
+
233
+ it('returns 400 when signature header is missing', async () => {
234
+ const response = await request(app)
235
+ .post('/payments/webhook')
236
+ .set('Content-Type', 'application/json')
237
+ .send(Buffer.from('{"id":"evt_test_003"}'))
238
+
239
+ expect(response.status).toBe(400)
240
+ expect(response.body.error).toBe('Missing webhook signature header')
241
+ })
242
+
243
+ it('does NOT require Authorization header', async () => {
244
+ service.processWebhook.mockResolvedValue({ event: 'payment_intent.succeeded', data: {}, duplicate: false })
245
+
246
+ const response = await request(app)
247
+ .post('/payments/webhook')
248
+ .set('Content-Type', 'application/json')
249
+ .set('stripe-signature', 'stripe-sig')
250
+ .send(Buffer.from('{"id":"evt_test_004"}'))
251
+
252
+ expect(response.status).toBe(200)
253
+ expect(response.body.received).toBe(true)
254
+ })
255
+ })
256
+ })
@@ -0,0 +1,94 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { createPaymentSchema, refundPaymentSchema } from '../payments.schemas.ts'
3
+
4
+ describe('createPaymentSchema', () => {
5
+ it('parses valid input correctly', () => {
6
+ const result = createPaymentSchema.parse({ amount: 999, currency: 'usd' })
7
+
8
+ expect(result).toEqual({ amount: 999, currency: 'usd', metadata: {} })
9
+ })
10
+
11
+ it('lowercases currency automatically', () => {
12
+ const result = createPaymentSchema.parse({ amount: 100, currency: 'USD' })
13
+
14
+ expect(result.currency).toBe('usd')
15
+ })
16
+
17
+ it('rejects non-integer amount', () => {
18
+ expect(() => createPaymentSchema.parse({ amount: 9.99, currency: 'usd' })).toThrow()
19
+ })
20
+
21
+ it('rejects negative amount', () => {
22
+ expect(() => createPaymentSchema.parse({ amount: -100, currency: 'usd' })).toThrow()
23
+ })
24
+
25
+ it('rejects zero amount', () => {
26
+ expect(() => createPaymentSchema.parse({ amount: 0, currency: 'usd' })).toThrow()
27
+ })
28
+
29
+ it('rejects amount over maximum', () => {
30
+ expect(() => createPaymentSchema.parse({ amount: 100000000, currency: 'usd' })).toThrow()
31
+ })
32
+
33
+ it('rejects currency longer than 3 chars', () => {
34
+ expect(() => createPaymentSchema.parse({ amount: 100, currency: 'usdx' })).toThrow()
35
+ })
36
+
37
+ it('rejects currency shorter than 3 chars', () => {
38
+ expect(() => createPaymentSchema.parse({ amount: 100, currency: 'us' })).toThrow()
39
+ })
40
+
41
+ it('rejects missing amount', () => {
42
+ expect(() => createPaymentSchema.parse({ currency: 'usd' })).toThrow(/amount is required/)
43
+ })
44
+
45
+ it('rejects missing currency', () => {
46
+ expect(() => createPaymentSchema.parse({ amount: 100 })).toThrow(/currency is required/)
47
+ })
48
+
49
+ it('accepts metadata as key-value string pairs', () => {
50
+ const result = createPaymentSchema.parse({
51
+ amount: 100,
52
+ currency: 'usd',
53
+ metadata: { orderId: '123', userId: '456' },
54
+ })
55
+
56
+ expect(result.metadata).toEqual({ orderId: '123', userId: '456' })
57
+ })
58
+
59
+ it('rejects metadata with non-string values', () => {
60
+ expect(() =>
61
+ createPaymentSchema.parse({ amount: 100, currency: 'usd', metadata: { count: 5 } }),
62
+ ).toThrow()
63
+ })
64
+ })
65
+
66
+ describe('refundPaymentSchema', () => {
67
+ it('parses paymentId correctly', () => {
68
+ const result = refundPaymentSchema.parse({ paymentId: 'pi_abc123' })
69
+
70
+ expect(result).toEqual({ paymentId: 'pi_abc123', amount: undefined })
71
+ })
72
+
73
+ it('accepts optional partial refund amount', () => {
74
+ const result = refundPaymentSchema.parse({ paymentId: 'pi_abc123', amount: 500 })
75
+
76
+ expect(result.amount).toBe(500)
77
+ })
78
+
79
+ it('rejects empty paymentId', () => {
80
+ expect(() => refundPaymentSchema.parse({ paymentId: '' })).toThrow()
81
+ })
82
+
83
+ it('rejects missing paymentId', () => {
84
+ expect(() => refundPaymentSchema.parse({})).toThrow(/paymentId is required/)
85
+ })
86
+
87
+ it('rejects non-integer refund amount', () => {
88
+ expect(() => refundPaymentSchema.parse({ paymentId: 'pi_abc', amount: 9.99 })).toThrow()
89
+ })
90
+
91
+ it('rejects negative refund amount', () => {
92
+ expect(() => refundPaymentSchema.parse({ paymentId: 'pi_abc', amount: -100 })).toThrow()
93
+ })
94
+ })