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.
- package/CHANGELOG.md +33 -2
- package/README.md +92 -5
- package/bin/index.js +121 -1
- package/package.json +1 -1
- 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/plan-2.3.0.md +1756 -0
- package/template/js/.env.example +19 -1
- package/template/js/README.md +133 -5
- package/template/js/basePackage.json +1 -1
- package/template/js/src/app.js +17 -0
- package/template/js/src/config/env.js +8 -0
- package/template/js/src/lib/swagger/SWAGGER_GUIDE.md +34 -0
- 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 +17 -1
- package/template/ts/README.md +135 -5
- 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/lib/swagger/SWAGGER_GUIDE.md +34 -0
- 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
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
|
|
3
|
+
vi.mock('../payments.adapter.ts', () => ({
|
|
4
|
+
getAdapter: vi.fn(),
|
|
5
|
+
resetAdapter: vi.fn(),
|
|
6
|
+
}))
|
|
7
|
+
|
|
8
|
+
import { getAdapter } from '../payments.adapter.ts'
|
|
9
|
+
import * as service from '../payments.service.ts'
|
|
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 = { verifyWebhook: vi.fn().mockResolvedValue({ event: 'payment_intent.succeeded', data: { id: 'evt_001' } }) }
|
|
93
|
+
getAdapter.mockReturnValue(adapter)
|
|
94
|
+
|
|
95
|
+
const result = await service.processWebhook(Buffer.from('{"id":"evt_001"}'), 'sig')
|
|
96
|
+
|
|
97
|
+
expect(adapter.verifyWebhook).toHaveBeenCalledWith(Buffer.from('{"id":"evt_001"}'), 'sig')
|
|
98
|
+
expect(result.duplicate).toBe(false)
|
|
99
|
+
expect(result.event).toBe('payment_intent.succeeded')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('returns duplicate:true on second call with same event id', async () => {
|
|
103
|
+
const event = { event: 'payment_intent.succeeded', data: { id: 'evt_002' } }
|
|
104
|
+
const adapter = { verifyWebhook: vi.fn().mockResolvedValue(event) }
|
|
105
|
+
getAdapter.mockReturnValue(adapter)
|
|
106
|
+
|
|
107
|
+
const first = await service.processWebhook(Buffer.from('{"id":"evt_002"}'), 'sig')
|
|
108
|
+
const second = await service.processWebhook(Buffer.from('{"id":"evt_002"}'), 'sig')
|
|
109
|
+
|
|
110
|
+
expect(first.duplicate).toBe(false)
|
|
111
|
+
expect(second.duplicate).toBe(true)
|
|
112
|
+
expect(adapter.verifyWebhook).toHaveBeenCalledTimes(2)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('treats events without id as unique using event+timestamp', async () => {
|
|
116
|
+
const adapter = { verifyWebhook: vi.fn().mockResolvedValue({ event: 'some_event', data: {} }) }
|
|
117
|
+
getAdapter.mockReturnValue(adapter)
|
|
118
|
+
|
|
119
|
+
const first = await service.processWebhook(Buffer.from('{"id":"evt_none_1"}'), 'sig')
|
|
120
|
+
await new Promise((resolve) => setTimeout(resolve, 1))
|
|
121
|
+
const second = await service.processWebhook(Buffer.from('{"id":"evt_none_2"}'), 'sig')
|
|
122
|
+
|
|
123
|
+
expect(first.duplicate).toBe(false)
|
|
124
|
+
expect(second.duplicate).toBe(false)
|
|
125
|
+
expect(adapter.verifyWebhook).toHaveBeenCalledTimes(2)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('propagates PaymentError WEBHOOK_INVALID from adapter', async () => {
|
|
129
|
+
const error = new Error('bad sig')
|
|
130
|
+
const adapter = { verifyWebhook: vi.fn().mockRejectedValue(error) }
|
|
131
|
+
getAdapter.mockReturnValue(adapter)
|
|
132
|
+
|
|
133
|
+
await expect(service.processWebhook(Buffer.from('{"id":"evt_003"}'), 'sig')).rejects.toThrow(error)
|
|
134
|
+
})
|
|
135
|
+
})
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { env } from "../../config/env.ts";
|
|
2
|
+
import {
|
|
3
|
+
StripeAdapter,
|
|
4
|
+
LemonSqueezyAdapter,
|
|
5
|
+
PaymentError,
|
|
6
|
+
} from "@charcoles/payments";
|
|
7
|
+
import type { PaymentAdapter } from "./payments.types.ts";
|
|
8
|
+
|
|
9
|
+
let adapter: PaymentAdapter | null = null;
|
|
10
|
+
|
|
11
|
+
export function getAdapter(): PaymentAdapter {
|
|
12
|
+
if (adapter) {
|
|
13
|
+
return adapter;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const provider = env.PAYMENT_PROVIDER;
|
|
17
|
+
|
|
18
|
+
if (!provider) {
|
|
19
|
+
const error = new PaymentError(
|
|
20
|
+
'PAYMENT_PROVIDER env var is not set. Set it to "stripe" or "lemonsqueezy".',
|
|
21
|
+
);
|
|
22
|
+
error.code = "PROVIDER_NOT_CONFIGURED";
|
|
23
|
+
error.statusCode = 500;
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (provider === "stripe") {
|
|
28
|
+
if (!env.STRIPE_SECRET_KEY || !env.STRIPE_WEBHOOK_SECRET) {
|
|
29
|
+
const error = new PaymentError(
|
|
30
|
+
"Stripe configuration is incomplete. Set STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET.",
|
|
31
|
+
);
|
|
32
|
+
error.code = "CONFIG_ERROR";
|
|
33
|
+
error.statusCode = 500;
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
adapter = new StripeAdapter({
|
|
38
|
+
secretKey: env.STRIPE_SECRET_KEY,
|
|
39
|
+
webhookSecret: env.STRIPE_WEBHOOK_SECRET,
|
|
40
|
+
}) as unknown as PaymentAdapter;
|
|
41
|
+
} else if (provider === "lemonsqueezy") {
|
|
42
|
+
if (
|
|
43
|
+
!env.LEMONSQUEEZY_API_KEY ||
|
|
44
|
+
!env.LEMONSQUEEZY_WEBHOOK_SECRET ||
|
|
45
|
+
!env.LEMONSQUEEZY_STORE_ID
|
|
46
|
+
) {
|
|
47
|
+
const error = new PaymentError(
|
|
48
|
+
"LemonSqueezy configuration is incomplete. Set LEMONSQUEEZY_API_KEY, LEMONSQUEEZY_WEBHOOK_SECRET, and LEMONSQUEEZY_STORE_ID.",
|
|
49
|
+
);
|
|
50
|
+
error.code = "CONFIG_ERROR";
|
|
51
|
+
error.statusCode = 500;
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
adapter = new LemonSqueezyAdapter({
|
|
56
|
+
apiKey: env.LEMONSQUEEZY_API_KEY,
|
|
57
|
+
webhookSecret: env.LEMONSQUEEZY_WEBHOOK_SECRET,
|
|
58
|
+
storeId: env.LEMONSQUEEZY_STORE_ID,
|
|
59
|
+
}) as unknown as PaymentAdapter;
|
|
60
|
+
} else {
|
|
61
|
+
const error = new PaymentError(
|
|
62
|
+
`Unknown PAYMENT_PROVIDER: "${provider}". Use "stripe" or "lemonsqueezy".`,
|
|
63
|
+
);
|
|
64
|
+
error.code = "CONFIG_ERROR";
|
|
65
|
+
error.statusCode = 500;
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return adapter;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function resetAdapter(): void {
|
|
73
|
+
adapter = null;
|
|
74
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export const PAYMENT_PROVIDERS = {
|
|
2
|
+
STRIPE: "stripe",
|
|
3
|
+
LEMONSQUEEZY: "lemonsqueezy",
|
|
4
|
+
} as const;
|
|
5
|
+
|
|
6
|
+
export const PAYMENT_EVENTS = {
|
|
7
|
+
STRIPE_PAYMENT_SUCCEEDED: "payment_intent.succeeded",
|
|
8
|
+
STRIPE_PAYMENT_FAILED: "payment_intent.payment_failed",
|
|
9
|
+
STRIPE_REFUND_CREATED: "charge.refunded",
|
|
10
|
+
LS_ORDER_CREATED: "order_created",
|
|
11
|
+
LS_ORDER_REFUNDED: "order_refunded",
|
|
12
|
+
LS_SUBSCRIPTION_CANCELLED: "subscription_cancelled",
|
|
13
|
+
} as const;
|
|
14
|
+
|
|
15
|
+
export const WEBHOOK_HEADERS = {
|
|
16
|
+
stripe: "stripe-signature",
|
|
17
|
+
lemonsqueezy: "x-signature",
|
|
18
|
+
} as const;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { Request, Response, NextFunction } from "express";
|
|
2
|
+
import * as paymentsService from "./payments.service.ts";
|
|
3
|
+
import { sendSuccess } from "../../utils/response.ts";
|
|
4
|
+
import {
|
|
5
|
+
createPaymentSchema,
|
|
6
|
+
refundPaymentSchema,
|
|
7
|
+
} from "./payments.schemas.ts";
|
|
8
|
+
import { PAYMENT_EVENTS, WEBHOOK_HEADERS } from "./payments.constants.ts";
|
|
9
|
+
import { logger } from "../../utils/logger.ts";
|
|
10
|
+
|
|
11
|
+
export const createPayment = async (
|
|
12
|
+
req: Request,
|
|
13
|
+
res: Response,
|
|
14
|
+
next: NextFunction,
|
|
15
|
+
): Promise<void> => {
|
|
16
|
+
try {
|
|
17
|
+
const validated = createPaymentSchema.parse(req.body);
|
|
18
|
+
const result = await paymentsService.createPayment(validated);
|
|
19
|
+
sendSuccess(res, result, 201);
|
|
20
|
+
} catch (err) {
|
|
21
|
+
next(err);
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const refundPayment = async (
|
|
26
|
+
req: Request,
|
|
27
|
+
res: Response,
|
|
28
|
+
next: NextFunction,
|
|
29
|
+
): Promise<void> => {
|
|
30
|
+
try {
|
|
31
|
+
const validated = refundPaymentSchema.parse(req.body);
|
|
32
|
+
const result = await paymentsService.refundPayment(validated);
|
|
33
|
+
sendSuccess(res, result);
|
|
34
|
+
} catch (err) {
|
|
35
|
+
next(err);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const getPaymentStatus = async (
|
|
40
|
+
req: Request,
|
|
41
|
+
res: Response,
|
|
42
|
+
next: NextFunction,
|
|
43
|
+
): Promise<void> => {
|
|
44
|
+
try {
|
|
45
|
+
const result = await paymentsService.getPaymentStatus(req.params.paymentId);
|
|
46
|
+
sendSuccess(res, result);
|
|
47
|
+
} catch (err) {
|
|
48
|
+
next(err);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export const handleWebhook = async (
|
|
53
|
+
req: Request,
|
|
54
|
+
res: Response,
|
|
55
|
+
next: NextFunction,
|
|
56
|
+
): Promise<void> => {
|
|
57
|
+
try {
|
|
58
|
+
const provider = process.env.PAYMENT_PROVIDER;
|
|
59
|
+
const headerName =
|
|
60
|
+
provider === "stripe"
|
|
61
|
+
? WEBHOOK_HEADERS.stripe
|
|
62
|
+
: WEBHOOK_HEADERS.lemonsqueezy;
|
|
63
|
+
|
|
64
|
+
const signature = req.headers[headerName] as string | undefined;
|
|
65
|
+
|
|
66
|
+
if (!signature) {
|
|
67
|
+
res.status(400).json({ error: "Missing webhook signature header" });
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const rawBody = req.body as Buffer;
|
|
72
|
+
const result = await paymentsService.processWebhook(rawBody, signature);
|
|
73
|
+
|
|
74
|
+
if (result.duplicate) {
|
|
75
|
+
logger.info(
|
|
76
|
+
`Duplicate webhook event received and ignored: ${result.event}`,
|
|
77
|
+
);
|
|
78
|
+
res.status(200).json({ received: true, duplicate: true });
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
logger.info(`Webhook event received: ${result.event}`);
|
|
83
|
+
|
|
84
|
+
switch (result.event) {
|
|
85
|
+
case PAYMENT_EVENTS.STRIPE_PAYMENT_SUCCEEDED:
|
|
86
|
+
case PAYMENT_EVENTS.LS_ORDER_CREATED:
|
|
87
|
+
logger.info("Payment confirmed. Add your fulfillment logic here.");
|
|
88
|
+
break;
|
|
89
|
+
case PAYMENT_EVENTS.STRIPE_PAYMENT_FAILED:
|
|
90
|
+
logger.warn("Payment failed. Add your failure handling logic here.");
|
|
91
|
+
break;
|
|
92
|
+
case PAYMENT_EVENTS.LS_ORDER_REFUNDED:
|
|
93
|
+
case PAYMENT_EVENTS.STRIPE_REFUND_CREATED:
|
|
94
|
+
logger.info("Refund processed. Add your refund handling logic here.");
|
|
95
|
+
break;
|
|
96
|
+
default:
|
|
97
|
+
logger.info(`Unhandled webhook event: ${result.event}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
res.status(200).json({ received: true });
|
|
101
|
+
} catch (err) {
|
|
102
|
+
next(err);
|
|
103
|
+
}
|
|
104
|
+
};
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
import { validateRequest } from "../../middlewares/validateRequest.ts";
|
|
3
|
+
import * as controller from "./payments.controller.ts";
|
|
4
|
+
import {
|
|
5
|
+
createPaymentSchema,
|
|
6
|
+
refundPaymentSchema,
|
|
7
|
+
} from "./payments.schemas.ts";
|
|
8
|
+
|
|
9
|
+
const router = Router();
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @swagger
|
|
13
|
+
* /api/payments/create-intent:
|
|
14
|
+
* post:
|
|
15
|
+
* summary: Create a payment intent or checkout session
|
|
16
|
+
* tags:
|
|
17
|
+
* - Payments
|
|
18
|
+
* requestBody:
|
|
19
|
+
* required: true
|
|
20
|
+
* content:
|
|
21
|
+
* application/json:
|
|
22
|
+
* schema:
|
|
23
|
+
* type: object
|
|
24
|
+
* required: [amount, currency]
|
|
25
|
+
* properties:
|
|
26
|
+
* amount:
|
|
27
|
+
* type: integer
|
|
28
|
+
* description: Amount in smallest currency unit (cents for USD, paisas for PKR)
|
|
29
|
+
* example: 2999
|
|
30
|
+
* currency:
|
|
31
|
+
* type: string
|
|
32
|
+
* description: ISO 4217 currency code
|
|
33
|
+
* example: usd
|
|
34
|
+
* metadata:
|
|
35
|
+
* type: object
|
|
36
|
+
* description: Optional metadata. LemonSqueezy requires variantId here.
|
|
37
|
+
* responses:
|
|
38
|
+
* 201:
|
|
39
|
+
* description: Payment intent created
|
|
40
|
+
* 400:
|
|
41
|
+
* description: Validation error
|
|
42
|
+
*/
|
|
43
|
+
router.post(
|
|
44
|
+
"/create-intent",
|
|
45
|
+
validateRequest(createPaymentSchema),
|
|
46
|
+
controller.createPayment,
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @swagger
|
|
51
|
+
* /api/payments/refund:
|
|
52
|
+
* post:
|
|
53
|
+
* summary: Refund a payment
|
|
54
|
+
* tags:
|
|
55
|
+
* - Payments
|
|
56
|
+
* requestBody:
|
|
57
|
+
* required: true
|
|
58
|
+
* content:
|
|
59
|
+
* application/json:
|
|
60
|
+
* schema:
|
|
61
|
+
* type: object
|
|
62
|
+
* required: [paymentId]
|
|
63
|
+
* properties:
|
|
64
|
+
* paymentId:
|
|
65
|
+
* type: string
|
|
66
|
+
* example: pi_123456789
|
|
67
|
+
* amount:
|
|
68
|
+
* type: integer
|
|
69
|
+
* description: Optional refund amount in smallest currency unit
|
|
70
|
+
* example: 2999
|
|
71
|
+
* responses:
|
|
72
|
+
* 200:
|
|
73
|
+
* description: Refund processed
|
|
74
|
+
* 400:
|
|
75
|
+
* description: Validation error
|
|
76
|
+
*/
|
|
77
|
+
router.post(
|
|
78
|
+
"/refund",
|
|
79
|
+
validateRequest(refundPaymentSchema),
|
|
80
|
+
controller.refundPayment,
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* @swagger
|
|
85
|
+
* /api/payments/status/{paymentId}:
|
|
86
|
+
* get:
|
|
87
|
+
* summary: Get payment status
|
|
88
|
+
* tags:
|
|
89
|
+
* - Payments
|
|
90
|
+
* parameters:
|
|
91
|
+
* - in: path
|
|
92
|
+
* name: paymentId
|
|
93
|
+
* required: true
|
|
94
|
+
* schema:
|
|
95
|
+
* type: string
|
|
96
|
+
* responses:
|
|
97
|
+
* 200:
|
|
98
|
+
* description: Payment status retrieved
|
|
99
|
+
* 404:
|
|
100
|
+
* description: Payment not found
|
|
101
|
+
*/
|
|
102
|
+
router.get("/status/:paymentId", controller.getPaymentStatus);
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* @swagger
|
|
106
|
+
* /api/payments/webhook:
|
|
107
|
+
* post:
|
|
108
|
+
* summary: Receive payment provider webhook events
|
|
109
|
+
* tags:
|
|
110
|
+
* - Payments
|
|
111
|
+
* requestBody:
|
|
112
|
+
* required: true
|
|
113
|
+
* content:
|
|
114
|
+
* application/json:
|
|
115
|
+
* schema:
|
|
116
|
+
* type: object
|
|
117
|
+
* responses:
|
|
118
|
+
* 200:
|
|
119
|
+
* description: Webhook received
|
|
120
|
+
* 400:
|
|
121
|
+
* description: Missing signature header
|
|
122
|
+
*/
|
|
123
|
+
router.post("/webhook", controller.handleWebhook);
|
|
124
|
+
|
|
125
|
+
export default router;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export const createPaymentSchema = z.object({
|
|
4
|
+
amount: z
|
|
5
|
+
.number({ required_error: "amount is required" })
|
|
6
|
+
.int("amount must be an integer (smallest currency unit, e.g. cents)")
|
|
7
|
+
.positive("amount must be positive")
|
|
8
|
+
.max(99999999, "amount exceeds maximum"),
|
|
9
|
+
|
|
10
|
+
currency: z
|
|
11
|
+
.string({ required_error: "currency is required" })
|
|
12
|
+
.length(3, "currency must be a 3-letter ISO 4217 code (e.g. usd, pkr)")
|
|
13
|
+
.toLowerCase(),
|
|
14
|
+
|
|
15
|
+
metadata: z.record(z.string()).optional().default({}),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export const refundPaymentSchema = z.object({
|
|
19
|
+
paymentId: z
|
|
20
|
+
.string({ required_error: "paymentId is required" })
|
|
21
|
+
.min(1, "paymentId cannot be empty"),
|
|
22
|
+
|
|
23
|
+
amount: z
|
|
24
|
+
.number()
|
|
25
|
+
.int("amount must be an integer")
|
|
26
|
+
.positive("amount must be positive")
|
|
27
|
+
.optional(),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export type CreatePaymentInput = z.infer<typeof createPaymentSchema>;
|
|
31
|
+
export type RefundPaymentInput = z.infer<typeof refundPaymentSchema>;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { getAdapter } from "./payments.adapter.ts";
|
|
2
|
+
import type {
|
|
3
|
+
CreatePaymentParams,
|
|
4
|
+
CreatePaymentResult,
|
|
5
|
+
RefundParams,
|
|
6
|
+
RefundResult,
|
|
7
|
+
PaymentStatus,
|
|
8
|
+
WebhookResult,
|
|
9
|
+
} from "./payments.types.ts";
|
|
10
|
+
|
|
11
|
+
const processedWebhookIds = new Set<string>();
|
|
12
|
+
|
|
13
|
+
export async function createPayment(
|
|
14
|
+
params: CreatePaymentParams,
|
|
15
|
+
): Promise<CreatePaymentResult> {
|
|
16
|
+
const adapter = getAdapter();
|
|
17
|
+
return adapter.createPayment(params);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function refundPayment(
|
|
21
|
+
params: RefundParams,
|
|
22
|
+
): Promise<RefundResult> {
|
|
23
|
+
const adapter = getAdapter();
|
|
24
|
+
return adapter.refundPayment(params);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function getPaymentStatus(
|
|
28
|
+
paymentId: string,
|
|
29
|
+
): Promise<PaymentStatus> {
|
|
30
|
+
const adapter = getAdapter();
|
|
31
|
+
return adapter.getPaymentStatus(paymentId);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function processWebhook(
|
|
35
|
+
rawBody: Buffer,
|
|
36
|
+
signature: string,
|
|
37
|
+
): Promise<WebhookResult & { duplicate: boolean }> {
|
|
38
|
+
const adapter = getAdapter();
|
|
39
|
+
const result = await adapter.verifyWebhook(rawBody, signature);
|
|
40
|
+
|
|
41
|
+
const eventId = result.data?.id
|
|
42
|
+
? String(result.data.id)
|
|
43
|
+
: `${result.event}-${Date.now()}`;
|
|
44
|
+
|
|
45
|
+
if (processedWebhookIds.has(eventId)) {
|
|
46
|
+
return { ...result, duplicate: true };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
processedWebhookIds.add(eventId);
|
|
50
|
+
return { ...result, duplicate: false };
|
|
51
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export interface CreatePaymentParams {
|
|
2
|
+
amount: number;
|
|
3
|
+
currency: string;
|
|
4
|
+
metadata?: Record<string, string>;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface CreatePaymentResult {
|
|
8
|
+
id: string;
|
|
9
|
+
clientSecret?: string;
|
|
10
|
+
checkoutUrl?: string;
|
|
11
|
+
status: "pending" | "requires_payment_method" | "created";
|
|
12
|
+
amount: number;
|
|
13
|
+
currency: string;
|
|
14
|
+
metadata: Record<string, unknown>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface RefundParams {
|
|
18
|
+
paymentId: string;
|
|
19
|
+
amount?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface RefundResult {
|
|
23
|
+
id: string;
|
|
24
|
+
status: "succeeded" | "pending" | "failed";
|
|
25
|
+
amount: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface PaymentStatus {
|
|
29
|
+
id: string;
|
|
30
|
+
status: "pending" | "paid" | "failed" | "refunded";
|
|
31
|
+
amount: number;
|
|
32
|
+
currency: string;
|
|
33
|
+
metadata: Record<string, unknown>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface WebhookResult {
|
|
37
|
+
event: string;
|
|
38
|
+
data: Record<string, unknown>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface PaymentAdapter {
|
|
42
|
+
createPayment(params: CreatePaymentParams): Promise<CreatePaymentResult>;
|
|
43
|
+
refundPayment(params: RefundParams): Promise<RefundResult>;
|
|
44
|
+
getPaymentStatus(paymentId: string): Promise<PaymentStatus>;
|
|
45
|
+
verifyWebhook(rawBody: Buffer, signature: string): Promise<WebhookResult>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface SetupPaymentsOptions {
|
|
49
|
+
provider: "stripe" | "lemonsqueezy";
|
|
50
|
+
stripeSecretKey?: string;
|
|
51
|
+
stripeWebhookSecret?: string;
|
|
52
|
+
lemonSqueezyApiKey?: string;
|
|
53
|
+
lemonSqueezyWebhookSecret?: string;
|
|
54
|
+
lemonSqueezyStoreId?: string;
|
|
55
|
+
mountPath?: string;
|
|
56
|
+
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { Router } from "express";
|
|
2
|
+
import { dirname } from "path";
|
|
3
|
+
import { fileURLToPath } from "url";
|
|
2
4
|
import {
|
|
3
5
|
getHealth,
|
|
4
6
|
createItem,
|
|
@@ -7,6 +9,9 @@ import {
|
|
|
7
9
|
import { validateRequest } from "../middlewares/validateRequest.ts";
|
|
8
10
|
import protectedRoutes from "./protected.ts";
|
|
9
11
|
import authRoutes from "../modules/auth/auth.routes.ts";
|
|
12
|
+
import paymentsRoutes from "../modules/payments/payments.routes.ts";
|
|
13
|
+
|
|
14
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
10
15
|
const router = Router();
|
|
11
16
|
|
|
12
17
|
// Health check
|
|
@@ -18,6 +23,9 @@ router.post("/items", validateRequest(createItemSchema), createItem);
|
|
|
18
23
|
// 🔐 Auth routes
|
|
19
24
|
router.use("/auth", authRoutes);
|
|
20
25
|
|
|
26
|
+
// 💳 Payment routes
|
|
27
|
+
router.use("/payments", paymentsRoutes);
|
|
28
|
+
|
|
21
29
|
// 🔐 Protected routes (REQUIRED BEARER TOKEN FOR THEM)
|
|
22
30
|
router.use("/protected", protectedRoutes);
|
|
23
31
|
|