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
package/template/js/.env.example
CHANGED
|
@@ -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=
|
package/template/js/README.md
CHANGED
|
@@ -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. [
|
|
13
|
-
7. [
|
|
14
|
-
8. [
|
|
15
|
-
9. [
|
|
16
|
-
10. [
|
|
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
|
package/template/js/src/app.js
CHANGED
|
@@ -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
|
+
});
|