create-charcole 2.2.2 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/CHANGELOG.md +33 -2
  2. package/README.md +92 -5
  3. package/bin/index.js +121 -1
  4. package/package.json +1 -1
  5. package/packages/payments/CHANGELOG.md +14 -0
  6. package/packages/payments/README.md +222 -0
  7. package/packages/payments/charcoles-payments-1.0.0.tgz +0 -0
  8. package/packages/payments/package.json +61 -0
  9. package/packages/payments/smoke-test.js +20 -0
  10. package/packages/payments/src/__tests__/LemonSqueezyAdapter.test.js +236 -0
  11. package/packages/payments/src/__tests__/StripeAdapter.test.js +139 -0
  12. package/packages/payments/src/__tests__/payments.service.test.js +131 -0
  13. package/packages/payments/src/__tests__/webhookUtils.test.js +47 -0
  14. package/packages/payments/src/adapters/LemonSqueezyAdapter.js +150 -0
  15. package/packages/payments/src/adapters/PaymentAdapter.js +109 -0
  16. package/packages/payments/src/adapters/StripeAdapter.js +114 -0
  17. package/packages/payments/src/controllers/payments.controller.js +48 -0
  18. package/packages/payments/src/errors/PaymentError.js +8 -0
  19. package/packages/payments/src/helpers/webhookUtils.js +27 -0
  20. package/packages/payments/src/index.d.ts +87 -0
  21. package/packages/payments/src/index.js +6 -0
  22. package/packages/payments/src/routes/payments.routes.js +41 -0
  23. package/packages/payments/src/schemas/payments.schemas.js +24 -0
  24. package/packages/payments/src/services/payments.service.js +68 -0
  25. package/plan-2.3.0.md +1756 -0
  26. package/template/js/.env.example +19 -1
  27. package/template/js/README.md +133 -5
  28. package/template/js/basePackage.json +1 -1
  29. package/template/js/src/app.js +17 -0
  30. package/template/js/src/config/env.js +8 -0
  31. package/template/js/src/lib/swagger/SWAGGER_GUIDE.md +34 -0
  32. package/template/js/src/modules/payments/__tests__/payments.controller.test.js +342 -0
  33. package/template/js/src/modules/payments/__tests__/payments.routes.test.js +256 -0
  34. package/template/js/src/modules/payments/__tests__/payments.schemas.test.js +94 -0
  35. package/template/js/src/modules/payments/__tests__/payments.service.test.js +141 -0
  36. package/template/js/src/modules/payments/package.json +7 -0
  37. package/template/js/src/modules/payments/payments.adapter.js +47 -0
  38. package/template/js/src/modules/payments/payments.constants.js +20 -0
  39. package/template/js/src/modules/payments/payments.controller.js +85 -0
  40. package/template/js/src/modules/payments/payments.routes.js +125 -0
  41. package/template/js/src/modules/payments/payments.schemas.js +28 -0
  42. package/template/js/src/modules/payments/payments.service.js +34 -0
  43. package/template/js/src/modules/swagger/package.json +1 -1
  44. package/template/js/src/routes/index.js +16 -0
  45. package/template/ts/.env.example +17 -1
  46. package/template/ts/README.md +135 -5
  47. package/template/ts/basePackage.json +1 -1
  48. package/template/ts/src/app.ts +13 -0
  49. package/template/ts/src/config/env.ts +7 -0
  50. package/template/ts/src/lib/swagger/SWAGGER_GUIDE.md +34 -0
  51. package/template/ts/src/modules/payments/__tests__/payments.controller.test.ts +282 -0
  52. package/template/ts/src/modules/payments/__tests__/payments.routes.test.ts +256 -0
  53. package/template/ts/src/modules/payments/__tests__/payments.schemas.test.ts +94 -0
  54. package/template/ts/src/modules/payments/__tests__/payments.service.test.ts +135 -0
  55. package/template/ts/src/modules/payments/package.json +7 -0
  56. package/template/ts/src/modules/payments/payments.adapter.ts +74 -0
  57. package/template/ts/src/modules/payments/payments.constants.ts +18 -0
  58. package/template/ts/src/modules/payments/payments.controller.ts +104 -0
  59. package/template/ts/src/modules/payments/payments.routes.ts +125 -0
  60. package/template/ts/src/modules/payments/payments.schemas.ts +31 -0
  61. package/template/ts/src/modules/payments/payments.service.ts +51 -0
  62. package/template/ts/src/modules/payments/payments.types.ts +56 -0
  63. package/template/ts/src/modules/swagger/package.json +1 -1
  64. package/template/ts/src/routes/index.ts +8 -0
