create-charcole 2.2.1 → 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.
- package/CHANGELOG.md +34 -3
- package/README.md +94 -4
- package/bin/index.js +174 -4
- package/package.json +3 -3
- package/packages/payments/CHANGELOG.md +14 -0
- package/packages/payments/README.md +222 -0
- package/packages/payments/charcoles-payments-1.0.0.tgz +0 -0
- package/packages/payments/package.json +61 -0
- package/packages/payments/smoke-test.js +20 -0
- package/packages/payments/src/__tests__/LemonSqueezyAdapter.test.js +236 -0
- package/packages/payments/src/__tests__/StripeAdapter.test.js +139 -0
- package/packages/payments/src/__tests__/payments.service.test.js +131 -0
- package/packages/payments/src/__tests__/webhookUtils.test.js +47 -0
- package/packages/payments/src/adapters/LemonSqueezyAdapter.js +150 -0
- package/packages/payments/src/adapters/PaymentAdapter.js +109 -0
- package/packages/payments/src/adapters/StripeAdapter.js +114 -0
- package/packages/payments/src/controllers/payments.controller.js +48 -0
- package/packages/payments/src/errors/PaymentError.js +8 -0
- package/packages/payments/src/helpers/webhookUtils.js +27 -0
- package/packages/payments/src/index.d.ts +87 -0
- package/packages/payments/src/index.js +6 -0
- package/packages/payments/src/routes/payments.routes.js +41 -0
- package/packages/payments/src/schemas/payments.schemas.js +24 -0
- package/packages/payments/src/services/payments.service.js +68 -0
- package/packages/swagger/BACKWARD_COMPATIBILITY.md +1 -1
- package/packages/swagger/CHANGELOG.md +1 -1
- package/packages/swagger/README.md +3 -3
- package/packages/swagger/package.json +3 -3
- package/packages/swagger/src/setup.js +1 -1
- package/plan-2.3.0.md +1756 -0
- package/template/js/.env.example +20 -1
- package/template/js/README.md +140 -8
- package/template/js/basePackage.json +1 -1
- package/template/js/src/app.js +18 -1
- package/template/js/src/config/env.js +8 -0
- package/template/js/src/config/swagger.config.js +1 -1
- package/template/js/src/lib/swagger/SWAGGER_GUIDE.md +37 -3
- package/template/js/src/modules/payments/__tests__/payments.controller.test.js +342 -0
- package/template/js/src/modules/payments/__tests__/payments.routes.test.js +256 -0
- package/template/js/src/modules/payments/__tests__/payments.schemas.test.js +94 -0
- package/template/js/src/modules/payments/__tests__/payments.service.test.js +141 -0
- package/template/js/src/modules/payments/package.json +7 -0
- package/template/js/src/modules/payments/payments.adapter.js +47 -0
- package/template/js/src/modules/payments/payments.constants.js +20 -0
- package/template/js/src/modules/payments/payments.controller.js +85 -0
- package/template/js/src/modules/payments/payments.routes.js +125 -0
- package/template/js/src/modules/payments/payments.schemas.js +28 -0
- package/template/js/src/modules/payments/payments.service.js +34 -0
- package/template/js/src/modules/swagger/package.json +1 -1
- package/template/js/src/routes/index.js +16 -0
- package/template/ts/.env.example +18 -1
- package/template/ts/README.md +142 -8
- package/template/ts/basePackage.json +1 -1
- package/template/ts/src/app.ts +13 -0
- package/template/ts/src/config/env.ts +7 -0
- package/template/ts/src/config/swagger.config.ts +1 -1
- package/template/ts/src/lib/swagger/SWAGGER_GUIDE.md +36 -2
- package/template/ts/src/modules/payments/__tests__/payments.controller.test.ts +282 -0
- package/template/ts/src/modules/payments/__tests__/payments.routes.test.ts +256 -0
- package/template/ts/src/modules/payments/__tests__/payments.schemas.test.ts +94 -0
- package/template/ts/src/modules/payments/__tests__/payments.service.test.ts +135 -0
- package/template/ts/src/modules/payments/package.json +7 -0
- package/template/ts/src/modules/payments/payments.adapter.ts +74 -0
- package/template/ts/src/modules/payments/payments.constants.ts +18 -0
- package/template/ts/src/modules/payments/payments.controller.ts +104 -0
- package/template/ts/src/modules/payments/payments.routes.ts +125 -0
- package/template/ts/src/modules/payments/payments.schemas.ts +31 -0
- package/template/ts/src/modules/payments/payments.service.ts +51 -0
- package/template/ts/src/modules/payments/payments.types.ts +56 -0
- package/template/ts/src/modules/swagger/package.json +1 -1
- package/template/ts/src/routes/index.ts +8 -0
- package/packages/swagger/package-lock.json +0 -1715
|
@@ -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.js', () => ({
|
|
6
|
+
createPayment: vi.fn(),
|
|
7
|
+
refundPayment: vi.fn(),
|
|
8
|
+
getPaymentStatus: vi.fn(),
|
|
9
|
+
processWebhook: vi.fn(),
|
|
10
|
+
}))
|
|
11
|
+
|
|
12
|
+
vi.mock('../../../utils/logger.js', () => ({
|
|
13
|
+
logger: {
|
|
14
|
+
info: vi.fn(),
|
|
15
|
+
error: vi.fn(),
|
|
16
|
+
warn: vi.fn(),
|
|
17
|
+
},
|
|
18
|
+
}))
|
|
19
|
+
|
|
20
|
+
import paymentsRouter from '../payments.routes.js'
|
|
21
|
+
import * as service from '../payments.service.js'
|
|
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
|
|
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')
|
|
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')
|
|
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')
|
|
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.js'
|
|
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
|
+
})
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
|
|
3
|
+
vi.mock('../payments.adapter.js', () => ({
|
|
4
|
+
getAdapter: vi.fn(),
|
|
5
|
+
resetAdapter: vi.fn(),
|
|
6
|
+
}))
|
|
7
|
+
|
|
8
|
+
import { getAdapter } from '../payments.adapter.js'
|
|
9
|
+
import * as service from '../payments.service.js'
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
vi.clearAllMocks()
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
describe('payments.service', () => {
|
|
16
|
+
it('delegates to adapter.createPayment and returns its result', async () => {
|
|
17
|
+
const mockResult = {
|
|
18
|
+
id: 'pi_test_123',
|
|
19
|
+
clientSecret: 'secret_abc',
|
|
20
|
+
status: 'requires_payment_method',
|
|
21
|
+
amount: 999,
|
|
22
|
+
currency: 'usd',
|
|
23
|
+
metadata: {},
|
|
24
|
+
}
|
|
25
|
+
const adapter = { createPayment: vi.fn().mockResolvedValue(mockResult) }
|
|
26
|
+
getAdapter.mockReturnValue(adapter)
|
|
27
|
+
|
|
28
|
+
const result = await service.createPayment({ amount: 999, currency: 'usd' })
|
|
29
|
+
|
|
30
|
+
expect(adapter.createPayment).toHaveBeenCalledWith({ amount: 999, currency: 'usd' })
|
|
31
|
+
expect(result).toEqual(mockResult)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('propagates errors from adapter.createPayment', async () => {
|
|
35
|
+
const error = new Error('payment failed')
|
|
36
|
+
const adapter = { createPayment: vi.fn().mockRejectedValue(error) }
|
|
37
|
+
getAdapter.mockReturnValue(adapter)
|
|
38
|
+
|
|
39
|
+
await expect(service.createPayment({ amount: 999, currency: 'usd' })).rejects.toThrow(error)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('delegates to adapter.refundPayment with paymentId and amount', async () => {
|
|
43
|
+
const mockResult = { id: 're_test_123', status: 'succeeded', amount: 500 }
|
|
44
|
+
const adapter = { refundPayment: vi.fn().mockResolvedValue(mockResult) }
|
|
45
|
+
getAdapter.mockReturnValue(adapter)
|
|
46
|
+
|
|
47
|
+
const result = await service.refundPayment({ paymentId: 'pi_123', amount: 500 })
|
|
48
|
+
|
|
49
|
+
expect(adapter.refundPayment).toHaveBeenCalledWith({ paymentId: 'pi_123', amount: 500 })
|
|
50
|
+
expect(result).toEqual(mockResult)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('delegates to adapter.refundPayment without amount when not provided', async () => {
|
|
54
|
+
const mockResult = { id: 're_test_124', status: 'succeeded', amount: 0 }
|
|
55
|
+
const adapter = { refundPayment: vi.fn().mockResolvedValue(mockResult) }
|
|
56
|
+
getAdapter.mockReturnValue(adapter)
|
|
57
|
+
|
|
58
|
+
const result = await service.refundPayment({ paymentId: 'pi_123' })
|
|
59
|
+
|
|
60
|
+
expect(adapter.refundPayment).toHaveBeenCalledWith({ paymentId: 'pi_123', amount: undefined })
|
|
61
|
+
expect(result).toEqual(mockResult)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('propagates adapter errors from refundPayment', async () => {
|
|
65
|
+
const error = new Error('refund failed')
|
|
66
|
+
const adapter = { refundPayment: vi.fn().mockRejectedValue(error) }
|
|
67
|
+
getAdapter.mockReturnValue(adapter)
|
|
68
|
+
|
|
69
|
+
await expect(service.refundPayment({ paymentId: 'pi_123', amount: 500 })).rejects.toThrow(error)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('delegates to adapter.getPaymentStatus with the paymentId string', async () => {
|
|
73
|
+
const mockResult = { id: 'pi_123', status: 'paid', amount: 999, currency: 'usd', metadata: {} }
|
|
74
|
+
const adapter = { getPaymentStatus: vi.fn().mockResolvedValue(mockResult) }
|
|
75
|
+
getAdapter.mockReturnValue(adapter)
|
|
76
|
+
|
|
77
|
+
const result = await service.getPaymentStatus('pi_123')
|
|
78
|
+
|
|
79
|
+
expect(adapter.getPaymentStatus).toHaveBeenCalledWith('pi_123')
|
|
80
|
+
expect(result).toEqual(mockResult)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('propagates adapter errors from getPaymentStatus', async () => {
|
|
84
|
+
const error = new Error('status error')
|
|
85
|
+
const adapter = { getPaymentStatus: vi.fn().mockRejectedValue(error) }
|
|
86
|
+
getAdapter.mockReturnValue(adapter)
|
|
87
|
+
|
|
88
|
+
await expect(service.getPaymentStatus('pi_123')).rejects.toThrow(error)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('calls adapter.verifyWebhook and returns duplicate:false on first call', async () => {
|
|
92
|
+
const adapter = {
|
|
93
|
+
verifyWebhook: vi.fn().mockResolvedValue({ event: 'payment_intent.succeeded', data: { id: 'evt_001' } }),
|
|
94
|
+
}
|
|
95
|
+
getAdapter.mockReturnValue(adapter)
|
|
96
|
+
|
|
97
|
+
const result = await service.processWebhook(Buffer.from('{"id":"evt_001"}'), 'sig')
|
|
98
|
+
|
|
99
|
+
expect(adapter.verifyWebhook).toHaveBeenCalledWith(Buffer.from('{"id":"evt_001"}'), 'sig')
|
|
100
|
+
expect(result.duplicate).toBe(false)
|
|
101
|
+
expect(result.event).toBe('payment_intent.succeeded')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('returns duplicate:true on second call with same event id', async () => {
|
|
105
|
+
const event = { event: 'payment_intent.succeeded', data: { id: 'evt_002' } }
|
|
106
|
+
const adapter = {
|
|
107
|
+
verifyWebhook: vi.fn().mockResolvedValue(event),
|
|
108
|
+
}
|
|
109
|
+
getAdapter.mockReturnValue(adapter)
|
|
110
|
+
|
|
111
|
+
const first = await service.processWebhook(Buffer.from('{"id":"evt_002"}'), 'sig')
|
|
112
|
+
const second = await service.processWebhook(Buffer.from('{"id":"evt_002"}'), 'sig')
|
|
113
|
+
|
|
114
|
+
expect(first.duplicate).toBe(false)
|
|
115
|
+
expect(second.duplicate).toBe(true)
|
|
116
|
+
expect(adapter.verifyWebhook).toHaveBeenCalledTimes(2)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('treats events without id as unique using event+timestamp', async () => {
|
|
120
|
+
const adapter = {
|
|
121
|
+
verifyWebhook: vi.fn().mockResolvedValue({ event: 'some_event', data: {} }),
|
|
122
|
+
}
|
|
123
|
+
getAdapter.mockReturnValue(adapter)
|
|
124
|
+
|
|
125
|
+
const first = await service.processWebhook(Buffer.from('{"id":"evt_none_1"}'), 'sig')
|
|
126
|
+
await new Promise((resolve) => setTimeout(resolve, 1))
|
|
127
|
+
const second = await service.processWebhook(Buffer.from('{"id":"evt_none_2"}'), 'sig')
|
|
128
|
+
|
|
129
|
+
expect(first.duplicate).toBe(false)
|
|
130
|
+
expect(second.duplicate).toBe(false)
|
|
131
|
+
expect(adapter.verifyWebhook).toHaveBeenCalledTimes(2)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('propagates PaymentError WEBHOOK_INVALID from adapter', async () => {
|
|
135
|
+
const error = new Error('bad sig')
|
|
136
|
+
const adapter = { verifyWebhook: vi.fn().mockRejectedValue(error) }
|
|
137
|
+
getAdapter.mockReturnValue(adapter)
|
|
138
|
+
|
|
139
|
+
await expect(service.processWebhook(Buffer.from('{"id":"evt_003"}'), 'sig')).rejects.toThrow(error)
|
|
140
|
+
})
|
|
141
|
+
})
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { env } from "../../config/env.js";
|
|
2
|
+
import {
|
|
3
|
+
StripeAdapter,
|
|
4
|
+
LemonSqueezyAdapter,
|
|
5
|
+
PaymentError,
|
|
6
|
+
} from "@charcoles/payments";
|
|
7
|
+
|
|
8
|
+
let adapter = null;
|
|
9
|
+
|
|
10
|
+
export function getAdapter() {
|
|
11
|
+
if (adapter) return adapter;
|
|
12
|
+
|
|
13
|
+
const provider = env.PAYMENT_PROVIDER;
|
|
14
|
+
|
|
15
|
+
if (!provider) {
|
|
16
|
+
throw new PaymentError(
|
|
17
|
+
'PAYMENT_PROVIDER env var is not set. Set it to "stripe" or "lemonsqueezy".',
|
|
18
|
+
"PROVIDER_NOT_CONFIGURED",
|
|
19
|
+
500,
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (provider === "stripe") {
|
|
24
|
+
adapter = new StripeAdapter({
|
|
25
|
+
secretKey: env.STRIPE_SECRET_KEY,
|
|
26
|
+
webhookSecret: env.STRIPE_WEBHOOK_SECRET,
|
|
27
|
+
});
|
|
28
|
+
} else if (provider === "lemonsqueezy") {
|
|
29
|
+
adapter = new LemonSqueezyAdapter({
|
|
30
|
+
apiKey: env.LEMONSQUEEZY_API_KEY,
|
|
31
|
+
webhookSecret: env.LEMONSQUEEZY_WEBHOOK_SECRET,
|
|
32
|
+
storeId: env.LEMONSQUEEZY_STORE_ID,
|
|
33
|
+
});
|
|
34
|
+
} else {
|
|
35
|
+
throw new PaymentError(
|
|
36
|
+
`Unknown PAYMENT_PROVIDER: "${provider}". Use "stripe" or "lemonsqueezy".`,
|
|
37
|
+
"CONFIG_ERROR",
|
|
38
|
+
500,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return adapter;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function resetAdapter() {
|
|
46
|
+
adapter = null;
|
|
47
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export const PAYMENT_PROVIDERS = {
|
|
2
|
+
STRIPE: "stripe",
|
|
3
|
+
LEMONSQUEEZY: "lemonsqueezy",
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export const PAYMENT_EVENTS = {
|
|
7
|
+
// Stripe events
|
|
8
|
+
STRIPE_PAYMENT_SUCCEEDED: "payment_intent.succeeded",
|
|
9
|
+
STRIPE_PAYMENT_FAILED: "payment_intent.payment_failed",
|
|
10
|
+
STRIPE_REFUND_CREATED: "charge.refunded",
|
|
11
|
+
// LemonSqueezy events
|
|
12
|
+
LS_ORDER_CREATED: "order_created",
|
|
13
|
+
LS_ORDER_REFUNDED: "order_refunded",
|
|
14
|
+
LS_SUBSCRIPTION_CANCELLED: "subscription_cancelled",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const WEBHOOK_HEADERS = {
|
|
18
|
+
stripe: "stripe-signature",
|
|
19
|
+
lemonsqueezy: "x-signature",
|
|
20
|
+
};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import * as paymentsService from "./payments.service.js";
|
|
2
|
+
import { sendSuccess } from "../../utils/response.js";
|
|
3
|
+
import {
|
|
4
|
+
createPaymentSchema,
|
|
5
|
+
refundPaymentSchema,
|
|
6
|
+
} from "./payments.schemas.js";
|
|
7
|
+
import { WEBHOOK_HEADERS, PAYMENT_EVENTS } from "./payments.constants.js";
|
|
8
|
+
import { logger } from "../../utils/logger.js";
|
|
9
|
+
|
|
10
|
+
export const createPayment = async (req, res, next) => {
|
|
11
|
+
try {
|
|
12
|
+
const validated = createPaymentSchema.parse(req.body);
|
|
13
|
+
const result = await paymentsService.createPayment(validated);
|
|
14
|
+
sendSuccess(res, result, 201);
|
|
15
|
+
} catch (err) {
|
|
16
|
+
next(err);
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const refundPayment = async (req, res, next) => {
|
|
21
|
+
try {
|
|
22
|
+
const validated = refundPaymentSchema.parse(req.body);
|
|
23
|
+
const result = await paymentsService.refundPayment(validated);
|
|
24
|
+
sendSuccess(res, result);
|
|
25
|
+
} catch (err) {
|
|
26
|
+
next(err);
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const getPaymentStatus = async (req, res, next) => {
|
|
31
|
+
try {
|
|
32
|
+
const result = await paymentsService.getPaymentStatus(req.params.paymentId);
|
|
33
|
+
sendSuccess(res, result);
|
|
34
|
+
} catch (err) {
|
|
35
|
+
next(err);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const handleWebhook = async (req, res, next) => {
|
|
40
|
+
try {
|
|
41
|
+
const provider = process.env.PAYMENT_PROVIDER;
|
|
42
|
+
const headerName = WEBHOOK_HEADERS[provider];
|
|
43
|
+
const signature = req.headers[headerName];
|
|
44
|
+
|
|
45
|
+
if (!signature) {
|
|
46
|
+
return res
|
|
47
|
+
.status(400)
|
|
48
|
+
.json({ error: "Missing webhook signature header" });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const result = await paymentsService.processWebhook(req.body, signature);
|
|
52
|
+
|
|
53
|
+
if (result.duplicate) {
|
|
54
|
+
logger.info(
|
|
55
|
+
`Duplicate webhook event received and ignored: ${result.event}`,
|
|
56
|
+
);
|
|
57
|
+
return res.status(200).json({ received: true, duplicate: true });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
logger.info(`Webhook event received: ${result.event}`);
|
|
61
|
+
|
|
62
|
+
switch (result.event) {
|
|
63
|
+
case PAYMENT_EVENTS.STRIPE_PAYMENT_SUCCEEDED:
|
|
64
|
+
case PAYMENT_EVENTS.LS_ORDER_CREATED:
|
|
65
|
+
logger.info("Payment confirmed. Add your fulfillment logic here.");
|
|
66
|
+
break;
|
|
67
|
+
|
|
68
|
+
case PAYMENT_EVENTS.STRIPE_PAYMENT_FAILED:
|
|
69
|
+
logger.warn("Payment failed. Add your failure handling logic here.");
|
|
70
|
+
break;
|
|
71
|
+
|
|
72
|
+
case PAYMENT_EVENTS.LS_ORDER_REFUNDED:
|
|
73
|
+
case PAYMENT_EVENTS.STRIPE_REFUND_CREATED:
|
|
74
|
+
logger.info("Refund processed. Add your refund handling logic here.");
|
|
75
|
+
break;
|
|
76
|
+
|
|
77
|
+
default:
|
|
78
|
+
logger.info(`Unhandled webhook event: ${result.event}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
res.status(200).json({ received: true });
|
|
82
|
+
} catch (err) {
|
|
83
|
+
next(err);
|
|
84
|
+
}
|
|
85
|
+
};
|