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,236 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { LemonSqueezyAdapter } from "../adapters/LemonSqueezyAdapter.js";
|
|
3
|
+
import { PaymentError } from "../errors/PaymentError.js";
|
|
4
|
+
|
|
5
|
+
vi.mock("@lemonsqueezy/lemonsqueezy.js", () => {
|
|
6
|
+
return {
|
|
7
|
+
lemonSqueezySetup: vi.fn(),
|
|
8
|
+
createCheckout: vi.fn(),
|
|
9
|
+
getOrder: vi.fn(),
|
|
10
|
+
};
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe("LemonSqueezyAdapter", () => {
|
|
14
|
+
let adapter;
|
|
15
|
+
let mockLS;
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
vi.clearAllMocks();
|
|
19
|
+
mockLS = vi.mocked(require("@lemonsqueezy/lemonsqueezy.js"));
|
|
20
|
+
adapter = new LemonSqueezyAdapter({
|
|
21
|
+
apiKey: "api_fake",
|
|
22
|
+
webhookSecret: "secret_fake",
|
|
23
|
+
storeId: "123",
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("creates a checkout", async () => {
|
|
28
|
+
mockLS.createCheckout.mockResolvedValue({
|
|
29
|
+
error: null,
|
|
30
|
+
data: {
|
|
31
|
+
data: {
|
|
32
|
+
id: 456,
|
|
33
|
+
attributes: {
|
|
34
|
+
url: "https://checkout.fake",
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const result = await adapter.createPayment({
|
|
41
|
+
amount: 1000,
|
|
42
|
+
currency: "usd",
|
|
43
|
+
metadata: { variantId: "789" },
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
expect(result.id).toBe("456");
|
|
47
|
+
expect(result.checkoutUrl).toBe("https://checkout.fake");
|
|
48
|
+
expect(result.status).toBe("created");
|
|
49
|
+
expect(result.amount).toBe(1000);
|
|
50
|
+
expect(result.currency).toBe("usd");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("throws MISSING_VARIANT_ID when variantId absent", async () => {
|
|
54
|
+
await expect(
|
|
55
|
+
adapter.createPayment({
|
|
56
|
+
amount: 1000,
|
|
57
|
+
currency: "usd",
|
|
58
|
+
metadata: {},
|
|
59
|
+
}),
|
|
60
|
+
).rejects.toThrow(PaymentError);
|
|
61
|
+
await expect(
|
|
62
|
+
adapter.createPayment({
|
|
63
|
+
amount: 1000,
|
|
64
|
+
currency: "usd",
|
|
65
|
+
metadata: {},
|
|
66
|
+
}),
|
|
67
|
+
).rejects.toMatchObject({
|
|
68
|
+
code: "MISSING_VARIANT_ID",
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("throws LS_CHECKOUT_FAILED when API returns error", async () => {
|
|
73
|
+
mockLS.createCheckout.mockResolvedValue({
|
|
74
|
+
error: { message: "Invalid variant" },
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
await expect(
|
|
78
|
+
adapter.createPayment({
|
|
79
|
+
amount: 1000,
|
|
80
|
+
currency: "usd",
|
|
81
|
+
metadata: { variantId: "789" },
|
|
82
|
+
}),
|
|
83
|
+
).rejects.toThrow(PaymentError);
|
|
84
|
+
await expect(
|
|
85
|
+
adapter.createPayment({
|
|
86
|
+
amount: 1000,
|
|
87
|
+
currency: "usd",
|
|
88
|
+
metadata: { variantId: "789" },
|
|
89
|
+
}),
|
|
90
|
+
).rejects.toMatchObject({
|
|
91
|
+
code: "LS_CHECKOUT_FAILED",
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("refunds a payment", async () => {
|
|
96
|
+
mockLS.createRefund.mockResolvedValue({
|
|
97
|
+
error: null,
|
|
98
|
+
data: {
|
|
99
|
+
data: {
|
|
100
|
+
id: 789,
|
|
101
|
+
attributes: {
|
|
102
|
+
status: "completed",
|
|
103
|
+
amount: 500,
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const result = await adapter.refundPayment({
|
|
110
|
+
paymentId: "456",
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
expect(result.id).toBe("789");
|
|
114
|
+
expect(result.status).toBe("completed");
|
|
115
|
+
expect(result.amount).toBe(500);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("gets payment status", async () => {
|
|
119
|
+
mockLS.getOrder.mockResolvedValue({
|
|
120
|
+
error: null,
|
|
121
|
+
data: {
|
|
122
|
+
data: {
|
|
123
|
+
id: 456,
|
|
124
|
+
attributes: {
|
|
125
|
+
status: "paid",
|
|
126
|
+
total: 1000,
|
|
127
|
+
currency: "usd",
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const result = await adapter.getPaymentStatus("456");
|
|
134
|
+
|
|
135
|
+
expect(result.id).toBe("456");
|
|
136
|
+
expect(result.status).toBe("paid");
|
|
137
|
+
expect(result.amount).toBe(1000);
|
|
138
|
+
expect(result.currency).toBe("usd");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("throws LS_ORDER_NOT_FOUND when order not found", async () => {
|
|
142
|
+
mockLS.getOrder.mockResolvedValue({
|
|
143
|
+
error: { message: "Order not found" },
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
await expect(adapter.getPaymentStatus("999")).rejects.toThrow(PaymentError);
|
|
147
|
+
await expect(adapter.getPaymentStatus("999")).rejects.toMatchObject({
|
|
148
|
+
code: "LS_ORDER_NOT_FOUND",
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("verifies webhook", async () => {
|
|
153
|
+
const rawBody = Buffer.from(
|
|
154
|
+
JSON.stringify({
|
|
155
|
+
meta: { event_name: "order_created" },
|
|
156
|
+
data: { id: "123" },
|
|
157
|
+
}),
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
const result = await adapter.verifyWebhook(rawBody, "correct_signature");
|
|
161
|
+
|
|
162
|
+
expect(result.event).toBe("order_created");
|
|
163
|
+
expect(result.data.id).toBe("123");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("throws WEBHOOK_INVALID on wrong signature", async () => {
|
|
167
|
+
const rawBody = Buffer.from("{}");
|
|
168
|
+
|
|
169
|
+
await expect(
|
|
170
|
+
adapter.verifyWebhook(rawBody, "wrong_signature"),
|
|
171
|
+
).rejects.toThrow(PaymentError);
|
|
172
|
+
await expect(
|
|
173
|
+
adapter.verifyWebhook(rawBody, "wrong_signature"),
|
|
174
|
+
).rejects.toMatchObject({
|
|
175
|
+
code: "WEBHOOK_INVALID",
|
|
176
|
+
statusCode: 401,
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("throws CONFIG_ERROR when apiKey missing", () => {
|
|
181
|
+
expect(
|
|
182
|
+
() =>
|
|
183
|
+
new LemonSqueezyAdapter({
|
|
184
|
+
webhookSecret: "secret_fake",
|
|
185
|
+
storeId: "123",
|
|
186
|
+
}),
|
|
187
|
+
).toThrow(PaymentError);
|
|
188
|
+
expect(
|
|
189
|
+
() =>
|
|
190
|
+
new LemonSqueezyAdapter({
|
|
191
|
+
webhookSecret: "secret_fake",
|
|
192
|
+
storeId: "123",
|
|
193
|
+
}),
|
|
194
|
+
).toMatchObject({
|
|
195
|
+
code: "CONFIG_ERROR",
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("throws CONFIG_ERROR when webhookSecret missing", () => {
|
|
200
|
+
expect(
|
|
201
|
+
() =>
|
|
202
|
+
new LemonSqueezyAdapter({
|
|
203
|
+
apiKey: "api_fake",
|
|
204
|
+
storeId: "123",
|
|
205
|
+
}),
|
|
206
|
+
).toThrow(PaymentError);
|
|
207
|
+
expect(
|
|
208
|
+
() =>
|
|
209
|
+
new LemonSqueezyAdapter({
|
|
210
|
+
apiKey: "api_fake",
|
|
211
|
+
storeId: "123",
|
|
212
|
+
}),
|
|
213
|
+
).toMatchObject({
|
|
214
|
+
code: "CONFIG_ERROR",
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("throws CONFIG_ERROR when storeId missing", () => {
|
|
219
|
+
expect(
|
|
220
|
+
() =>
|
|
221
|
+
new LemonSqueezyAdapter({
|
|
222
|
+
apiKey: "api_fake",
|
|
223
|
+
webhookSecret: "secret_fake",
|
|
224
|
+
}),
|
|
225
|
+
).toThrow(PaymentError);
|
|
226
|
+
expect(
|
|
227
|
+
() =>
|
|
228
|
+
new LemonSqueezyAdapter({
|
|
229
|
+
apiKey: "api_fake",
|
|
230
|
+
webhookSecret: "secret_fake",
|
|
231
|
+
}),
|
|
232
|
+
).toMatchObject({
|
|
233
|
+
code: "CONFIG_ERROR",
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { StripeAdapter } from "../adapters/StripeAdapter.js";
|
|
3
|
+
import { PaymentError } from "../errors/PaymentError.js";
|
|
4
|
+
|
|
5
|
+
vi.mock("stripe", () => {
|
|
6
|
+
return {
|
|
7
|
+
default: vi.fn().mockImplementation(() => ({
|
|
8
|
+
paymentIntents: {
|
|
9
|
+
create: vi.fn(),
|
|
10
|
+
retrieve: vi.fn(),
|
|
11
|
+
},
|
|
12
|
+
refunds: {
|
|
13
|
+
create: vi.fn(),
|
|
14
|
+
},
|
|
15
|
+
webhooks: {
|
|
16
|
+
constructEvent: vi.fn(),
|
|
17
|
+
},
|
|
18
|
+
})),
|
|
19
|
+
};
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("StripeAdapter", () => {
|
|
23
|
+
let adapter;
|
|
24
|
+
let mockStripe;
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
vi.clearAllMocks();
|
|
28
|
+
mockStripe = new (vi.mocked(require("stripe").default))();
|
|
29
|
+
adapter = new StripeAdapter({
|
|
30
|
+
secretKey: "sk_test_fake",
|
|
31
|
+
webhookSecret: "whsec_fake",
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("creates a payment intent", async () => {
|
|
36
|
+
mockStripe.paymentIntents.create.mockResolvedValue({
|
|
37
|
+
id: "pi_abc123",
|
|
38
|
+
client_secret: "secret_fake",
|
|
39
|
+
status: "requires_payment_method",
|
|
40
|
+
amount: 1000,
|
|
41
|
+
currency: "usd",
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const result = await adapter.createPayment({
|
|
45
|
+
amount: 1000,
|
|
46
|
+
currency: "usd",
|
|
47
|
+
metadata: { orderId: "123" },
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
expect(result.id).toBe("pi_abc123");
|
|
51
|
+
expect(result.clientSecret).toBe("secret_fake");
|
|
52
|
+
expect(result.status).toBe("requires_payment_method");
|
|
53
|
+
expect(result.amount).toBe(1000);
|
|
54
|
+
expect(result.currency).toBe("usd");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("refunds a payment", async () => {
|
|
58
|
+
mockStripe.refunds.create.mockResolvedValue({
|
|
59
|
+
id: "rf_abc123",
|
|
60
|
+
status: "succeeded",
|
|
61
|
+
amount: 500,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const result = await adapter.refundPayment({
|
|
65
|
+
paymentId: "pi_abc123",
|
|
66
|
+
amount: 500,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
expect(result.id).toBe("rf_abc123");
|
|
70
|
+
expect(result.status).toBe("succeeded");
|
|
71
|
+
expect(result.amount).toBe(500);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("gets payment status", async () => {
|
|
75
|
+
mockStripe.paymentIntents.retrieve.mockResolvedValue({
|
|
76
|
+
id: "pi_abc123",
|
|
77
|
+
status: "succeeded",
|
|
78
|
+
amount: 1000,
|
|
79
|
+
currency: "usd",
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const result = await adapter.getPaymentStatus("pi_abc123");
|
|
83
|
+
|
|
84
|
+
expect(result.id).toBe("pi_abc123");
|
|
85
|
+
expect(result.status).toBe("paid");
|
|
86
|
+
expect(result.amount).toBe(1000);
|
|
87
|
+
expect(result.currency).toBe("usd");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("verifies webhook", async () => {
|
|
91
|
+
mockStripe.webhooks.constructEvent.mockReturnValue({
|
|
92
|
+
type: "payment_intent.succeeded",
|
|
93
|
+
data: { object: { id: "pi_abc123" } },
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const result = await adapter.verifyWebhook(Buffer.from("{}"), "signature");
|
|
97
|
+
|
|
98
|
+
expect(result.event).toBe("payment_intent.succeeded");
|
|
99
|
+
expect(result.data.id).toBe("pi_abc123");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("throws WEBHOOK_INVALID on bad signature", async () => {
|
|
103
|
+
mockStripe.webhooks.constructEvent.mockImplementation(() => {
|
|
104
|
+
throw new Error("Invalid signature");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
await expect(
|
|
108
|
+
adapter.verifyWebhook(Buffer.from("{}"), "bad"),
|
|
109
|
+
).rejects.toThrow(PaymentError);
|
|
110
|
+
await expect(
|
|
111
|
+
adapter.verifyWebhook(Buffer.from("{}"), "bad"),
|
|
112
|
+
).rejects.toMatchObject({
|
|
113
|
+
code: "WEBHOOK_INVALID",
|
|
114
|
+
statusCode: 401,
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("throws CONFIG_ERROR when secretKey missing", () => {
|
|
119
|
+
expect(() => new StripeAdapter({ webhookSecret: "whsec_fake" })).toThrow(
|
|
120
|
+
PaymentError,
|
|
121
|
+
);
|
|
122
|
+
expect(
|
|
123
|
+
() => new StripeAdapter({ webhookSecret: "whsec_fake" }),
|
|
124
|
+
).toMatchObject({
|
|
125
|
+
code: "CONFIG_ERROR",
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("throws CONFIG_ERROR when webhookSecret missing", () => {
|
|
130
|
+
expect(() => new StripeAdapter({ secretKey: "sk_test_fake" })).toThrow(
|
|
131
|
+
PaymentError,
|
|
132
|
+
);
|
|
133
|
+
expect(
|
|
134
|
+
() => new StripeAdapter({ secretKey: "sk_test_fake" }),
|
|
135
|
+
).toMatchObject({
|
|
136
|
+
code: "CONFIG_ERROR",
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import * as paymentsService from "../services/payments.service.js";
|
|
3
|
+
import { PaymentError } from "../errors/PaymentError.js";
|
|
4
|
+
|
|
5
|
+
vi.mock("../adapters/StripeAdapter.js");
|
|
6
|
+
vi.mock("../adapters/LemonSqueezyAdapter.js");
|
|
7
|
+
|
|
8
|
+
describe("payments.service", () => {
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
vi.clearAllMocks();
|
|
11
|
+
paymentsService.resetAdapter();
|
|
12
|
+
delete process.env.PAYMENT_PROVIDER;
|
|
13
|
+
delete process.env.STRIPE_SECRET_KEY;
|
|
14
|
+
delete process.env.STRIPE_WEBHOOK_SECRET;
|
|
15
|
+
delete process.env.LEMONSQUEEZY_API_KEY;
|
|
16
|
+
delete process.env.LEMONSQUEEZY_WEBHOOK_SECRET;
|
|
17
|
+
delete process.env.LEMONSQUEEZY_STORE_ID;
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("getAdapter returns StripeAdapter when PAYMENT_PROVIDER=stripe", () => {
|
|
21
|
+
process.env.PAYMENT_PROVIDER = "stripe";
|
|
22
|
+
process.env.STRIPE_SECRET_KEY = "sk_test_fake";
|
|
23
|
+
process.env.STRIPE_WEBHOOK_SECRET = "whsec_fake";
|
|
24
|
+
|
|
25
|
+
const adapter = paymentsService.getAdapter();
|
|
26
|
+
expect(
|
|
27
|
+
vi.mocked(require("../adapters/StripeAdapter.js")).StripeAdapter,
|
|
28
|
+
).toHaveBeenCalledWith({
|
|
29
|
+
secretKey: "sk_test_fake",
|
|
30
|
+
webhookSecret: "whsec_fake",
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("getAdapter returns LemonSqueezyAdapter when PAYMENT_PROVIDER=lemonsqueezy", () => {
|
|
35
|
+
process.env.PAYMENT_PROVIDER = "lemonsqueezy";
|
|
36
|
+
process.env.LEMONSQUEEZY_API_KEY = "api_fake";
|
|
37
|
+
process.env.LEMONSQUEEZY_WEBHOOK_SECRET = "secret_fake";
|
|
38
|
+
process.env.LEMONSQUEEZY_STORE_ID = "123";
|
|
39
|
+
|
|
40
|
+
const adapter = paymentsService.getAdapter();
|
|
41
|
+
expect(
|
|
42
|
+
vi.mocked(require("../adapters/LemonSqueezyAdapter.js"))
|
|
43
|
+
.LemonSqueezyAdapter,
|
|
44
|
+
).toHaveBeenCalledWith({
|
|
45
|
+
apiKey: "api_fake",
|
|
46
|
+
webhookSecret: "secret_fake",
|
|
47
|
+
storeId: "123",
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("getAdapter throws PROVIDER_NOT_CONFIGURED when env var not set", () => {
|
|
52
|
+
expect(() => paymentsService.getAdapter()).toThrow(PaymentError);
|
|
53
|
+
expect(() => paymentsService.getAdapter()).toMatchObject({
|
|
54
|
+
code: "PROVIDER_NOT_CONFIGURED",
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("getAdapter throws CONFIG_ERROR for unknown provider", () => {
|
|
59
|
+
process.env.PAYMENT_PROVIDER = "unknown";
|
|
60
|
+
expect(() => paymentsService.getAdapter()).toThrow(PaymentError);
|
|
61
|
+
expect(() => paymentsService.getAdapter()).toMatchObject({
|
|
62
|
+
code: "CONFIG_ERROR",
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("getAdapter caches instance", () => {
|
|
67
|
+
process.env.PAYMENT_PROVIDER = "stripe";
|
|
68
|
+
process.env.STRIPE_SECRET_KEY = "sk_test_fake";
|
|
69
|
+
process.env.STRIPE_WEBHOOK_SECRET = "whsec_fake";
|
|
70
|
+
|
|
71
|
+
const adapter1 = paymentsService.getAdapter();
|
|
72
|
+
const adapter2 = paymentsService.getAdapter();
|
|
73
|
+
expect(adapter1).toBe(adapter2);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("resetAdapter creates new instance", () => {
|
|
77
|
+
process.env.PAYMENT_PROVIDER = "stripe";
|
|
78
|
+
process.env.STRIPE_SECRET_KEY = "sk_test_fake";
|
|
79
|
+
process.env.STRIPE_WEBHOOK_SECRET = "whsec_fake";
|
|
80
|
+
|
|
81
|
+
const adapter1 = paymentsService.getAdapter();
|
|
82
|
+
paymentsService.resetAdapter();
|
|
83
|
+
const adapter2 = paymentsService.getAdapter();
|
|
84
|
+
expect(adapter1).not.toBe(adapter2);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("processWebhook returns duplicate: false on first call", async () => {
|
|
88
|
+
process.env.PAYMENT_PROVIDER = "stripe";
|
|
89
|
+
process.env.STRIPE_SECRET_KEY = "sk_test_fake";
|
|
90
|
+
process.env.STRIPE_WEBHOOK_SECRET = "whsec_fake";
|
|
91
|
+
|
|
92
|
+
const mockAdapter = {
|
|
93
|
+
verifyWebhook: vi.fn().mockResolvedValue({
|
|
94
|
+
event: "payment_intent.succeeded",
|
|
95
|
+
data: { id: "pi_123" },
|
|
96
|
+
}),
|
|
97
|
+
};
|
|
98
|
+
vi.mocked(
|
|
99
|
+
require("../adapters/StripeAdapter.js"),
|
|
100
|
+
).StripeAdapter.mockReturnValue(mockAdapter);
|
|
101
|
+
|
|
102
|
+
const result = await paymentsService.processWebhook(
|
|
103
|
+
Buffer.from("{}"),
|
|
104
|
+
"sig",
|
|
105
|
+
);
|
|
106
|
+
expect(result.duplicate).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("processWebhook returns duplicate: true on second call", async () => {
|
|
110
|
+
process.env.PAYMENT_PROVIDER = "stripe";
|
|
111
|
+
process.env.STRIPE_SECRET_KEY = "sk_test_fake";
|
|
112
|
+
process.env.STRIPE_WEBHOOK_SECRET = "whsec_fake";
|
|
113
|
+
|
|
114
|
+
const mockAdapter = {
|
|
115
|
+
verifyWebhook: vi.fn().mockResolvedValue({
|
|
116
|
+
event: "payment_intent.succeeded",
|
|
117
|
+
data: { id: "pi_123" },
|
|
118
|
+
}),
|
|
119
|
+
};
|
|
120
|
+
vi.mocked(
|
|
121
|
+
require("../adapters/StripeAdapter.js"),
|
|
122
|
+
).StripeAdapter.mockReturnValue(mockAdapter);
|
|
123
|
+
|
|
124
|
+
await paymentsService.processWebhook(Buffer.from("{}"), "sig");
|
|
125
|
+
const result = await paymentsService.processWebhook(
|
|
126
|
+
Buffer.from("{}"),
|
|
127
|
+
"sig",
|
|
128
|
+
);
|
|
129
|
+
expect(result.duplicate).toBe(true);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
getWebhookSignatureHeader,
|
|
4
|
+
extractSignature,
|
|
5
|
+
} from "../helpers/webhookUtils.js";
|
|
6
|
+
import { PaymentError } from "../errors/PaymentError.js";
|
|
7
|
+
|
|
8
|
+
describe("webhookUtils", () => {
|
|
9
|
+
describe("getWebhookSignatureHeader", () => {
|
|
10
|
+
it("returns stripe-signature for stripe", () => {
|
|
11
|
+
expect(getWebhookSignatureHeader("stripe")).toBe("stripe-signature");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("returns x-signature for lemonsqueezy", () => {
|
|
15
|
+
expect(getWebhookSignatureHeader("lemonsqueezy")).toBe("x-signature");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("throws PaymentError for unknown provider", () => {
|
|
19
|
+
expect(() => getWebhookSignatureHeader("unknown")).toThrow(PaymentError);
|
|
20
|
+
expect(() => getWebhookSignatureHeader("unknown")).toMatchObject({
|
|
21
|
+
code: "CONFIG_ERROR",
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("extractSignature", () => {
|
|
27
|
+
it("returns header value when present", () => {
|
|
28
|
+
const req = {
|
|
29
|
+
headers: {
|
|
30
|
+
"stripe-signature": "t=123,v1=abc",
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
expect(extractSignature(req, "stripe")).toBe("t=123,v1=abc");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("throws WEBHOOK_INVALID when header missing", () => {
|
|
37
|
+
const req = {
|
|
38
|
+
headers: {},
|
|
39
|
+
};
|
|
40
|
+
expect(() => extractSignature(req, "stripe")).toThrow(PaymentError);
|
|
41
|
+
expect(() => extractSignature(req, "stripe")).toMatchObject({
|
|
42
|
+
code: "WEBHOOK_INVALID",
|
|
43
|
+
statusCode: 401,
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -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
|
+
}
|