@@ -14,4 +14,22 @@ REQUEST_TIMEOUT=30000
14
14
 
15
15
 
16
16
  # Authentication
17
- JWT_SECRET=your-secret-key-here
17
+ JWT_SECRET=your-secret-key-here
18
+
19
+ # ─── Payments ──────────────────────────────────────────────────────────────────
20
+ # PAYMENT_PROVIDER selects the active payment adapter.
21
+ # Options: "stripe" | "lemonsqueezy"
22
+ # Use "lemonsqueezy" if you are in Pakistan — Stripe does not support PKR payouts.
23
+ PAYMENT_PROVIDER=
24
+
25
+ # Stripe — https://dashboard.stripe.com/apikeys
26
+ # Use test keys during development: sk_test_...
27
+ STRIPE_SECRET_KEY=
28
+ STRIPE_WEBHOOK_SECRET=
29
+ STRIPE_PUBLISHABLE_KEY=
30
+
31
+ # LemonSqueezy — https://app.lemonsqueezy.com/settings/api
32
+ # LEMONSQUEEZY_STORE_ID is the numeric ID from your store URL
33
+ LEMONSQUEEZY_API_KEY=
34
+ LEMONSQUEEZY_WEBHOOK_SECRET=
35
+ LEMONSQUEEZY_STORE_ID=
@@ -9,11 +9,12 @@ Welcome! This guide will help you set up and start using the Charcole API framew
9
9
  3. [Configuration](#configuration)
10
10
  4. [Creating Your First Endpoint](#creating-your-first-endpoint)
11
11
  5. [API Documentation with Swagger](#api-documentation-with-swagger)
12
- 6. [Error Handling](#error-handling)
13
- 7. [Validation](#validation)
14
- 8. [Logging](#logging)
15
- 9. [Running Your API](#running-your-api)
16
- 10. [Troubleshooting](#troubleshooting)
12
+ 6. [Payment Processing (if enabled)](#payment-processing-if-enabled)
13
+ 7. [Error Handling](#error-handling)
14
+ 8. [Validation](#validation)
15
+ 9. [Logging](#logging)
16
+ 10. [Running Your API](#running-your-api)
17
+ 11. [Troubleshooting](#troubleshooting)
17
18
 
18
19
  ---
19
20
 
@@ -523,6 +524,133 @@ For protected endpoints:
523
524
 
524
525
  ---
525
526
 
527
+ ## 💳 Payment Processing (if enabled)
528
+
529
+ ### Overview
530
+
531
+ Your API comes with **production-ready payment processing** if you selected the payments module during setup. Choose between:
532
+
533
+ - **Stripe** - Industry standard payment processing
534
+ - **LemonSqueezy** - Perfect for Pakistani developers (PKR payout support via bank transfer)
535
+ - **Both** - Flexibility to switch providers
536
+
537
+ ### Payment Endpoints
538
+
539
+ Four ready-to-use payment APIs are auto-configured:
540
+
541
+ ```
542
+ POST /api/payments/create-intent # Create payment intent
543
+ POST /api/payments/refund # Refund a payment
544
+ GET /api/payments/status/:paymentId # Check payment status
545
+ POST /api/payments/webhook # Webhook receiver
546
+ ```
547
+
548
+ ### Configuration
549
+
550
+ Add payment provider credentials to `.env`:
551
+
552
+ ```env
553
+ # Payment Provider (stripe, lemonsqueezy, or both)
554
+ PAYMENT_PROVIDER=stripe
555
+
556
+ # Stripe Configuration
557
+ STRIPE_SECRET_KEY=sk_live_...
558
+ STRIPE_WEBHOOK_SECRET=whsec_...
559
+
560
+ # LemonSqueezy Configuration
561
+ LEMONSQUEEZY_API_KEY=...
562
+ LEMONSQUEEZY_WEBHOOK_SECRET=...
563
+ ```
564
+
565
+ ### Usage Example
566
+
567
+ ```javascript
568
+ import { setupPayments } from "@charcoles/payments";
569
+
570
+ // In your app.js
571
+ const paymentAdapter = setupPayments({
572
+ provider: process.env.PAYMENT_PROVIDER,
573
+ stripeKey: process.env.STRIPE_SECRET_KEY,
574
+ lemonsqueezyKey: process.env.LEMONSQUEEZY_API_KEY,
575
+ });
576
+
577
+ // In your controller
578
+ export const createPaymentIntent = asyncHandler(async (req, res) => {
579
+ const { amount, currency, customerId } = req.body;
580
+
581
+ const intent = await paymentAdapter.createPayment({
582
+ amount,
583
+ currency,
584
+ customerId,
585
+ });
586
+
587
+ sendSuccess(res, intent, 201, "Payment intent created");
588
+ });
589
+ ```
590
+
591
+ ### Webhook Handling
592
+
593
+ Webhooks are automatically configured and validated:
594
+
595
+ ```javascript
596
+ // src/modules/payments/payments.routes.js
597
+ // POST /api/payments/webhook automatically handles:
598
+ // - Stripe: payment_intent.succeeded, charge.refunded
599
+ // - LemonSqueezy: order_created, order_refunded
600
+
601
+ // Raw body middleware auto-configured in app.js
602
+ // app.use('/payments/webhook', express.raw({ type: 'application/json' }))
603
+ ```
604
+
605
+ ### Error Handling
606
+
607
+ Payment errors are automatically caught by the global error handler:
608
+
609
+ ```javascript
610
+ import { PaymentError } from "@charcoles/payments";
611
+
612
+ // Throws structured error
613
+ throw new PaymentError("Insufficient funds", {
614
+ code: "insufficient_funds",
615
+ statusCode: 402,
616
+ });
617
+
618
+ // Response:
619
+ // {
620
+ // "success": false,
621
+ // "message": "Insufficient funds",
622
+ // "statusCode": 402,
623
+ // "code": "insufficient_funds"
624
+ // }
625
+ ```
626
+
627
+ ### Testing Payments Locally
628
+
629
+ **Stripe:**
630
+
631
+ ```bash
632
+ STRIPE_SECRET_KEY=sk_test_... npm run dev
633
+ # Use test card: 4242 4242 4242 4242
634
+ ```
635
+
636
+ **LemonSqueezy:**
637
+
638
+ ```bash
639
+ LEMONSQUEEZY_API_KEY=... npm run dev
640
+ # Sandbox mode automatically used with test keys
641
+ ```
642
+
643
+ ### Documentation in Swagger
644
+
645
+ All payment endpoints are automatically documented in Swagger UI when enabled:
646
+
647
+ 1. Start server: `npm run dev`
648
+ 2. Visit http://localhost:3000/api-docs
649
+ 3. Look for **Payments** tag
650
+ 4. Test all endpoints directly from the browser
651
+
652
+ ---
653
+
526
654
  ## ✔️ Validation
527
655
 
528
656
  ### Zod Schema Basics
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "charcole",
3
- "version": "2.2.2",
3
+ "version": "2.3.0",
4
4
  "description": "Production-grade Node.js Express API",
5
5
  "main": "src/server.js",
6
6
  "type": "module",
@@ -1,4 +1,7 @@
1
1
  import express from "express";
2
+ import { existsSync } from "fs";
3
+ import { dirname, join } from "path";
4
+ import { fileURLToPath } from "url";
2
5
  import { userRepo } from "./repositories/user.repo.js";
3
6
  import cors from "cors";
4
7
  import { env } from "./config/env.js";
@@ -14,6 +17,13 @@ import { logger } from "./utils/logger.js";
14
17
  import routes from "./routes/index.js";
15
18
  import swaggerOptions from "./config/swagger.config.js";
16
19
  import { setupSwagger } from "@charcoles/swagger";
20
+
21
+ const __dirname = dirname(fileURLToPath(import.meta.url));
22
+ const paymentsModulePath = join(
23
+ __dirname,
24
+ "modules/payments/payments.routes.js",
25
+ );
26
+
17
27
  export const app = express();
18
28
 
19
29
  // Trust proxy
@@ -30,6 +40,13 @@ app.use(
30
40
  );
31
41
 
32
42
  // Body parsing middleware
43
+ // Webhook raw body — must be registered BEFORE express.json()
44
+ // Required for Stripe and LemonSqueezy webhook signature verification.
45
+ // express.json() destroys the raw bytes needed for HMAC verification.
46
+ if (existsSync(paymentsModulePath)) {
47
+ app.use("/payments/webhook", express.raw({ type: "application/json" }));
48
+ }
49
+
33
50
  app.use(express.json({ limit: "10mb" }));
34
51
  app.use(express.urlencoded({ extended: true, limit: "10mb" }));
35
52
 
@@ -8,6 +8,14 @@ const envSchema = z.object({
8
8
  LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
9
9
  CORS_ORIGIN: z.string().default("*"),
10
10
  REQUEST_TIMEOUT: z.coerce.number().default(30000),
11
+ // Payments — all optional so projects without payments don't fail env validation
12
+ PAYMENT_PROVIDER: z.enum(["stripe", "lemonsqueezy"]).optional(),
13
+ STRIPE_SECRET_KEY: z.string().optional(),
14
+ STRIPE_WEBHOOK_SECRET: z.string().optional(),
15
+ STRIPE_PUBLISHABLE_KEY: z.string().optional(),
16
+ LEMONSQUEEZY_API_KEY: z.string().optional(),
17
+ LEMONSQUEEZY_WEBHOOK_SECRET: z.string().optional(),
18
+ LEMONSQUEEZY_STORE_ID: z.string().optional(),
11
19
  });
12
20
 
13
21
  const parseEnv = () => {
@@ -96,6 +96,40 @@ That's it! Your Zod schemas are now available as `$ref` in Swagger.
96
96
 
97
97
  **Result:** 60 lines eliminated! And your schema stays in sync automatically.
98
98
 
99
+ ### Example 2.1: Documenting a Payments Webhook Endpoint
100
+
101
+ ```javascript
102
+ /**
103
+ * @swagger
104
+ * /api/payments/webhook:
105
+ * post:
106
+ * summary: Receive payment provider webhook events
107
+ * tags:
108
+ * - Payments
109
+ * requestBody:
110
+ * required: true
111
+ * content:
112
+ * application/json:
113
+ * schema:
114
+ * type: object
115
+ * responses:
116
+ * 200:
117
+ * description: Webhook received
118
+ * 400:
119
+ * description: Missing signature header
120
+ */
121
+ router.post("/webhook", handleWebhook);
122
+ ```
123
+
124
+ When you expose a webhook route, mount raw JSON middleware before `express.json()` so the provider signature verification receives the raw body:
125
+
126
+ ```javascript
127
+ app.use("/payments/webhook", express.raw({ type: "application/json" }));
128
+ app.use(express.json());
129
+ ```
130
+
131
+ This means your final webhook route becomes `/api/payments/webhook` when the payments router is mounted at `/api`.
132
+
99
133
  ---
100
134
 
101
135
  ## 📚 Complete Examples
@@ -0,0 +1,342 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+
3
+ vi.mock("../payments.service.js", () => ({
4
+ createPayment: vi.fn(),
5
+ refundPayment: vi.fn(),
6
+ getPaymentStatus: vi.fn(),
7
+ processWebhook: vi.fn(),
8
+ }));
9
+
10
+ vi.mock("../../../utils/logger.js", () => ({
11
+ logger: {
12
+ info: vi.fn(),
13
+ warn: vi.fn(),
14
+ error: vi.fn(),
15
+ },
16
+ }));
17
+
18
+ import * as controller from "../payments.controller.js";
19
+ import * as service from "../payments.service.js";
20
+
21
+ function buildMocks(overrides = {}) {
22
+ const req = {
23
+ body: {},
24
+ params: {},
25
+ headers: {},
26
+ ...overrides,
27
+ };
28
+
29
+ const res = {
30
+ status: vi.fn().mockReturnThis(),
31
+ json: vi.fn().mockReturnThis(),
32
+ };
33
+
34
+ const next = vi.fn();
35
+
36
+ return { req, res, next };
37
+ }
38
+
39
+ beforeEach(() => {
40
+ vi.clearAllMocks();
41
+ delete process.env.PAYMENT_PROVIDER;
42
+ });
43
+
44
+ describe("createPayment controller", () => {
45
+ it("calls service.createPayment with validated body and responds 201", async () => {
46
+ const result = {
47
+ id: "pi_test_123",
48
+ clientSecret: "secret_abc",
49
+ status: "requires_payment_method",
50
+ amount: 999,
51
+ currency: "usd",
52
+ metadata: {},
53
+ };
54
+ service.createPayment.mockResolvedValue(result);
55
+
56
+ const { req, res, next } = buildMocks({
57
+ body: { amount: 999, currency: "usd" },
58
+ });
59
+
60
+ await controller.createPayment(req, res, next);
61
+
62
+ expect(service.createPayment).toHaveBeenCalledTimes(1);
63
+ expect(service.createPayment).toHaveBeenCalledWith({
64
+ amount: 999,
65
+ currency: "usd",
66
+ metadata: {},
67
+ });
68
+ expect(res.status).toHaveBeenCalledWith(201);
69
+ expect(res.json).toHaveBeenCalledWith(
70
+ expect.objectContaining({ success: true, data: result }),
71
+ );
72
+ expect(next).not.toHaveBeenCalled();
73
+ });
74
+
75
+ it("calls next(err) when schema validation fails", async () => {
76
+ const { req, res, next } = buildMocks({
77
+ body: { amount: "not-a-number", currency: "usd" },
78
+ });
79
+
80
+ await controller.createPayment(req, res, next);
81
+
82
+ expect(next).toHaveBeenCalled();
83
+ expect(res.json).not.toHaveBeenCalled();
84
+ });
85
+
86
+ it("calls next(err) when service.createPayment rejects", async () => {
87
+ const error = new Error("stripe down");
88
+ service.createPayment.mockRejectedValue(error);
89
+
90
+ const { req, res, next } = buildMocks({
91
+ body: { amount: 999, currency: "usd" },
92
+ });
93
+
94
+ await controller.createPayment(req, res, next);
95
+
96
+ expect(next).toHaveBeenCalledWith(error);
97
+ expect(next.mock.calls[0][0]).toBe(error);
98
+ });
99
+
100
+ it("currency is lowercased by the schema", async () => {
101
+ const result = {
102
+ id: "pi_test_123",
103
+ clientSecret: "secret_abc",
104
+ status: "requires_payment_method",
105
+ amount: 100,
106
+ currency: "usd",
107
+ metadata: {},
108
+ };
109
+ service.createPayment.mockResolvedValue(result);
110
+
111
+ const { req, res, next } = buildMocks({
112
+ body: { amount: 100, currency: "USD" },
113
+ });
114
+
115
+ await controller.createPayment(req, res, next);
116
+
117
+ expect(service.createPayment).toHaveBeenCalledWith({
118
+ amount: 100,
119
+ currency: "usd",
120
+ metadata: {},
121
+ });
122
+ expect(res.status).toHaveBeenCalledWith(201);
123
+ expect(res.json).toHaveBeenCalledWith(
124
+ expect.objectContaining({ success: true, data: result }),
125
+ );
126
+ expect(next).not.toHaveBeenCalled();
127
+ });
128
+ });
129
+
130
+ describe("refundPayment controller", () => {
131
+ it("calls service.refundPayment with paymentId and optional amount, responds 200", async () => {
132
+ const result = { id: "re_test_123", status: "succeeded", amount: 500 };
133
+ service.refundPayment.mockResolvedValue(result);
134
+
135
+ const { req, res, next } = buildMocks({
136
+ body: { paymentId: "pi_123", amount: 500 },
137
+ });
138
+
139
+ await controller.refundPayment(req, res, next);
140
+
141
+ expect(service.refundPayment).toHaveBeenCalledWith({
142
+ paymentId: "pi_123",
143
+ amount: 500,
144
+ });
145
+ expect(res.status).toHaveBeenCalledWith(200);
146
+ expect(res.json).toHaveBeenCalledWith(
147
+ expect.objectContaining({ success: true, data: result }),
148
+ );
149
+ expect(next).not.toHaveBeenCalled();
150
+ });
151
+
152
+ it("calls service.refundPayment with just paymentId when amount is omitted", async () => {
153
+ const result = { id: "re_test_456", status: "succeeded", amount: 0 };
154
+ service.refundPayment.mockResolvedValue(result);
155
+
156
+ const { req, res, next } = buildMocks({ body: { paymentId: "pi_123" } });
157
+
158
+ await controller.refundPayment(req, res, next);
159
+
160
+ expect(service.refundPayment).toHaveBeenCalledWith({
161
+ paymentId: "pi_123",
162
+ amount: undefined,
163
+ });
164
+ expect(res.status).toHaveBeenCalledWith(200);
165
+ expect(res.json).toHaveBeenCalledWith(
166
+ expect.objectContaining({ success: true, data: result }),
167
+ );
168
+ expect(next).not.toHaveBeenCalled();
169
+ });
170
+
171
+ it("calls next(err) when paymentId is missing from body", async () => {
172
+ const { req, res, next } = buildMocks({ body: { amount: 500 } });
173
+
174
+ await controller.refundPayment(req, res, next);
175
+
176
+ expect(next).toHaveBeenCalled();
177
+ expect(res.json).not.toHaveBeenCalled();
178
+ });
179
+
180
+ it("calls next(err) when service.refundPayment rejects", async () => {
181
+ const error = new Error("refund failed");
182
+ service.refundPayment.mockRejectedValue(error);
183
+
184
+ const { req, res, next } = buildMocks({
185
+ body: { paymentId: "pi_123", amount: 500 },
186
+ });
187
+
188
+ await controller.refundPayment(req, res, next);
189
+
190
+ expect(next).toHaveBeenCalledWith(error);
191
+ });
192
+ });
193
+
194
+ describe("getPaymentStatus controller", () => {
195
+ it("calls service.getPaymentStatus with req.params.paymentId and responds with result", async () => {
196
+ const result = {
197
+ id: "pi_123",
198
+ status: "paid",
199
+ amount: 999,
200
+ currency: "usd",
201
+ metadata: {},
202
+ };
203
+ service.getPaymentStatus.mockResolvedValue(result);
204
+
205
+ const { req, res, next } = buildMocks({ params: { paymentId: "pi_123" } });
206
+
207
+ await controller.getPaymentStatus(req, res, next);
208
+
209
+ expect(service.getPaymentStatus).toHaveBeenCalledWith("pi_123");
210
+ expect(res.status).toHaveBeenCalledWith(200);
211
+ expect(res.json).toHaveBeenCalledWith(
212
+ expect.objectContaining({ success: true, data: result }),
213
+ );
214
+ expect(next).not.toHaveBeenCalled();
215
+ });
216
+
217
+ it("calls next(err) when service rejects", async () => {
218
+ const error = new Error("status lookup failed");
219
+ service.getPaymentStatus.mockRejectedValue(error);
220
+
221
+ const { req, res, next } = buildMocks({ params: { paymentId: "pi_123" } });
222
+
223
+ await controller.getPaymentStatus(req, res, next);
224
+
225
+ expect(next).toHaveBeenCalledWith(error);
226
+ });
227
+ });
228
+
229
+ describe("handleWebhook controller", () => {
230
+ it("reads stripe-signature header when PAYMENT_PROVIDER is stripe", async () => {
231
+ process.env.PAYMENT_PROVIDER = "stripe";
232
+ const result = {
233
+ event: "payment_intent.succeeded",
234
+ data: {},
235
+ duplicate: false,
236
+ };
237
+ service.processWebhook.mockResolvedValue(result);
238
+
239
+ const { req, res, next } = buildMocks({
240
+ headers: { "stripe-signature": "test-sig" },
241
+ body: Buffer.from('{"id":"evt_123"}'),
242
+ });
243
+
244
+ await controller.handleWebhook(req, res, next);
245
+
246
+ expect(service.processWebhook).toHaveBeenCalledWith(req.body, "test-sig");
247
+ expect(res.status).toHaveBeenCalledWith(200);
248
+ expect(res.json).toHaveBeenCalledWith({ received: true });
249
+ expect(next).not.toHaveBeenCalled();
250
+ });
251
+
252
+ it("reads x-signature header when PAYMENT_PROVIDER is lemonsqueezy", async () => {
253
+ process.env.PAYMENT_PROVIDER = "lemonsqueezy";
254
+ const result = { event: "order_created", data: {}, duplicate: false };
255
+ service.processWebhook.mockResolvedValue(result);
256
+
257
+ const { req, res, next } = buildMocks({
258
+ headers: { "x-signature": "hmac-sig" },
259
+ body: Buffer.from('{"id":"evt_456"}'),
260
+ });
261
+
262
+ await controller.handleWebhook(req, res, next);
263
+
264
+ expect(service.processWebhook).toHaveBeenCalledWith(req.body, "hmac-sig");
265
+ expect(res.status).toHaveBeenCalledWith(200);
266
+ expect(res.json).toHaveBeenCalledWith({ received: true });
267
+ expect(next).not.toHaveBeenCalled();
268
+ });
269
+
270
+ it("responds 200 { received: true, duplicate: true } for duplicate events", async () => {
271
+ process.env.PAYMENT_PROVIDER = "stripe";
272
+ const result = {
273
+ event: "payment_intent.succeeded",
274
+ data: { id: "evt_789" },
275
+ duplicate: true,
276
+ };
277
+ service.processWebhook.mockResolvedValue(result);
278
+
279
+ const { req, res, next } = buildMocks({
280
+ headers: { "stripe-signature": "test-sig" },
281
+ body: Buffer.from('{"id":"evt_789"}'),
282
+ });
283
+
284
+ await controller.handleWebhook(req, res, next);
285
+
286
+ expect(res.status).toHaveBeenCalledWith(200);
287
+ expect(res.json).toHaveBeenCalledWith({ received: true, duplicate: true });
288
+ expect(next).not.toHaveBeenCalled();
289
+ });
290
+
291
+ it("responds 400 when signature header is missing", async () => {
292
+ process.env.PAYMENT_PROVIDER = "stripe";
293
+ const { req, res, next } = buildMocks({
294
+ body: Buffer.from('{"id":"evt_000"}'),
295
+ });
296
+
297
+ await controller.handleWebhook(req, res, next);
298
+
299
+ expect(res.status).toHaveBeenCalledWith(400);
300
+ expect(res.json).toHaveBeenCalledWith({
301
+ error: "Missing webhook signature header",
302
+ });
303
+ expect(next).not.toHaveBeenCalled();
304
+ });
305
+
306
+ it("calls next(err) when processWebhook throws an error", async () => {
307
+ process.env.PAYMENT_PROVIDER = "stripe";
308
+ const error = new Error("invalid signature");
309
+ service.processWebhook.mockRejectedValue(error);
310
+
311
+ const { req, res, next } = buildMocks({
312
+ headers: { "stripe-signature": "test-sig" },
313
+ body: Buffer.from('{"id":"evt_999"}'),
314
+ });
315
+
316
+ await controller.handleWebhook(req, res, next);
317
+
318
+ expect(next).toHaveBeenCalledWith(error);
319
+ });
320
+
321
+ it("passes req.body as a raw Buffer to processWebhook", async () => {
322
+ process.env.PAYMENT_PROVIDER = "stripe";
323
+ const result = {
324
+ event: "payment_intent.succeeded",
325
+ data: {},
326
+ duplicate: false,
327
+ };
328
+ service.processWebhook.mockResolvedValue(result);
329
+
330
+ const { req, res, next } = buildMocks({
331
+ headers: { "stripe-signature": "test-sig" },
332
+ body: Buffer.from('{"id":"evt_buffer"}'),
333
+ });
334
+
335
+ await controller.handleWebhook(req, res, next);
336
+
337
+ const rawArg = service.processWebhook.mock.calls[0][0];
338
+ expect(Buffer.isBuffer(rawArg)).toBe(true);
339
+ expect(rawArg.toString()).toBe('{"id":"evt_buffer"}');
340
+ expect(next).not.toHaveBeenCalled();
341
+ });
342
+ });