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,150 @@
|
|
|
1
|
+
import {
|
|
2
|
+
lemonSqueezySetup,
|
|
3
|
+
createCheckout,
|
|
4
|
+
getOrder,
|
|
5
|
+
} from "@lemonsqueezy/lemonsqueezy.js";
|
|
6
|
+
import { createHmac } from "crypto";
|
|
7
|
+
import { PaymentAdapter } from "./PaymentAdapter.js";
|
|
8
|
+
import { PaymentError } from "../errors/PaymentError.js";
|
|
9
|
+
|
|
10
|
+
export class LemonSqueezyAdapter extends PaymentAdapter {
|
|
11
|
+
#apiKey;
|
|
12
|
+
#webhookSecret;
|
|
13
|
+
#storeId;
|
|
14
|
+
|
|
15
|
+
constructor({ apiKey, webhookSecret, storeId }) {
|
|
16
|
+
super();
|
|
17
|
+
if (!apiKey) {
|
|
18
|
+
throw new PaymentError(
|
|
19
|
+
"LemonSqueezy API key is required",
|
|
20
|
+
"CONFIG_ERROR",
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
if (!webhookSecret) {
|
|
24
|
+
throw new PaymentError(
|
|
25
|
+
"LemonSqueezy webhook secret is required",
|
|
26
|
+
"CONFIG_ERROR",
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
if (!storeId) {
|
|
30
|
+
throw new PaymentError(
|
|
31
|
+
"LemonSqueezy store ID is required",
|
|
32
|
+
"CONFIG_ERROR",
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
this.#apiKey = apiKey;
|
|
36
|
+
this.#webhookSecret = webhookSecret;
|
|
37
|
+
this.#storeId = storeId;
|
|
38
|
+
lemonSqueezySetup({ apiKey });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async createPayment({ amount, currency, metadata = {} }) {
|
|
42
|
+
if (!metadata.variantId) {
|
|
43
|
+
throw new PaymentError(
|
|
44
|
+
"variantId is required in metadata for LemonSqueezy",
|
|
45
|
+
"MISSING_VARIANT_ID",
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
const checkout = await createCheckout(this.#storeId, metadata.variantId, {
|
|
50
|
+
checkoutOptions: {
|
|
51
|
+
embed: false,
|
|
52
|
+
media: false,
|
|
53
|
+
logo: false,
|
|
54
|
+
},
|
|
55
|
+
checkoutData: {
|
|
56
|
+
custom: metadata,
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
if (checkout.error) {
|
|
60
|
+
throw new PaymentError(
|
|
61
|
+
`LemonSqueezy checkout failed: ${checkout.error.message}`,
|
|
62
|
+
"LS_CHECKOUT_FAILED",
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
id: String(checkout.data.data.id),
|
|
67
|
+
checkoutUrl: checkout.data.data.attributes.url,
|
|
68
|
+
status: "created",
|
|
69
|
+
amount,
|
|
70
|
+
currency,
|
|
71
|
+
metadata: checkout.data,
|
|
72
|
+
};
|
|
73
|
+
} catch (error) {
|
|
74
|
+
if (error instanceof PaymentError) throw error;
|
|
75
|
+
throw new PaymentError(
|
|
76
|
+
`LemonSqueezy createPayment failed: ${error.message}`,
|
|
77
|
+
"LS_CHECKOUT_FAILED",
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async refundPayment({ paymentId, amount }) {
|
|
83
|
+
try {
|
|
84
|
+
// LemonSqueezy SDK v4 requires manual refund creation via API
|
|
85
|
+
// This is a placeholder implementation - full refund support requires
|
|
86
|
+
// direct API calls with authentication
|
|
87
|
+
throw new PaymentError(
|
|
88
|
+
"LemonSqueezy refunds require manual implementation with API client",
|
|
89
|
+
"LS_REFUND_NOT_SUPPORTED",
|
|
90
|
+
);
|
|
91
|
+
} catch (error) {
|
|
92
|
+
if (error instanceof PaymentError) throw error;
|
|
93
|
+
throw new PaymentError(
|
|
94
|
+
`LemonSqueezy refundPayment failed: ${error.message}`,
|
|
95
|
+
"LS_REFUND_FAILED",
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async getPaymentStatus(paymentId) {
|
|
101
|
+
try {
|
|
102
|
+
const order = await getOrder(paymentId);
|
|
103
|
+
if (order.error) {
|
|
104
|
+
throw new PaymentError(
|
|
105
|
+
`LemonSqueezy order not found: ${order.error.message}`,
|
|
106
|
+
"LS_ORDER_NOT_FOUND",
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
const statusMap = {
|
|
110
|
+
paid: "paid",
|
|
111
|
+
pending: "pending",
|
|
112
|
+
failed: "failed",
|
|
113
|
+
refunded: "refunded",
|
|
114
|
+
};
|
|
115
|
+
const normalizedStatus =
|
|
116
|
+
statusMap[order.data.data.attributes.status] || "pending";
|
|
117
|
+
return {
|
|
118
|
+
id: String(order.data.data.id),
|
|
119
|
+
status: normalizedStatus,
|
|
120
|
+
amount: order.data.data.attributes.total,
|
|
121
|
+
currency: order.data.data.attributes.currency,
|
|
122
|
+
metadata: order.data,
|
|
123
|
+
};
|
|
124
|
+
} catch (error) {
|
|
125
|
+
if (error instanceof PaymentError) throw error;
|
|
126
|
+
throw new PaymentError(
|
|
127
|
+
`LemonSqueezy getPaymentStatus failed: ${error.message}`,
|
|
128
|
+
"LS_ORDER_NOT_FOUND",
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async verifyWebhook(rawBody, signature) {
|
|
134
|
+
const expectedSignature = createHmac("sha256", this.#webhookSecret)
|
|
135
|
+
.update(rawBody)
|
|
136
|
+
.digest("hex");
|
|
137
|
+
if (signature !== expectedSignature) {
|
|
138
|
+
throw new PaymentError(
|
|
139
|
+
"Invalid webhook signature",
|
|
140
|
+
"WEBHOOK_INVALID",
|
|
141
|
+
401,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
const payload = JSON.parse(rawBody.toString());
|
|
145
|
+
return {
|
|
146
|
+
event: payload.meta.event_name,
|
|
147
|
+
data: payload.data,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {Object} CreatePaymentResult
|
|
3
|
+
* @property {string} id - Provider-specific payment/checkout ID
|
|
4
|
+
* @property {string} [clientSecret] - Stripe: client_secret for frontend
|
|
5
|
+
* @property {string} [checkoutUrl] - LemonSqueezy: redirect URL
|
|
6
|
+
* @property {string} status - 'pending' | 'requires_payment_method' | 'created'
|
|
7
|
+
* @property {number} amount - Amount in smallest currency unit (cents)
|
|
8
|
+
* @property {string} currency - ISO 4217 (e.g. 'usd', 'pkr')
|
|
9
|
+
* @property {Object} metadata - Provider-specific raw response
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @typedef {Object} RefundResult
|
|
14
|
+
* @property {string} id - Refund ID
|
|
15
|
+
* @property {string} status - 'succeeded' | 'pending' | 'failed'
|
|
16
|
+
* @property {number} amount - Refunded amount in smallest unit
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @typedef {Object} PaymentStatus
|
|
21
|
+
* @property {string} id
|
|
22
|
+
* @property {string} status - 'pending' | 'paid' | 'failed' | 'refunded'
|
|
23
|
+
* @property {number} amount
|
|
24
|
+
* @property {string} currency
|
|
25
|
+
* @property {Object} metadata
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @typedef {Object} WebhookResult
|
|
30
|
+
* @property {string} event
|
|
31
|
+
* @property {Object} data
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Abstract PaymentAdapter interface.
|
|
36
|
+
* All adapters must implement these methods.
|
|
37
|
+
*/
|
|
38
|
+
export class PaymentAdapter {
|
|
39
|
+
/**
|
|
40
|
+
* Create a payment intent (Stripe) or checkout session (LemonSqueezy).
|
|
41
|
+
* @param {Object} params
|
|
42
|
+
* @param {number} params.amount - Amount in smallest currency unit
|
|
43
|
+
* @param {string} params.currency - ISO 4217 currency code
|
|
44
|
+
* @param {Object} [params.metadata] - Additional metadata
|
|
45
|
+
* @returns {Promise<CreatePaymentResult>}
|
|
46
|
+
*/
|
|
47
|
+
async createPayment(params) {
|
|
48
|
+
throw new Error("createPayment() must be implemented");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Refund a payment.
|
|
53
|
+
* @param {Object} params
|
|
54
|
+
* @param {string} params.paymentId - Payment ID to refund
|
|
55
|
+
* @param {number} [params.amount] - Amount to refund (omit for full refund)
|
|
56
|
+
* @returns {Promise<RefundResult>}
|
|
57
|
+
*/
|
|
58
|
+
async refundPayment(params) {
|
|
59
|
+
throw new Error("refundPayment() must be implemented");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Get current payment status.
|
|
64
|
+
* @param {string} paymentId
|
|
65
|
+
* @returns {Promise<PaymentStatus>}
|
|
66
|
+
*/
|
|
67
|
+
async getPaymentStatus(paymentId) {
|
|
68
|
+
throw new Error("getPaymentStatus() must be implemented");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Verify and parse a webhook payload.
|
|
73
|
+
* @param {Buffer} rawBody - Raw webhook body as Buffer
|
|
74
|
+
* @param {string} signature - Webhook signature from header
|
|
75
|
+
* @returns {Promise<WebhookResult>}
|
|
76
|
+
*/
|
|
77
|
+
async verifyWebhook(rawBody, signature) {
|
|
78
|
+
throw new Error("verifyWebhook() must be implemented");
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Factory function to create an adapter instance.
|
|
84
|
+
* @param {Object} options
|
|
85
|
+
* @param {'stripe' | 'lemonsqueezy'} options.provider
|
|
86
|
+
* @param {string} [options.stripeSecretKey]
|
|
87
|
+
* @param {string} [options.stripeWebhookSecret]
|
|
88
|
+
* @param {string} [options.lemonSqueezyApiKey]
|
|
89
|
+
* @param {string} [options.lemonSqueezyWebhookSecret]
|
|
90
|
+
* @param {string} [options.lemonSqueezyStoreId]
|
|
91
|
+
* @returns {PaymentAdapter}
|
|
92
|
+
*/
|
|
93
|
+
export function createAdapter(options) {
|
|
94
|
+
const { provider } = options;
|
|
95
|
+
if (provider === "stripe") {
|
|
96
|
+
return new StripeAdapter({
|
|
97
|
+
secretKey: options.stripeSecretKey,
|
|
98
|
+
webhookSecret: options.stripeWebhookSecret,
|
|
99
|
+
});
|
|
100
|
+
} else if (provider === "lemonsqueezy") {
|
|
101
|
+
return new LemonSqueezyAdapter({
|
|
102
|
+
apiKey: options.lemonSqueezyApiKey,
|
|
103
|
+
webhookSecret: options.lemonSqueezyWebhookSecret,
|
|
104
|
+
storeId: options.lemonSqueezyStoreId,
|
|
105
|
+
});
|
|
106
|
+
} else {
|
|
107
|
+
throw new PaymentError("Unknown provider", "CONFIG_ERROR");
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import Stripe from "stripe";
|
|
2
|
+
import { PaymentAdapter } from "./PaymentAdapter.js";
|
|
3
|
+
import { PaymentError } from "../errors/PaymentError.js";
|
|
4
|
+
|
|
5
|
+
export class StripeAdapter extends PaymentAdapter {
|
|
6
|
+
#stripe;
|
|
7
|
+
#webhookSecret;
|
|
8
|
+
|
|
9
|
+
constructor({ secretKey, webhookSecret }) {
|
|
10
|
+
super();
|
|
11
|
+
if (!secretKey) {
|
|
12
|
+
throw new PaymentError("Stripe secret key is required", "CONFIG_ERROR");
|
|
13
|
+
}
|
|
14
|
+
if (!webhookSecret) {
|
|
15
|
+
throw new PaymentError(
|
|
16
|
+
"Stripe webhook secret is required",
|
|
17
|
+
"CONFIG_ERROR",
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
this.#stripe = new Stripe(secretKey, { apiVersion: "2024-06-20" });
|
|
21
|
+
this.#webhookSecret = webhookSecret;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async createPayment({ amount, currency, metadata = {} }) {
|
|
25
|
+
try {
|
|
26
|
+
const intent = await this.#stripe.paymentIntents.create({
|
|
27
|
+
amount,
|
|
28
|
+
currency,
|
|
29
|
+
metadata,
|
|
30
|
+
automatic_payment_methods: { enabled: true },
|
|
31
|
+
});
|
|
32
|
+
return {
|
|
33
|
+
id: intent.id,
|
|
34
|
+
clientSecret: intent.client_secret,
|
|
35
|
+
status: intent.status,
|
|
36
|
+
amount: intent.amount,
|
|
37
|
+
currency: intent.currency,
|
|
38
|
+
metadata: intent,
|
|
39
|
+
};
|
|
40
|
+
} catch (error) {
|
|
41
|
+
throw new PaymentError(
|
|
42
|
+
`Stripe createPayment failed: ${error.message}`,
|
|
43
|
+
"STRIPE_ERROR",
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async refundPayment({ paymentId, amount }) {
|
|
49
|
+
try {
|
|
50
|
+
const params = { payment_intent: paymentId };
|
|
51
|
+
if (amount) {
|
|
52
|
+
params.amount = amount;
|
|
53
|
+
}
|
|
54
|
+
const refund = await this.#stripe.refunds.create(params);
|
|
55
|
+
return {
|
|
56
|
+
id: refund.id,
|
|
57
|
+
status: refund.status,
|
|
58
|
+
amount: refund.amount,
|
|
59
|
+
};
|
|
60
|
+
} catch (error) {
|
|
61
|
+
throw new PaymentError(
|
|
62
|
+
`Stripe refundPayment failed: ${error.message}`,
|
|
63
|
+
"STRIPE_ERROR",
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async getPaymentStatus(paymentId) {
|
|
69
|
+
try {
|
|
70
|
+
const intent = await this.#stripe.paymentIntents.retrieve(paymentId);
|
|
71
|
+
const statusMap = {
|
|
72
|
+
succeeded: "paid",
|
|
73
|
+
requires_payment_method: "pending",
|
|
74
|
+
requires_confirmation: "pending",
|
|
75
|
+
processing: "pending",
|
|
76
|
+
canceled: "failed",
|
|
77
|
+
requires_action: "pending",
|
|
78
|
+
};
|
|
79
|
+
const normalizedStatus = statusMap[intent.status] || "pending";
|
|
80
|
+
return {
|
|
81
|
+
id: intent.id,
|
|
82
|
+
status: normalizedStatus,
|
|
83
|
+
amount: intent.amount,
|
|
84
|
+
currency: intent.currency,
|
|
85
|
+
metadata: intent,
|
|
86
|
+
};
|
|
87
|
+
} catch (error) {
|
|
88
|
+
throw new PaymentError(
|
|
89
|
+
`Stripe getPaymentStatus failed: ${error.message}`,
|
|
90
|
+
"STRIPE_ERROR",
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async verifyWebhook(rawBody, signature) {
|
|
96
|
+
try {
|
|
97
|
+
const event = this.#stripe.webhooks.constructEvent(
|
|
98
|
+
rawBody,
|
|
99
|
+
signature,
|
|
100
|
+
this.#webhookSecret,
|
|
101
|
+
);
|
|
102
|
+
return {
|
|
103
|
+
event: event.type,
|
|
104
|
+
data: event.data.object,
|
|
105
|
+
};
|
|
106
|
+
} catch (error) {
|
|
107
|
+
throw new PaymentError(
|
|
108
|
+
"Invalid webhook signature",
|
|
109
|
+
"WEBHOOK_INVALID",
|
|
110
|
+
401,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import * as paymentsService from "../services/payments.service.js";
|
|
2
|
+
import {
|
|
3
|
+
createPaymentSchema,
|
|
4
|
+
refundPaymentSchema,
|
|
5
|
+
} from "../schemas/payments.schemas.js";
|
|
6
|
+
import { extractSignature } from "../helpers/webhookUtils.js";
|
|
7
|
+
import { PaymentError } from "../errors/PaymentError.js";
|
|
8
|
+
|
|
9
|
+
export const createPayment = async (req, res, next) => {
|
|
10
|
+
try {
|
|
11
|
+
const validated = createPaymentSchema.parse(req.body);
|
|
12
|
+
const result = await paymentsService.createPayment(validated);
|
|
13
|
+
res.status(201).json({ success: true, data: result });
|
|
14
|
+
} catch (err) {
|
|
15
|
+
next(err);
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const refundPayment = async (req, res, next) => {
|
|
20
|
+
try {
|
|
21
|
+
const validated = refundPaymentSchema.parse(req.body);
|
|
22
|
+
const result = await paymentsService.refundPayment(validated);
|
|
23
|
+
res.json({ success: true, data: result });
|
|
24
|
+
} catch (err) {
|
|
25
|
+
next(err);
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const getPaymentStatus = async (req, res, next) => {
|
|
30
|
+
try {
|
|
31
|
+
const result = await paymentsService.getPaymentStatus(req.params.paymentId);
|
|
32
|
+
res.json({ success: true, data: result });
|
|
33
|
+
} catch (err) {
|
|
34
|
+
next(err);
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const handleWebhook = async (req, res, next) => {
|
|
39
|
+
try {
|
|
40
|
+
const provider = process.env.PAYMENT_PROVIDER;
|
|
41
|
+
const signature = extractSignature(req, provider);
|
|
42
|
+
const result = await paymentsService.processWebhook(req.body, signature);
|
|
43
|
+
console.log(`Webhook received: ${result.event}`);
|
|
44
|
+
res.status(200).json({ received: true });
|
|
45
|
+
} catch (err) {
|
|
46
|
+
next(err);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { PaymentError } from "../errors/PaymentError.js";
|
|
2
|
+
|
|
3
|
+
export function getWebhookSignatureHeader(provider) {
|
|
4
|
+
if (provider === "stripe") {
|
|
5
|
+
return "stripe-signature";
|
|
6
|
+
} else if (provider === "lemonsqueezy") {
|
|
7
|
+
return "x-signature";
|
|
8
|
+
} else {
|
|
9
|
+
throw new PaymentError(
|
|
10
|
+
"Unknown provider for webhook header",
|
|
11
|
+
"CONFIG_ERROR",
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function extractSignature(req, provider) {
|
|
17
|
+
const headerName = getWebhookSignatureHeader(provider);
|
|
18
|
+
const signature = req.headers[headerName];
|
|
19
|
+
if (!signature) {
|
|
20
|
+
throw new PaymentError(
|
|
21
|
+
"Webhook signature header missing",
|
|
22
|
+
"WEBHOOK_INVALID",
|
|
23
|
+
401,
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
return signature;
|
|
27
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
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
|
+
}
|
|
57
|
+
|
|
58
|
+
export declare function setupPayments(
|
|
59
|
+
app: any,
|
|
60
|
+
options?: SetupPaymentsOptions,
|
|
61
|
+
): void;
|
|
62
|
+
export declare function createAdapter(
|
|
63
|
+
options: SetupPaymentsOptions,
|
|
64
|
+
): PaymentAdapter;
|
|
65
|
+
export declare class StripeAdapter implements PaymentAdapter {
|
|
66
|
+
constructor(options: { secretKey: string; webhookSecret: string });
|
|
67
|
+
createPayment(params: CreatePaymentParams): Promise<CreatePaymentResult>;
|
|
68
|
+
refundPayment(params: RefundParams): Promise<RefundResult>;
|
|
69
|
+
getPaymentStatus(paymentId: string): Promise<PaymentStatus>;
|
|
70
|
+
verifyWebhook(rawBody: Buffer, signature: string): Promise<WebhookResult>;
|
|
71
|
+
}
|
|
72
|
+
export declare class LemonSqueezyAdapter implements PaymentAdapter {
|
|
73
|
+
constructor(options: {
|
|
74
|
+
apiKey: string;
|
|
75
|
+
webhookSecret: string;
|
|
76
|
+
storeId: string;
|
|
77
|
+
});
|
|
78
|
+
createPayment(params: CreatePaymentParams): Promise<CreatePaymentResult>;
|
|
79
|
+
refundPayment(params: RefundParams): Promise<RefundResult>;
|
|
80
|
+
getPaymentStatus(paymentId: string): Promise<PaymentStatus>;
|
|
81
|
+
verifyWebhook(rawBody: Buffer, signature: string): Promise<WebhookResult>;
|
|
82
|
+
}
|
|
83
|
+
export declare class PaymentError extends Error {
|
|
84
|
+
code: string;
|
|
85
|
+
statusCode: number;
|
|
86
|
+
}
|
|
87
|
+
export declare function resetAdapter(): void;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { setupPayments } from "./routes/payments.routes.js";
|
|
2
|
+
export { createAdapter } from "./adapters/PaymentAdapter.js";
|
|
3
|
+
export { StripeAdapter } from "./adapters/StripeAdapter.js";
|
|
4
|
+
export { LemonSqueezyAdapter } from "./adapters/LemonSqueezyAdapter.js";
|
|
5
|
+
export { PaymentError } from "./errors/PaymentError.js";
|
|
6
|
+
export { resetAdapter } from "./services/payments.service.js";
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
import express from "express";
|
|
3
|
+
import * as controller from "../controllers/payments.controller.js";
|
|
4
|
+
|
|
5
|
+
const router = Router();
|
|
6
|
+
|
|
7
|
+
router.post("/create-intent", controller.createPayment);
|
|
8
|
+
router.post("/refund", controller.refundPayment);
|
|
9
|
+
router.get("/status/:paymentId", controller.getPaymentStatus);
|
|
10
|
+
router.post("/webhook", controller.handleWebhook);
|
|
11
|
+
|
|
12
|
+
export default router;
|
|
13
|
+
|
|
14
|
+
export function setupPayments(app, options = {}) {
|
|
15
|
+
const {
|
|
16
|
+
provider,
|
|
17
|
+
stripeSecretKey,
|
|
18
|
+
stripeWebhookSecret,
|
|
19
|
+
lemonSqueezyApiKey,
|
|
20
|
+
lemonSqueezyWebhookSecret,
|
|
21
|
+
lemonSqueezyStoreId,
|
|
22
|
+
mountPath = "/payments",
|
|
23
|
+
} = options;
|
|
24
|
+
|
|
25
|
+
// Set env vars if provided
|
|
26
|
+
if (provider) process.env.PAYMENT_PROVIDER = provider;
|
|
27
|
+
if (stripeSecretKey) process.env.STRIPE_SECRET_KEY = stripeSecretKey;
|
|
28
|
+
if (stripeWebhookSecret)
|
|
29
|
+
process.env.STRIPE_WEBHOOK_SECRET = stripeWebhookSecret;
|
|
30
|
+
if (lemonSqueezyApiKey) process.env.LEMONSQUEEZY_API_KEY = lemonSqueezyApiKey;
|
|
31
|
+
if (lemonSqueezyWebhookSecret)
|
|
32
|
+
process.env.LEMONSQUEEZY_WEBHOOK_SECRET = lemonSqueezyWebhookSecret;
|
|
33
|
+
if (lemonSqueezyStoreId)
|
|
34
|
+
process.env.LEMONSQUEEZY_STORE_ID = lemonSqueezyStoreId;
|
|
35
|
+
|
|
36
|
+
// Register raw body middleware for webhook BEFORE mounting router
|
|
37
|
+
app.use(`${mountPath}/webhook`, express.raw({ type: "application/json" }));
|
|
38
|
+
|
|
39
|
+
// Mount the router
|
|
40
|
+
app.use(mountPath, router);
|
|
41
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export const createPaymentSchema = z.object({
|
|
4
|
+
amount: z
|
|
5
|
+
.number({ required_error: "amount is required" })
|
|
6
|
+
.int({ message: "amount must be an integer" })
|
|
7
|
+
.positive({ message: "amount must be positive" })
|
|
8
|
+
.max(99999999, "amount exceeds maximum allowed"),
|
|
9
|
+
|
|
10
|
+
currency: z
|
|
11
|
+
.string({ required_error: "currency is required" })
|
|
12
|
+
.length(3, "currency must be 3 characters")
|
|
13
|
+
.toLowerCase(),
|
|
14
|
+
|
|
15
|
+
metadata: z.record(z.string()).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.number().int().positive().optional(),
|
|
24
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { StripeAdapter } from "../adapters/StripeAdapter.js";
|
|
2
|
+
import { LemonSqueezyAdapter } from "../adapters/LemonSqueezyAdapter.js";
|
|
3
|
+
import { PaymentError } from "../errors/PaymentError.js";
|
|
4
|
+
|
|
5
|
+
let adapter = null;
|
|
6
|
+
const processedWebhookIds = new Set();
|
|
7
|
+
|
|
8
|
+
export function getAdapter() {
|
|
9
|
+
if (adapter) return adapter;
|
|
10
|
+
|
|
11
|
+
const provider = process.env.PAYMENT_PROVIDER;
|
|
12
|
+
if (!provider) {
|
|
13
|
+
throw new PaymentError(
|
|
14
|
+
"PAYMENT_PROVIDER environment variable is required",
|
|
15
|
+
"PROVIDER_NOT_CONFIGURED",
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (provider === "stripe") {
|
|
20
|
+
adapter = new StripeAdapter({
|
|
21
|
+
secretKey: process.env.STRIPE_SECRET_KEY,
|
|
22
|
+
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
|
|
23
|
+
});
|
|
24
|
+
} else if (provider === "lemonsqueezy") {
|
|
25
|
+
adapter = new LemonSqueezyAdapter({
|
|
26
|
+
apiKey: process.env.LEMONSQUEEZY_API_KEY,
|
|
27
|
+
webhookSecret: process.env.LEMONSQUEEZY_WEBHOOK_SECRET,
|
|
28
|
+
storeId: process.env.LEMONSQUEEZY_STORE_ID,
|
|
29
|
+
});
|
|
30
|
+
} else {
|
|
31
|
+
throw new PaymentError(`Unknown provider: ${provider}`, "CONFIG_ERROR");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return adapter;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function resetAdapter() {
|
|
38
|
+
adapter = null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function createPayment({ amount, currency, metadata }) {
|
|
42
|
+
const adapter = getAdapter();
|
|
43
|
+
return adapter.createPayment({ amount, currency, metadata });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function refundPayment({ paymentId, amount }) {
|
|
47
|
+
const adapter = getAdapter();
|
|
48
|
+
return adapter.refundPayment({ paymentId, amount });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function getPaymentStatus(paymentId) {
|
|
52
|
+
const adapter = getAdapter();
|
|
53
|
+
return adapter.getPaymentStatus(paymentId);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function processWebhook(rawBody, signature) {
|
|
57
|
+
const adapter = getAdapter();
|
|
58
|
+
const { event, data } = await adapter.verifyWebhook(rawBody, signature);
|
|
59
|
+
|
|
60
|
+
const eventId = data.id ?? `${event}-${Date.now()}`;
|
|
61
|
+
|
|
62
|
+
if (processedWebhookIds.has(eventId)) {
|
|
63
|
+
return { event, data, duplicate: true };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
processedWebhookIds.add(eventId);
|
|
67
|
+
return { event, data, duplicate: false };
|
|
68
|
+
}
|