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
package/plan-2.3.0.md
ADDED
|
@@ -0,0 +1,1756 @@
|
|
|
1
|
+
# Charcole v2.3.0 — Payments Module Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **Audience**: AI coding agents + human engineers implementing this feature.
|
|
4
|
+
> **Goal**: Zero ambiguity. Every file, every decision, every edge case documented.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Table of Contents
|
|
9
|
+
|
|
10
|
+
1. [Overview & Core Concept](#overview)
|
|
11
|
+
2. [Project Context & Current Architecture](#context)
|
|
12
|
+
3. [Feature Architecture Decision Records (ADRs)](#adrs)
|
|
13
|
+
4. [Detailed File Map — What Gets Created/Modified](#file-map)
|
|
14
|
+
5. [Package: `@charcoles/payments` — Deep Spec](#package-spec)
|
|
15
|
+
6. [Adapter Contracts (Exact Interfaces)](#adapter-contracts)
|
|
16
|
+
7. [Stripe Adapter — Implementation Guide](#stripe-adapter)
|
|
17
|
+
8. [LemonSqueezy Adapter — Implementation Guide](#lemonsqueezy-adapter)
|
|
18
|
+
9. [Template Integration — JS & TS](#template-integration)
|
|
19
|
+
10. [CLI Changes (`bin/index.js`)](#cli-changes)
|
|
20
|
+
11. [Environment Variables — Full Spec](#env-spec)
|
|
21
|
+
12. [Route & Endpoint Spec](#route-spec)
|
|
22
|
+
13. [Zod Schemas — Full Definitions](#zod-schemas)
|
|
23
|
+
14. [Error Handling Strategy](#error-handling)
|
|
24
|
+
15. [Webhook Handling — Security & Verification](#webhooks)
|
|
25
|
+
16. [Testing Strategy — Full Coverage Plan](#testing)
|
|
26
|
+
17. [Code Style Rules (Enforced)](#code-style)
|
|
27
|
+
18. [Swagger / OpenAPI Integration](#swagger)
|
|
28
|
+
19. [Migration Guide (Existing Projects)](#migration)
|
|
29
|
+
20. [Release Checklist](#release-checklist)
|
|
30
|
+
21. [AI Agent Instructions](#ai-agent-instructions)
|
|
31
|
+
22. [Common Pitfalls & How to Avoid Them](#pitfalls)
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## 1. Overview & Core Concept {#overview}
|
|
36
|
+
|
|
37
|
+
Charcole v2.3.0 introduces a **payments module** — an optional, production-ready payment processing system that scaffolds into both JS and TS templates, and is also publishable as a standalone npm package (`@charcoles/payments`).
|
|
38
|
+
|
|
39
|
+
### What Gets Built
|
|
40
|
+
|
|
41
|
+
| Deliverable | Description |
|
|
42
|
+
| ----------------------------------- | -------------------------------------------- |
|
|
43
|
+
| `packages/payments/` | Standalone npm package `@charcoles/payments` |
|
|
44
|
+
| `template/js/src/modules/payments/` | JS template module |
|
|
45
|
+
| `template/ts/src/modules/payments/` | TS template module |
|
|
46
|
+
| CLI prompt update | New payments question in `bin/index.js` |
|
|
47
|
+
| Env schema updates | Payment vars added to Zod env config |
|
|
48
|
+
| Swagger docs | Payment endpoints auto-documented |
|
|
49
|
+
|
|
50
|
+
### Why Two Providers
|
|
51
|
+
|
|
52
|
+
- **Stripe** — Global standard. Best DX, best docs, best ecosystem.
|
|
53
|
+
- **LemonSqueezy** — Stripe does not support Pakistani bank accounts for payouts. Pakistani developers building SaaS cannot receive Stripe payouts. LemonSqueezy uses a merchant-of-record model — developers sell through LemonSqueezy's entity and receive bank transfers. This is the correct solution for Pakistan and similar regions.
|
|
54
|
+
|
|
55
|
+
The system must support both providers through an **adapter pattern** so the codebase is identical regardless of which provider is active. Provider is selected at runtime via `PAYMENT_PROVIDER` env var.
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## 2. Project Context & Current Architecture {#context}
|
|
60
|
+
|
|
61
|
+
### How the CLI Works Right Now
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
bin/index.js
|
|
65
|
+
→ prompts user (language, auth, swagger)
|
|
66
|
+
→ copies template/[lang]/ to target directory
|
|
67
|
+
→ if auth selected: copies template/[lang]/src/modules/auth/
|
|
68
|
+
→ if swagger selected: copies template/[lang]/src/modules/swagger/
|
|
69
|
+
→ merges module package.json into base package.json
|
|
70
|
+
→ merges dependencies
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### How Optional Modules Currently Wire In
|
|
74
|
+
|
|
75
|
+
Routes use **conditional dynamic imports based on file existence**:
|
|
76
|
+
|
|
77
|
+
```js
|
|
78
|
+
// src/routes/index.js (current pattern)
|
|
79
|
+
import { fileURLToPath } from "url";
|
|
80
|
+
import { join, dirname } from "path";
|
|
81
|
+
import { existsSync } from "fs";
|
|
82
|
+
|
|
83
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
84
|
+
|
|
85
|
+
// Auth routes — only load if module exists
|
|
86
|
+
const authRoutesPath = join(__dirname, "../modules/auth/auth.routes.js");
|
|
87
|
+
if (existsSync(authRoutesPath)) {
|
|
88
|
+
const { default: authRoutes } = await import(authRoutesPath);
|
|
89
|
+
router.use("/auth", authRoutes);
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
The payments module **must follow this exact pattern**. Do not hardcode imports. Do not change the pattern.
|
|
94
|
+
|
|
95
|
+
### The Module Package Pattern
|
|
96
|
+
|
|
97
|
+
Every optional module has its own `package.json` with dependencies it needs:
|
|
98
|
+
|
|
99
|
+
```json
|
|
100
|
+
// template/js/src/modules/auth/package.json
|
|
101
|
+
{
|
|
102
|
+
"dependencies": {
|
|
103
|
+
"jsonwebtoken": "^9.0.0",
|
|
104
|
+
"bcryptjs": "^2.4.3"
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
The CLI reads this and merges it into the project's root `package.json`. The payments module must follow this exact same pattern.
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## 3. Architecture Decision Records (ADRs) {#adrs}
|
|
114
|
+
|
|
115
|
+
These decisions are **final**. Do not re-evaluate during implementation.
|
|
116
|
+
|
|
117
|
+
### ADR-001: Adapter Pattern (Not Strategy Pattern)
|
|
118
|
+
|
|
119
|
+
**Decision**: Use adapter pattern — each provider implements a common `PaymentAdapter` interface.
|
|
120
|
+
|
|
121
|
+
**Rationale**: Providers have wildly different APIs (Stripe uses intents, LemonSqueezy uses checkouts). An adapter normalizes them. Controllers and services talk only to the adapter interface — they never import Stripe or LemonSqueezy SDK directly.
|
|
122
|
+
|
|
123
|
+
**Interface location**: `packages/payments/src/adapters/PaymentAdapter.js` (JS), `.ts` (TS)
|
|
124
|
+
|
|
125
|
+
### ADR-002: Provider Selection at Boot, Not Per-Request
|
|
126
|
+
|
|
127
|
+
**Decision**: Provider is instantiated once at startup based on `PAYMENT_PROVIDER` env var. Not switchable per-request.
|
|
128
|
+
|
|
129
|
+
**Rationale**: Simplicity. No multi-tenancy requirement at this scope.
|
|
130
|
+
|
|
131
|
+
### ADR-003: No Database Layer in the Package
|
|
132
|
+
|
|
133
|
+
**Decision**: The package does not create database tables or manage payment records. It processes payments and returns results. Persistence is the app developer's responsibility.
|
|
134
|
+
|
|
135
|
+
**Rationale**: Charcole templates use in-memory repos. Adding a DB dependency would force a DB choice. This is out of scope.
|
|
136
|
+
|
|
137
|
+
**Exception**: Webhook event logs can be stored in-memory for deduplication during server lifetime.
|
|
138
|
+
|
|
139
|
+
### ADR-004: Webhooks Are First-Class Citizens
|
|
140
|
+
|
|
141
|
+
**Decision**: Webhook endpoints are included by default, not optional.
|
|
142
|
+
|
|
143
|
+
**Rationale**: Without webhooks, payment confirmation is unreliable (users close tabs, network drops). Webhooks are the only reliable payment confirmation method.
|
|
144
|
+
|
|
145
|
+
### ADR-005: LemonSqueezy for Regional Support, Not as Secondary
|
|
146
|
+
|
|
147
|
+
**Decision**: LemonSqueezy is a first-class adapter, equal to Stripe. No "fallback" language.
|
|
148
|
+
|
|
149
|
+
**Rationale**: For Pakistani developers, LemonSqueezy IS the primary provider. Framing it as regional/fallback is disrespectful to the use case.
|
|
150
|
+
|
|
151
|
+
### ADR-006: Zod Everywhere
|
|
152
|
+
|
|
153
|
+
**Decision**: All incoming request bodies, all env vars, all webhook payloads are validated with Zod before processing.
|
|
154
|
+
|
|
155
|
+
**Rationale**: Consistent with existing Charcole codebase.
|
|
156
|
+
|
|
157
|
+
### ADR-007: No Express Router Injection via Middleware
|
|
158
|
+
|
|
159
|
+
**Decision**: `setupPayments(app, options)` calls `app.use('/payments', paymentsRouter)` directly.
|
|
160
|
+
|
|
161
|
+
**Rationale**: Mirrors how `setupSwagger` works. Consistent pattern.
|
|
162
|
+
|
|
163
|
+
---
|
|
164
|
+
|
|
165
|
+
## 4. Detailed File Map {#file-map}
|
|
166
|
+
|
|
167
|
+
### Files to CREATE
|
|
168
|
+
|
|
169
|
+
```
|
|
170
|
+
packages/payments/
|
|
171
|
+
├── src/
|
|
172
|
+
│ ├── index.js # Public API: setupPayments(), createAdapter()
|
|
173
|
+
│ ├── index.d.ts # TypeScript definitions for standalone package
|
|
174
|
+
│ ├── adapters/
|
|
175
|
+
│ │ ├── PaymentAdapter.js # Abstract interface (JSDoc annotated)
|
|
176
|
+
│ │ ├── StripeAdapter.js # Stripe implementation
|
|
177
|
+
│ │ └── LemonSqueezyAdapter.js # LemonSqueezy implementation
|
|
178
|
+
│ ├── controllers/
|
|
179
|
+
│ │ └── payments.controller.js # Route handlers
|
|
180
|
+
│ ├── services/
|
|
181
|
+
│ │ └── payments.service.js # Business logic layer
|
|
182
|
+
│ ├── schemas/
|
|
183
|
+
│ │ └── payments.schemas.js # Zod request/response schemas
|
|
184
|
+
│ ├── routes/
|
|
185
|
+
│ │ └── payments.routes.js # Express router
|
|
186
|
+
│ ├── errors/
|
|
187
|
+
│ │ └── PaymentError.js # Custom error class
|
|
188
|
+
│ └── helpers/
|
|
189
|
+
│ └── webhookUtils.js # Signature verification helpers
|
|
190
|
+
├── package.json
|
|
191
|
+
├── README.md
|
|
192
|
+
├── CHANGELOG.md
|
|
193
|
+
└── charcole-payments-1.0.0.tgz # Built tarball (generated, not committed)
|
|
194
|
+
|
|
195
|
+
template/js/src/modules/payments/
|
|
196
|
+
├── package.json # { "dependencies": { "stripe": "^14", "@lemonsqueezy/lemonsqueezy.js": "^3" } }
|
|
197
|
+
├── payments.constants.js
|
|
198
|
+
├── payments.controller.js
|
|
199
|
+
├── payments.service.js
|
|
200
|
+
├── payments.schemas.js
|
|
201
|
+
├── payments.routes.js
|
|
202
|
+
└── payments.adapter.js # Provider factory (reads PAYMENT_PROVIDER env)
|
|
203
|
+
|
|
204
|
+
template/ts/src/modules/payments/
|
|
205
|
+
├── package.json # Same deps
|
|
206
|
+
├── payments.constants.ts
|
|
207
|
+
├── payments.controller.ts
|
|
208
|
+
├── payments.service.ts
|
|
209
|
+
├── payments.schemas.ts
|
|
210
|
+
├── payments.routes.ts
|
|
211
|
+
├── payments.adapter.ts
|
|
212
|
+
└── payments.types.ts # TS-only: all types/interfaces
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
### Files to MODIFY
|
|
216
|
+
|
|
217
|
+
```
|
|
218
|
+
bin/index.js
|
|
219
|
+
→ Add payments prompt (yes/no)
|
|
220
|
+
→ Add provider selection prompt (stripe / lemonsqueezy / both)
|
|
221
|
+
→ Add payments module copy logic
|
|
222
|
+
→ Add payments tarball copy (if standalone package used)
|
|
223
|
+
|
|
224
|
+
template/js/src/routes/index.js
|
|
225
|
+
→ Add conditional payments routes import
|
|
226
|
+
|
|
227
|
+
template/ts/src/routes/index.ts
|
|
228
|
+
→ Add conditional payments routes import
|
|
229
|
+
|
|
230
|
+
template/js/src/config/env.js
|
|
231
|
+
→ Add PAYMENT_PROVIDER, STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET,
|
|
232
|
+
LEMONSQUEEZY_API_KEY, LEMONSQUEEZY_WEBHOOK_SECRET, LEMONSQUEEZY_STORE_ID
|
|
233
|
+
|
|
234
|
+
template/ts/src/config/env.ts
|
|
235
|
+
→ Same additions
|
|
236
|
+
|
|
237
|
+
template/js/.env.example
|
|
238
|
+
→ Add payment vars section
|
|
239
|
+
|
|
240
|
+
template/ts/.env.example
|
|
241
|
+
→ Add payment vars section
|
|
242
|
+
|
|
243
|
+
template/ts/.env
|
|
244
|
+
→ Add payment vars (empty values)
|
|
245
|
+
|
|
246
|
+
CHANGELOG.md (root)
|
|
247
|
+
→ Add v2.3.0 entry
|
|
248
|
+
|
|
249
|
+
README.md (root)
|
|
250
|
+
→ Add payments section
|
|
251
|
+
|
|
252
|
+
packages/payments/CHANGELOG.md
|
|
253
|
+
→ Initial v1.0.0 entry
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
---
|
|
257
|
+
|
|
258
|
+
## 5. Package: `@charcoles/payments` — Deep Spec {#package-spec}
|
|
259
|
+
|
|
260
|
+
### `packages/payments/package.json`
|
|
261
|
+
|
|
262
|
+
```json
|
|
263
|
+
{
|
|
264
|
+
"name": "@charcoles/payments",
|
|
265
|
+
"version": "1.0.0",
|
|
266
|
+
"description": "Drop-in payment processing for Express apps. Stripe + LemonSqueezy.",
|
|
267
|
+
"type": "module",
|
|
268
|
+
"main": "./src/index.js",
|
|
269
|
+
"types": "./src/index.d.ts",
|
|
270
|
+
"exports": {
|
|
271
|
+
".": {
|
|
272
|
+
"import": "./src/index.js",
|
|
273
|
+
"types": "./src/index.d.ts"
|
|
274
|
+
}
|
|
275
|
+
},
|
|
276
|
+
"files": ["src", "README.md", "CHANGELOG.md"],
|
|
277
|
+
"scripts": {
|
|
278
|
+
"build": "node build.js",
|
|
279
|
+
"test": "vitest",
|
|
280
|
+
"test:run": "vitest run"
|
|
281
|
+
},
|
|
282
|
+
"peerDependencies": {
|
|
283
|
+
"express": "^4.18.0 || ^5.0.0"
|
|
284
|
+
},
|
|
285
|
+
"dependencies": {
|
|
286
|
+
"stripe": "^14.0.0",
|
|
287
|
+
"@lemonsqueezy/lemonsqueezy.js": "^4.0.0",
|
|
288
|
+
"zod": "^3.22.0"
|
|
289
|
+
},
|
|
290
|
+
"devDependencies": {
|
|
291
|
+
"@types/express": "^4.17.0",
|
|
292
|
+
"vitest": "^1.0.0",
|
|
293
|
+
"express": "^4.18.0"
|
|
294
|
+
},
|
|
295
|
+
"keywords": ["payments", "stripe", "lemonsqueezy", "express", "charcole"],
|
|
296
|
+
"license": "MIT"
|
|
297
|
+
}
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
### `packages/payments/src/index.js` — Public API
|
|
301
|
+
|
|
302
|
+
```js
|
|
303
|
+
export { setupPayments } from "./routes/payments.routes.js";
|
|
304
|
+
export { createAdapter } from "./adapters/PaymentAdapter.js";
|
|
305
|
+
export { StripeAdapter } from "./adapters/StripeAdapter.js";
|
|
306
|
+
export { LemonSqueezyAdapter } from "./adapters/LemonSqueezyAdapter.js";
|
|
307
|
+
export { PaymentError } from "./errors/PaymentError.js";
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
The `setupPayments(app, options)` function signature:
|
|
311
|
+
|
|
312
|
+
```js
|
|
313
|
+
/**
|
|
314
|
+
* @param {import('express').Application} app
|
|
315
|
+
* @param {Object} options
|
|
316
|
+
* @param {'stripe' | 'lemonsqueezy'} options.provider
|
|
317
|
+
* @param {string} [options.stripeSecretKey]
|
|
318
|
+
* @param {string} [options.stripeWebhookSecret]
|
|
319
|
+
* @param {string} [options.lemonSqueezyApiKey]
|
|
320
|
+
* @param {string} [options.lemonSqueezyWebhookSecret]
|
|
321
|
+
* @param {string} [options.lemonSqueezyStoreId]
|
|
322
|
+
* @param {string} [options.mountPath='/payments']
|
|
323
|
+
*/
|
|
324
|
+
export function setupPayments(app, options) { ... }
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
---
|
|
328
|
+
|
|
329
|
+
## 6. Adapter Contracts (Exact Interfaces) {#adapter-contracts}
|
|
330
|
+
|
|
331
|
+
Every payment adapter **must** implement all of these methods. No exceptions.
|
|
332
|
+
|
|
333
|
+
### JS (JSDoc interface in `PaymentAdapter.js`)
|
|
334
|
+
|
|
335
|
+
```js
|
|
336
|
+
/**
|
|
337
|
+
* @typedef {Object} CreatePaymentResult
|
|
338
|
+
* @property {string} id - Provider-specific payment/checkout ID
|
|
339
|
+
* @property {string} [clientSecret] - Stripe: client_secret for frontend
|
|
340
|
+
* @property {string} [checkoutUrl] - LemonSqueezy: redirect URL
|
|
341
|
+
* @property {string} status - 'pending' | 'requires_payment_method' | 'created'
|
|
342
|
+
* @property {number} amount - Amount in smallest currency unit (cents)
|
|
343
|
+
* @property {string} currency - ISO 4217 (e.g. 'usd', 'pkr')
|
|
344
|
+
* @property {Object} metadata - Provider-specific raw response
|
|
345
|
+
*/
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* @typedef {Object} RefundResult
|
|
349
|
+
* @property {string} id - Refund ID
|
|
350
|
+
* @property {string} status - 'succeeded' | 'pending' | 'failed'
|
|
351
|
+
* @property {number} amount - Refunded amount in smallest unit
|
|
352
|
+
*/
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* @typedef {Object} PaymentStatus
|
|
356
|
+
* @property {string} id
|
|
357
|
+
* @property {string} status - 'pending' | 'paid' | 'failed' | 'refunded'
|
|
358
|
+
* @property {number} amount
|
|
359
|
+
* @property {string} currency
|
|
360
|
+
* @property {Object} metadata
|
|
361
|
+
*/
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Abstract PaymentAdapter interface.
|
|
365
|
+
* All adapters must implement these methods.
|
|
366
|
+
*/
|
|
367
|
+
export class PaymentAdapter {
|
|
368
|
+
/**
|
|
369
|
+
* Create a payment intent (Stripe) or checkout session (LemonSqueezy).
|
|
370
|
+
* @param {Object} params
|
|
371
|
+
* @param {number} params.amount - Amount in smallest currency unit
|
|
372
|
+
* @param {string} params.currency - ISO 4217
|
|
373
|
+
* @param {Object} [params.metadata] - Arbitrary key-value metadata
|
|
374
|
+
* @returns {Promise<CreatePaymentResult>}
|
|
375
|
+
*/
|
|
376
|
+
async createPayment(params) {
|
|
377
|
+
throw new Error("createPayment() must be implemented");
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Refund a payment.
|
|
382
|
+
* @param {Object} params
|
|
383
|
+
* @param {string} params.paymentId - ID from createPayment result
|
|
384
|
+
* @param {number} [params.amount] - Partial refund amount. Full refund if omitted.
|
|
385
|
+
* @returns {Promise<RefundResult>}
|
|
386
|
+
*/
|
|
387
|
+
async refundPayment(params) {
|
|
388
|
+
throw new Error("refundPayment() must be implemented");
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Get current payment status.
|
|
393
|
+
* @param {string} paymentId
|
|
394
|
+
* @returns {Promise<PaymentStatus>}
|
|
395
|
+
*/
|
|
396
|
+
async getPaymentStatus(paymentId) {
|
|
397
|
+
throw new Error("getPaymentStatus() must be implemented");
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Verify and parse a webhook payload.
|
|
402
|
+
* @param {Buffer} rawBody - Raw request body (MUST be Buffer, not parsed JSON)
|
|
403
|
+
* @param {string} signature - Provider signature header value
|
|
404
|
+
* @returns {Promise<{ event: string, data: Object }>}
|
|
405
|
+
* @throws {PaymentError} if signature verification fails
|
|
406
|
+
*/
|
|
407
|
+
async verifyWebhook(rawBody, signature) {
|
|
408
|
+
throw new Error("verifyWebhook() must be implemented");
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
### TS (in `packages/payments/src/index.d.ts` and template `payments.types.ts`)
|
|
414
|
+
|
|
415
|
+
```ts
|
|
416
|
+
export interface CreatePaymentParams {
|
|
417
|
+
amount: number; // smallest currency unit (e.g. cents)
|
|
418
|
+
currency: string; // ISO 4217
|
|
419
|
+
metadata?: Record<string, string>;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
export interface CreatePaymentResult {
|
|
423
|
+
id: string;
|
|
424
|
+
clientSecret?: string; // Stripe only
|
|
425
|
+
checkoutUrl?: string; // LemonSqueezy only
|
|
426
|
+
status: "pending" | "requires_payment_method" | "created";
|
|
427
|
+
amount: number;
|
|
428
|
+
currency: string;
|
|
429
|
+
metadata: Record<string, unknown>;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
export interface RefundParams {
|
|
433
|
+
paymentId: string;
|
|
434
|
+
amount?: number; // omit for full refund
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
export interface RefundResult {
|
|
438
|
+
id: string;
|
|
439
|
+
status: "succeeded" | "pending" | "failed";
|
|
440
|
+
amount: number;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
export interface PaymentStatus {
|
|
444
|
+
id: string;
|
|
445
|
+
status: "pending" | "paid" | "failed" | "refunded";
|
|
446
|
+
amount: number;
|
|
447
|
+
currency: string;
|
|
448
|
+
metadata: Record<string, unknown>;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
export interface WebhookResult {
|
|
452
|
+
event: string;
|
|
453
|
+
data: Record<string, unknown>;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
export interface PaymentAdapter {
|
|
457
|
+
createPayment(params: CreatePaymentParams): Promise<CreatePaymentResult>;
|
|
458
|
+
refundPayment(params: RefundParams): Promise<RefundResult>;
|
|
459
|
+
getPaymentStatus(paymentId: string): Promise<PaymentStatus>;
|
|
460
|
+
verifyWebhook(rawBody: Buffer, signature: string): Promise<WebhookResult>;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
export interface SetupPaymentsOptions {
|
|
464
|
+
provider: "stripe" | "lemonsqueezy";
|
|
465
|
+
stripeSecretKey?: string;
|
|
466
|
+
stripeWebhookSecret?: string;
|
|
467
|
+
lemonSqueezyApiKey?: string;
|
|
468
|
+
lemonSqueezyWebhookSecret?: string;
|
|
469
|
+
lemonSqueezyStoreId?: string;
|
|
470
|
+
mountPath?: string; // default: '/payments'
|
|
471
|
+
}
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
---
|
|
475
|
+
|
|
476
|
+
## 7. Stripe Adapter — Implementation Guide {#stripe-adapter}
|
|
477
|
+
|
|
478
|
+
### File: `src/adapters/StripeAdapter.js`
|
|
479
|
+
|
|
480
|
+
```js
|
|
481
|
+
import Stripe from "stripe";
|
|
482
|
+
import { PaymentAdapter } from "./PaymentAdapter.js";
|
|
483
|
+
import { PaymentError } from "../errors/PaymentError.js";
|
|
484
|
+
|
|
485
|
+
export class StripeAdapter extends PaymentAdapter {
|
|
486
|
+
#stripe;
|
|
487
|
+
#webhookSecret;
|
|
488
|
+
|
|
489
|
+
constructor({ secretKey, webhookSecret }) {
|
|
490
|
+
super();
|
|
491
|
+
if (!secretKey)
|
|
492
|
+
throw new PaymentError("STRIPE_SECRET_KEY is required", "CONFIG_ERROR");
|
|
493
|
+
if (!webhookSecret)
|
|
494
|
+
throw new PaymentError(
|
|
495
|
+
"STRIPE_WEBHOOK_SECRET is required",
|
|
496
|
+
"CONFIG_ERROR",
|
|
497
|
+
);
|
|
498
|
+
this.#stripe = new Stripe(secretKey, { apiVersion: "2024-06-20" });
|
|
499
|
+
this.#webhookSecret = webhookSecret;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
async createPayment({ amount, currency, metadata = {} }) {
|
|
503
|
+
const intent = await this.#stripe.paymentIntents.create({
|
|
504
|
+
amount,
|
|
505
|
+
currency,
|
|
506
|
+
metadata,
|
|
507
|
+
automatic_payment_methods: { enabled: true },
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
return {
|
|
511
|
+
id: intent.id,
|
|
512
|
+
clientSecret: intent.client_secret,
|
|
513
|
+
status: intent.status,
|
|
514
|
+
amount: intent.amount,
|
|
515
|
+
currency: intent.currency,
|
|
516
|
+
metadata: intent,
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
async refundPayment({ paymentId, amount }) {
|
|
521
|
+
const params = { payment_intent: paymentId };
|
|
522
|
+
if (amount) params.amount = amount;
|
|
523
|
+
|
|
524
|
+
const refund = await this.#stripe.refunds.create(params);
|
|
525
|
+
|
|
526
|
+
return {
|
|
527
|
+
id: refund.id,
|
|
528
|
+
status: refund.status,
|
|
529
|
+
amount: refund.amount,
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
async getPaymentStatus(paymentId) {
|
|
534
|
+
const intent = await this.#stripe.paymentIntents.retrieve(paymentId);
|
|
535
|
+
|
|
536
|
+
const statusMap = {
|
|
537
|
+
succeeded: "paid",
|
|
538
|
+
requires_payment_method: "pending",
|
|
539
|
+
requires_confirmation: "pending",
|
|
540
|
+
processing: "pending",
|
|
541
|
+
canceled: "failed",
|
|
542
|
+
requires_action: "pending",
|
|
543
|
+
};
|
|
544
|
+
|
|
545
|
+
return {
|
|
546
|
+
id: intent.id,
|
|
547
|
+
status: statusMap[intent.status] ?? "pending",
|
|
548
|
+
amount: intent.amount,
|
|
549
|
+
currency: intent.currency,
|
|
550
|
+
metadata: intent,
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
async verifyWebhook(rawBody, signature) {
|
|
555
|
+
let event;
|
|
556
|
+
try {
|
|
557
|
+
event = this.#stripe.webhooks.constructEvent(
|
|
558
|
+
rawBody,
|
|
559
|
+
signature,
|
|
560
|
+
this.#webhookSecret,
|
|
561
|
+
);
|
|
562
|
+
} catch (err) {
|
|
563
|
+
throw new PaymentError(
|
|
564
|
+
`Webhook signature verification failed: ${err.message}`,
|
|
565
|
+
"WEBHOOK_INVALID",
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
return {
|
|
570
|
+
event: event.type, // e.g. 'payment_intent.succeeded'
|
|
571
|
+
data: event.data.object, // the Stripe object
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
### Important Stripe Notes for AI Agents
|
|
578
|
+
|
|
579
|
+
1. **API version must be pinned** — Always use `apiVersion: "2024-06-20"`. Never use `latest`. Breaking changes happen.
|
|
580
|
+
2. **Webhook body must be raw Buffer** — Express `express.json()` middleware WILL break webhook verification if it parses the body first. The webhook route MUST use `express.raw({ type: 'application/json' })` as its middleware, not `express.json()`. This is the #1 Stripe integration mistake.
|
|
581
|
+
3. **`client_secret` is for frontend only** — Never log it, never store it in your DB, never return it from a GET endpoint.
|
|
582
|
+
4. **`amount` is in smallest currency unit** — USD: cents (100 = $1.00). PKR: paisas (100 = ₨1.00). This is Stripe's convention and must be communicated in API docs.
|
|
583
|
+
5. **Payment intents ≠ charges** — `createPaymentIntent` creates an intent. The charge happens when the frontend confirms it using the `client_secret`. The webhook `payment_intent.succeeded` is the reliable confirmation signal.
|
|
584
|
+
|
|
585
|
+
---
|
|
586
|
+
|
|
587
|
+
## 8. LemonSqueezy Adapter — Implementation Guide {#lemonsqueezy-adapter}
|
|
588
|
+
|
|
589
|
+
### File: `src/adapters/LemonSqueezyAdapter.js`
|
|
590
|
+
|
|
591
|
+
```js
|
|
592
|
+
import {
|
|
593
|
+
lemonSqueezySetup,
|
|
594
|
+
createCheckout,
|
|
595
|
+
getOrder,
|
|
596
|
+
createRefund,
|
|
597
|
+
listWebhooks,
|
|
598
|
+
} from "@lemonsqueezy/lemonsqueezy.js";
|
|
599
|
+
import { createHmac } from "crypto";
|
|
600
|
+
import { PaymentAdapter } from "./PaymentAdapter.js";
|
|
601
|
+
import { PaymentError } from "../errors/PaymentError.js";
|
|
602
|
+
|
|
603
|
+
export class LemonSqueezyAdapter extends PaymentAdapter {
|
|
604
|
+
#apiKey;
|
|
605
|
+
#webhookSecret;
|
|
606
|
+
#storeId;
|
|
607
|
+
|
|
608
|
+
constructor({ apiKey, webhookSecret, storeId }) {
|
|
609
|
+
super();
|
|
610
|
+
if (!apiKey)
|
|
611
|
+
throw new PaymentError(
|
|
612
|
+
"LEMONSQUEEZY_API_KEY is required",
|
|
613
|
+
"CONFIG_ERROR",
|
|
614
|
+
);
|
|
615
|
+
if (!webhookSecret)
|
|
616
|
+
throw new PaymentError(
|
|
617
|
+
"LEMONSQUEEZY_WEBHOOK_SECRET is required",
|
|
618
|
+
"CONFIG_ERROR",
|
|
619
|
+
);
|
|
620
|
+
if (!storeId)
|
|
621
|
+
throw new PaymentError(
|
|
622
|
+
"LEMONSQUEEZY_STORE_ID is required",
|
|
623
|
+
"CONFIG_ERROR",
|
|
624
|
+
);
|
|
625
|
+
|
|
626
|
+
this.#apiKey = apiKey;
|
|
627
|
+
this.#webhookSecret = webhookSecret;
|
|
628
|
+
this.#storeId = storeId;
|
|
629
|
+
|
|
630
|
+
lemonSqueezySetup({ apiKey });
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
async createPayment({ amount, currency, metadata = {} }) {
|
|
634
|
+
// LemonSqueezy uses 'variants' (product variants) not raw amounts.
|
|
635
|
+
// For generic payments, a "tip jar" or "custom amount" variant must exist in the store.
|
|
636
|
+
// The variantId must be passed in metadata: metadata.variantId
|
|
637
|
+
if (!metadata.variantId) {
|
|
638
|
+
throw new PaymentError(
|
|
639
|
+
"metadata.variantId is required for LemonSqueezy payments. Create a flexible-price product in your LS store.",
|
|
640
|
+
"MISSING_VARIANT_ID",
|
|
641
|
+
);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const checkout = await createCheckout(this.#storeId, metadata.variantId, {
|
|
645
|
+
checkoutData: {
|
|
646
|
+
custom: metadata,
|
|
647
|
+
discounts: [],
|
|
648
|
+
},
|
|
649
|
+
productOptions: {
|
|
650
|
+
enabledVariants: [Number(metadata.variantId)],
|
|
651
|
+
},
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
if (checkout.error) {
|
|
655
|
+
throw new PaymentError(checkout.error.message, "LS_CHECKOUT_FAILED");
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
return {
|
|
659
|
+
id: checkout.data.data.id,
|
|
660
|
+
checkoutUrl: checkout.data.data.attributes.url,
|
|
661
|
+
status: "created",
|
|
662
|
+
amount,
|
|
663
|
+
currency,
|
|
664
|
+
metadata: checkout.data.data,
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
async refundPayment({ paymentId, amount }) {
|
|
669
|
+
const refund = await createRefund({ orderId: paymentId });
|
|
670
|
+
if (refund.error) {
|
|
671
|
+
throw new PaymentError(refund.error.message, "LS_REFUND_FAILED");
|
|
672
|
+
}
|
|
673
|
+
return {
|
|
674
|
+
id: refund.data.data.id,
|
|
675
|
+
status: "pending",
|
|
676
|
+
amount: amount ?? 0, // LS doesn't return amount in refund response
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
async getPaymentStatus(paymentId) {
|
|
681
|
+
const order = await getOrder(paymentId);
|
|
682
|
+
if (order.error) {
|
|
683
|
+
throw new PaymentError(order.error.message, "LS_ORDER_NOT_FOUND");
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
const attrs = order.data.data.attributes;
|
|
687
|
+
const statusMap = {
|
|
688
|
+
paid: "paid",
|
|
689
|
+
pending: "pending",
|
|
690
|
+
failed: "failed",
|
|
691
|
+
refunded: "refunded",
|
|
692
|
+
};
|
|
693
|
+
|
|
694
|
+
return {
|
|
695
|
+
id: String(order.data.data.id),
|
|
696
|
+
status: statusMap[attrs.status] ?? "pending",
|
|
697
|
+
amount: attrs.total,
|
|
698
|
+
currency: attrs.currency,
|
|
699
|
+
metadata: order.data.data,
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
async verifyWebhook(rawBody, signature) {
|
|
704
|
+
// LemonSqueezy uses HMAC-SHA256
|
|
705
|
+
const hmac = createHmac("sha256", this.#webhookSecret);
|
|
706
|
+
hmac.update(rawBody);
|
|
707
|
+
const digest = hmac.digest("hex");
|
|
708
|
+
|
|
709
|
+
if (digest !== signature) {
|
|
710
|
+
throw new PaymentError(
|
|
711
|
+
"Webhook signature verification failed",
|
|
712
|
+
"WEBHOOK_INVALID",
|
|
713
|
+
);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
const payload = JSON.parse(rawBody.toString());
|
|
717
|
+
|
|
718
|
+
return {
|
|
719
|
+
event: payload.meta.event_name, // e.g. 'order_created'
|
|
720
|
+
data: payload.data,
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
```
|
|
725
|
+
|
|
726
|
+
### Important LemonSqueezy Notes for AI Agents
|
|
727
|
+
|
|
728
|
+
1. **LemonSqueezy uses products/variants, not raw amounts** — Unlike Stripe (where you pass any amount), LemonSqueezy requires a Product Variant ID. To accept variable amounts (like a custom invoice), the developer must create a "Pay What You Want" product in their LS dashboard and pass that variant's ID. Document this clearly in README and Swagger.
|
|
729
|
+
|
|
730
|
+
2. **Webhook header is `X-Signature`** — Not `Stripe-Signature`. Make sure the controller reads the right header: `req.headers['x-signature']`.
|
|
731
|
+
|
|
732
|
+
3. **LemonSqueezy orders have integer IDs** — Stripe uses string IDs like `pi_abc123`. LemonSqueezy uses numeric IDs like `12345`. Always coerce to string when returning from the adapter.
|
|
733
|
+
|
|
734
|
+
4. **LemonSqueezy checkout creates a hosted URL** — Unlike Stripe's `clientSecret` (which goes to a frontend element), LemonSqueezy returns a URL the user is redirected to. The frontend handling is completely different. Both must be documented clearly.
|
|
735
|
+
|
|
736
|
+
5. **`lemonSqueezySetup()` is global** — It sets a global API key. Call it once at startup. Do not call it per-request.
|
|
737
|
+
|
|
738
|
+
6. **LEMONSQUEEZY_STORE_ID is numeric** — It's the store ID from the LS dashboard URL: `app.lemonsqueezy.com/stores/[STORE_ID]`.
|
|
739
|
+
|
|
740
|
+
---
|
|
741
|
+
|
|
742
|
+
## 9. Template Integration — JS & TS {#template-integration}
|
|
743
|
+
|
|
744
|
+
### Module File: `payments.service.js` (JS Template)
|
|
745
|
+
|
|
746
|
+
```js
|
|
747
|
+
// template/js/src/modules/payments/payments.service.js
|
|
748
|
+
import { getAdapter } from "./payments.adapter.js";
|
|
749
|
+
import { PaymentError } from "./payments.adapter.js";
|
|
750
|
+
|
|
751
|
+
export async function createPayment({ amount, currency, metadata }) {
|
|
752
|
+
const adapter = getAdapter();
|
|
753
|
+
return adapter.createPayment({ amount, currency, metadata });
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
export async function refundPayment({ paymentId, amount }) {
|
|
757
|
+
const adapter = getAdapter();
|
|
758
|
+
return adapter.refundPayment({ paymentId, amount });
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
export async function getPaymentStatus(paymentId) {
|
|
762
|
+
const adapter = getAdapter();
|
|
763
|
+
return adapter.getPaymentStatus(paymentId);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
export async function processWebhook(rawBody, signature) {
|
|
767
|
+
const adapter = getAdapter();
|
|
768
|
+
return adapter.verifyWebhook(rawBody, signature);
|
|
769
|
+
}
|
|
770
|
+
```
|
|
771
|
+
|
|
772
|
+
### Module File: `payments.adapter.js` (JS Template)
|
|
773
|
+
|
|
774
|
+
This is the **factory** — it reads `PAYMENT_PROVIDER` from env and returns the correct adapter. This is where the env validation happens for the adapter config.
|
|
775
|
+
|
|
776
|
+
```js
|
|
777
|
+
// template/js/src/modules/payments/payments.adapter.js
|
|
778
|
+
import { env } from "../../config/env.js";
|
|
779
|
+
import { StripeAdapter } from "@charcoles/payments/adapters/stripe";
|
|
780
|
+
import { LemonSqueezyAdapter } from "@charcoles/payments/adapters/lemonsqueezy";
|
|
781
|
+
import { PaymentError } from "@charcoles/payments";
|
|
782
|
+
|
|
783
|
+
let adapter = null;
|
|
784
|
+
|
|
785
|
+
export function getAdapter() {
|
|
786
|
+
if (adapter) return adapter;
|
|
787
|
+
|
|
788
|
+
if (env.PAYMENT_PROVIDER === "stripe") {
|
|
789
|
+
adapter = new StripeAdapter({
|
|
790
|
+
secretKey: env.STRIPE_SECRET_KEY,
|
|
791
|
+
webhookSecret: env.STRIPE_WEBHOOK_SECRET,
|
|
792
|
+
});
|
|
793
|
+
} else if (env.PAYMENT_PROVIDER === "lemonsqueezy") {
|
|
794
|
+
adapter = new LemonSqueezyAdapter({
|
|
795
|
+
apiKey: env.LEMONSQUEEZY_API_KEY,
|
|
796
|
+
webhookSecret: env.LEMONSQUEEZY_WEBHOOK_SECRET,
|
|
797
|
+
storeId: env.LEMONSQUEEZY_STORE_ID,
|
|
798
|
+
});
|
|
799
|
+
} else {
|
|
800
|
+
throw new PaymentError(
|
|
801
|
+
`Unknown PAYMENT_PROVIDER: ${env.PAYMENT_PROVIDER}`,
|
|
802
|
+
"CONFIG_ERROR",
|
|
803
|
+
);
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
return adapter;
|
|
807
|
+
}
|
|
808
|
+
```
|
|
809
|
+
|
|
810
|
+
### Module File: `payments.controller.js` (JS Template)
|
|
811
|
+
|
|
812
|
+
```js
|
|
813
|
+
// template/js/src/modules/payments/payments.controller.js
|
|
814
|
+
import * as paymentsService from "./payments.service.js";
|
|
815
|
+
import { sendSuccess } from "../../utils/response.js";
|
|
816
|
+
import {
|
|
817
|
+
createPaymentSchema,
|
|
818
|
+
refundPaymentSchema,
|
|
819
|
+
} from "./payments.schemas.js";
|
|
820
|
+
|
|
821
|
+
export const createPayment = async (req, res, next) => {
|
|
822
|
+
try {
|
|
823
|
+
const validated = createPaymentSchema.parse(req.body);
|
|
824
|
+
const result = await paymentsService.createPayment(validated);
|
|
825
|
+
sendSuccess(res, result, 201);
|
|
826
|
+
} catch (err) {
|
|
827
|
+
next(err);
|
|
828
|
+
}
|
|
829
|
+
};
|
|
830
|
+
|
|
831
|
+
export const refundPayment = async (req, res, next) => {
|
|
832
|
+
try {
|
|
833
|
+
const validated = refundPaymentSchema.parse(req.body);
|
|
834
|
+
const result = await paymentsService.refundPayment(validated);
|
|
835
|
+
sendSuccess(res, result);
|
|
836
|
+
} catch (err) {
|
|
837
|
+
next(err);
|
|
838
|
+
}
|
|
839
|
+
};
|
|
840
|
+
|
|
841
|
+
export const getPaymentStatus = async (req, res, next) => {
|
|
842
|
+
try {
|
|
843
|
+
const result = await paymentsService.getPaymentStatus(req.params.paymentId);
|
|
844
|
+
sendSuccess(res, result);
|
|
845
|
+
} catch (err) {
|
|
846
|
+
next(err);
|
|
847
|
+
}
|
|
848
|
+
};
|
|
849
|
+
|
|
850
|
+
export const handleWebhook = async (req, res, next) => {
|
|
851
|
+
try {
|
|
852
|
+
const provider = process.env.PAYMENT_PROVIDER;
|
|
853
|
+
const signature =
|
|
854
|
+
provider === "stripe"
|
|
855
|
+
? req.headers["stripe-signature"]
|
|
856
|
+
: req.headers["x-signature"];
|
|
857
|
+
|
|
858
|
+
const result = await paymentsService.processWebhook(req.body, signature);
|
|
859
|
+
// req.body is raw Buffer here (see route middleware)
|
|
860
|
+
|
|
861
|
+
// TODO: Handle specific events here
|
|
862
|
+
// result.event: 'payment_intent.succeeded' | 'order_created' | etc.
|
|
863
|
+
// Persist to DB, send confirmation email, etc.
|
|
864
|
+
|
|
865
|
+
res.status(200).json({ received: true });
|
|
866
|
+
} catch (err) {
|
|
867
|
+
next(err);
|
|
868
|
+
}
|
|
869
|
+
};
|
|
870
|
+
```
|
|
871
|
+
|
|
872
|
+
### Module File: `payments.routes.js` (JS Template)
|
|
873
|
+
|
|
874
|
+
```js
|
|
875
|
+
// template/js/src/modules/payments/payments.routes.js
|
|
876
|
+
import { Router } from "express";
|
|
877
|
+
import { validateRequest } from "../../middlewares/validateRequest.js";
|
|
878
|
+
import * as controller from "./payments.controller.js";
|
|
879
|
+
import {
|
|
880
|
+
createPaymentSchema,
|
|
881
|
+
refundPaymentSchema,
|
|
882
|
+
} from "./payments.schemas.js";
|
|
883
|
+
|
|
884
|
+
const router = Router();
|
|
885
|
+
|
|
886
|
+
// POST /payments/create-intent
|
|
887
|
+
router.post(
|
|
888
|
+
"/create-intent",
|
|
889
|
+
validateRequest(createPaymentSchema),
|
|
890
|
+
controller.createPayment,
|
|
891
|
+
);
|
|
892
|
+
|
|
893
|
+
// POST /payments/refund
|
|
894
|
+
router.post(
|
|
895
|
+
"/refund",
|
|
896
|
+
validateRequest(refundPaymentSchema),
|
|
897
|
+
controller.refundPayment,
|
|
898
|
+
);
|
|
899
|
+
|
|
900
|
+
// GET /payments/status/:paymentId
|
|
901
|
+
router.get("/status/:paymentId", controller.getPaymentStatus);
|
|
902
|
+
|
|
903
|
+
// POST /payments/webhook
|
|
904
|
+
// CRITICAL: raw body parsing must happen BEFORE express.json() for this route
|
|
905
|
+
// This is handled in app.js by mounting raw middleware BEFORE the global json middleware
|
|
906
|
+
router.post(
|
|
907
|
+
"/webhook",
|
|
908
|
+
// No validateRequest here — webhook bodies are provider-signed, not user input
|
|
909
|
+
controller.handleWebhook,
|
|
910
|
+
);
|
|
911
|
+
|
|
912
|
+
export default router;
|
|
913
|
+
```
|
|
914
|
+
|
|
915
|
+
### CRITICAL: `app.js` Webhook Raw Body Setup
|
|
916
|
+
|
|
917
|
+
The webhook route requires a raw `Buffer` body, not a parsed JSON object. This requires special middleware ordering in `app.js`.
|
|
918
|
+
|
|
919
|
+
**Modify `template/js/src/app.js` to add:**
|
|
920
|
+
|
|
921
|
+
```js
|
|
922
|
+
// MUST come before express.json()
|
|
923
|
+
app.use("/payments/webhook", express.raw({ type: "application/json" }));
|
|
924
|
+
|
|
925
|
+
// Global JSON parsing (existing)
|
|
926
|
+
app.use(express.json());
|
|
927
|
+
```
|
|
928
|
+
|
|
929
|
+
**The raw middleware MUST be registered BEFORE `express.json()`.** This is non-negotiable and is the most common integration failure point.
|
|
930
|
+
|
|
931
|
+
> AI Agent Note: When modifying app.js, check if the payments module was selected before adding this middleware. Only add it if payments is included. Use the same file-existence check pattern as routes.
|
|
932
|
+
|
|
933
|
+
---
|
|
934
|
+
|
|
935
|
+
## 10. CLI Changes (`bin/index.js`) {#cli-changes}
|
|
936
|
+
|
|
937
|
+
### New Prompts to Add
|
|
938
|
+
|
|
939
|
+
Insert after the existing swagger prompt:
|
|
940
|
+
|
|
941
|
+
```js
|
|
942
|
+
// Add after swagger prompt
|
|
943
|
+
{
|
|
944
|
+
type: "confirm",
|
|
945
|
+
name: "includePayments",
|
|
946
|
+
message: "Include payments module? (Stripe / LemonSqueezy)",
|
|
947
|
+
initial: false,
|
|
948
|
+
},
|
|
949
|
+
{
|
|
950
|
+
type: (prev) => (prev ? "select" : null), // Only show if payments = true
|
|
951
|
+
name: "paymentProvider",
|
|
952
|
+
message: "Which payment provider will you use?",
|
|
953
|
+
choices: [
|
|
954
|
+
{ title: "Stripe (global)", value: "stripe" },
|
|
955
|
+
{ title: "LemonSqueezy (Pakistan + global)", value: "lemonsqueezy" },
|
|
956
|
+
{ title: "Both (I'll switch via env var)", value: "both" },
|
|
957
|
+
],
|
|
958
|
+
initial: 0,
|
|
959
|
+
},
|
|
960
|
+
```
|
|
961
|
+
|
|
962
|
+
### Module Copy Logic
|
|
963
|
+
|
|
964
|
+
Add after existing auth/swagger copy logic:
|
|
965
|
+
|
|
966
|
+
```js
|
|
967
|
+
if (answers.includePayments) {
|
|
968
|
+
const paymentsSrc = join(templateDir, "src/modules/payments");
|
|
969
|
+
const paymentsDest = join(targetDir, "src/modules/payments");
|
|
970
|
+
await copyDir(paymentsSrc, paymentsDest);
|
|
971
|
+
|
|
972
|
+
// Merge payments module dependencies
|
|
973
|
+
const paymentsPackageJson = join(paymentsSrc, "package.json");
|
|
974
|
+
await mergeDependencies(targetPackageJson, paymentsPackageJson);
|
|
975
|
+
|
|
976
|
+
// Write PAYMENT_PROVIDER to .env.example comment
|
|
977
|
+
// (env vars are empty — user fills them in)
|
|
978
|
+
}
|
|
979
|
+
```
|
|
980
|
+
|
|
981
|
+
### `templateHandler.js` — No Changes Required
|
|
982
|
+
|
|
983
|
+
The existing `copyDir` and `mergeDependencies` helpers should work. Verify they handle the payments module structure before modifying.
|
|
984
|
+
|
|
985
|
+
---
|
|
986
|
+
|
|
987
|
+
## 11. Environment Variables — Full Spec {#env-spec}
|
|
988
|
+
|
|
989
|
+
### All Payment-Related Env Vars
|
|
990
|
+
|
|
991
|
+
| Variable | Required | Provider | Description |
|
|
992
|
+
| ----------------------------- | ------------- | ------------ | ---------------------------------- |
|
|
993
|
+
| `PAYMENT_PROVIDER` | YES | Both | `"stripe"` or `"lemonsqueezy"` |
|
|
994
|
+
| `STRIPE_SECRET_KEY` | If Stripe | Stripe | `sk_live_...` or `sk_test_...` |
|
|
995
|
+
| `STRIPE_WEBHOOK_SECRET` | If Stripe | Stripe | `whsec_...` from Stripe dashboard |
|
|
996
|
+
| `STRIPE_PUBLISHABLE_KEY` | No (frontend) | Stripe | `pk_live_...` — for docs only |
|
|
997
|
+
| `LEMONSQUEEZY_API_KEY` | If LS | LemonSqueezy | From LS API settings |
|
|
998
|
+
| `LEMONSQUEEZY_WEBHOOK_SECRET` | If LS | LemonSqueezy | From LS webhook settings |
|
|
999
|
+
| `LEMONSQUEEZY_STORE_ID` | If LS | LemonSqueezy | Numeric store ID from LS dashboard |
|
|
1000
|
+
|
|
1001
|
+
### `.env.example` Addition (both JS and TS templates)
|
|
1002
|
+
|
|
1003
|
+
```env
|
|
1004
|
+
# ─── Payments ──────────────────────────────────────────────────────────────────
|
|
1005
|
+
# PAYMENT_PROVIDER selects the active payment adapter.
|
|
1006
|
+
# Options: "stripe" | "lemonsqueezy"
|
|
1007
|
+
# Use "lemonsqueezy" if you're based in Pakistan (Stripe payouts don't work there).
|
|
1008
|
+
PAYMENT_PROVIDER=
|
|
1009
|
+
|
|
1010
|
+
# Stripe — https://dashboard.stripe.com/apikeys
|
|
1011
|
+
STRIPE_SECRET_KEY=
|
|
1012
|
+
STRIPE_WEBHOOK_SECRET=
|
|
1013
|
+
STRIPE_PUBLISHABLE_KEY= # Frontend only — safe to expose
|
|
1014
|
+
|
|
1015
|
+
# LemonSqueezy — https://app.lemonsqueezy.com/settings/api
|
|
1016
|
+
LEMONSQUEEZY_API_KEY=
|
|
1017
|
+
LEMONSQUEEZY_WEBHOOK_SECRET=
|
|
1018
|
+
LEMONSQUEEZY_STORE_ID=
|
|
1019
|
+
```
|
|
1020
|
+
|
|
1021
|
+
### Zod Env Schema Addition (JS: `src/config/env.js`)
|
|
1022
|
+
|
|
1023
|
+
```js
|
|
1024
|
+
// Add to existing env schema object:
|
|
1025
|
+
PAYMENT_PROVIDER: z.enum(["stripe", "lemonsqueezy"]).optional(),
|
|
1026
|
+
|
|
1027
|
+
// Stripe (optional at schema level — validated at adapter init)
|
|
1028
|
+
STRIPE_SECRET_KEY: z.string().optional(),
|
|
1029
|
+
STRIPE_WEBHOOK_SECRET: z.string().optional(),
|
|
1030
|
+
|
|
1031
|
+
// LemonSqueezy
|
|
1032
|
+
LEMONSQUEEZY_API_KEY: z.string().optional(),
|
|
1033
|
+
LEMONSQUEEZY_WEBHOOK_SECRET: z.string().optional(),
|
|
1034
|
+
LEMONSQUEEZY_STORE_ID: z.string().optional(),
|
|
1035
|
+
```
|
|
1036
|
+
|
|
1037
|
+
**Important**: Keep all payment vars as `.optional()` in the Zod schema. The adapter constructors enforce required fields based on selected provider. This prevents the server from crashing on startup when payments module isn't configured yet (dev experience).
|
|
1038
|
+
|
|
1039
|
+
---
|
|
1040
|
+
|
|
1041
|
+
## 12. Route & Endpoint Spec {#route-spec}
|
|
1042
|
+
|
|
1043
|
+
### All Endpoints
|
|
1044
|
+
|
|
1045
|
+
| Method | Path | Auth Required | Body | Description |
|
|
1046
|
+
| ------ | ----------------------------- | -------------------- | ------------------- | --------------------------------- |
|
|
1047
|
+
| `POST` | `/payments/create-intent` | Yes (JWT) | `CreatePaymentBody` | Create payment intent or checkout |
|
|
1048
|
+
| `POST` | `/payments/refund` | Yes (JWT) | `RefundBody` | Refund a payment |
|
|
1049
|
+
| `GET` | `/payments/status/:paymentId` | Yes (JWT) | — | Get payment status |
|
|
1050
|
+
| `POST` | `/payments/webhook` | No (signature-based) | Raw Buffer | Receive provider webhook |
|
|
1051
|
+
|
|
1052
|
+
### Request/Response Examples
|
|
1053
|
+
|
|
1054
|
+
**POST /payments/create-intent**
|
|
1055
|
+
|
|
1056
|
+
```json
|
|
1057
|
+
// Request
|
|
1058
|
+
{
|
|
1059
|
+
"amount": 2999,
|
|
1060
|
+
"currency": "usd",
|
|
1061
|
+
"metadata": {
|
|
1062
|
+
"orderId": "order_123",
|
|
1063
|
+
"userId": "user_456",
|
|
1064
|
+
"variantId": "78901" // LemonSqueezy only
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
// Response (Stripe)
|
|
1069
|
+
{
|
|
1070
|
+
"success": true,
|
|
1071
|
+
"data": {
|
|
1072
|
+
"id": "pi_3abc...",
|
|
1073
|
+
"clientSecret": "pi_3abc..._secret_xyz",
|
|
1074
|
+
"status": "requires_payment_method",
|
|
1075
|
+
"amount": 2999,
|
|
1076
|
+
"currency": "usd"
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
// Response (LemonSqueezy)
|
|
1081
|
+
{
|
|
1082
|
+
"success": true,
|
|
1083
|
+
"data": {
|
|
1084
|
+
"id": "abc123",
|
|
1085
|
+
"checkoutUrl": "https://store.lemonsqueezy.com/checkout/buy/...",
|
|
1086
|
+
"status": "created",
|
|
1087
|
+
"amount": 2999,
|
|
1088
|
+
"currency": "usd"
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
```
|
|
1092
|
+
|
|
1093
|
+
**POST /payments/webhook**
|
|
1094
|
+
|
|
1095
|
+
```
|
|
1096
|
+
// No JSON body — raw Buffer
|
|
1097
|
+
// Headers:
|
|
1098
|
+
// stripe-signature: t=...,v1=... (Stripe)
|
|
1099
|
+
// x-signature: abc123def456... (LemonSqueezy)
|
|
1100
|
+
|
|
1101
|
+
// Response (always 200 — never return 4xx to webhooks unless signature fails)
|
|
1102
|
+
{ "received": true }
|
|
1103
|
+
```
|
|
1104
|
+
|
|
1105
|
+
---
|
|
1106
|
+
|
|
1107
|
+
## 13. Zod Schemas — Full Definitions {#zod-schemas}
|
|
1108
|
+
|
|
1109
|
+
### `payments.schemas.js` (JS Template)
|
|
1110
|
+
|
|
1111
|
+
```js
|
|
1112
|
+
import { z } from "zod"
|
|
1113
|
+
|
|
1114
|
+
export const createPaymentSchema = z.object({
|
|
1115
|
+
amount: z
|
|
1116
|
+
.number({ required_error: "amount is required" })
|
|
1117
|
+
.int("amount must be an integer (smallest currency unit)")
|
|
1118
|
+
.positive("amount must be positive")
|
|
1119
|
+
.max(99999999, "amount exceeds maximum allowed"),
|
|
1120
|
+
|
|
1121
|
+
currency: z
|
|
1122
|
+
.string({ required_error: "currency is required" })
|
|
1123
|
+
.length(3, "currency must be a 3-letter ISO 4217 code")
|
|
1124
|
+
.toLowerCase(),
|
|
1125
|
+
|
|
1126
|
+
metadata: z
|
|
1127
|
+
.record(z.string())
|
|
1128
|
+
.optional()
|
|
1129
|
+
.default({}),
|
|
1130
|
+
})
|
|
1131
|
+
|
|
1132
|
+
export const refundPaymentSchema = z.object({
|
|
1133
|
+
paymentId: z
|
|
1134
|
+
.string({ required_error: "paymentId is required" })
|
|
1135
|
+
.min(1, "paymentId cannot be empty"),
|
|
1136
|
+
|
|
1137
|
+
amount: z
|
|
1138
|
+
.number()
|
|
1139
|
+
.int("amount must be an integer")
|
|
1140
|
+
.positive("amount must be positive")
|
|
1141
|
+
.optional(),
|
|
1142
|
+
})
|
|
1143
|
+
|
|
1144
|
+
export const webhookQuerySchema = z.object({
|
|
1145
|
+
// No body validation — webhook bodies are raw Buffer
|
|
1146
|
+
// Signature is validated by the adapter
|
|
1147
|
+
})
|
|
1148
|
+
|
|
1149
|
+
// Type exports for TS version
|
|
1150
|
+
export type CreatePaymentBody = z.infer<typeof createPaymentSchema>
|
|
1151
|
+
export type RefundPaymentBody = z.infer<typeof refundPaymentSchema>
|
|
1152
|
+
```
|
|
1153
|
+
|
|
1154
|
+
---
|
|
1155
|
+
|
|
1156
|
+
## 14. Error Handling Strategy {#error-handling}
|
|
1157
|
+
|
|
1158
|
+
### Custom Error Class: `PaymentError`
|
|
1159
|
+
|
|
1160
|
+
```js
|
|
1161
|
+
// packages/payments/src/errors/PaymentError.js
|
|
1162
|
+
export class PaymentError extends Error {
|
|
1163
|
+
constructor(message, code = "PAYMENT_ERROR", statusCode = 400) {
|
|
1164
|
+
super(message);
|
|
1165
|
+
this.name = "PaymentError";
|
|
1166
|
+
this.code = code;
|
|
1167
|
+
this.statusCode = statusCode;
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
```
|
|
1171
|
+
|
|
1172
|
+
### Error Codes Reference
|
|
1173
|
+
|
|
1174
|
+
| Code | HTTP | Meaning |
|
|
1175
|
+
| ------------------------- | ---- | ------------------------------------------ |
|
|
1176
|
+
| `CONFIG_ERROR` | 500 | Missing required env var or adapter config |
|
|
1177
|
+
| `WEBHOOK_INVALID` | 401 | Webhook signature verification failed |
|
|
1178
|
+
| `STRIPE_ERROR` | 402 | Stripe API returned an error |
|
|
1179
|
+
| `LS_CHECKOUT_FAILED` | 400 | LemonSqueezy checkout creation failed |
|
|
1180
|
+
| `LS_REFUND_FAILED` | 400 | LemonSqueezy refund failed |
|
|
1181
|
+
| `LS_ORDER_NOT_FOUND` | 404 | LemonSqueezy order not found |
|
|
1182
|
+
| `MISSING_VARIANT_ID` | 400 | LemonSqueezy variantId not in metadata |
|
|
1183
|
+
| `PROVIDER_NOT_CONFIGURED` | 500 | PAYMENT_PROVIDER env var not set |
|
|
1184
|
+
| `PAYMENT_ERROR` | 400 | Generic payment error |
|
|
1185
|
+
|
|
1186
|
+
### How `PaymentError` Integrates with Existing `errorHandler.js`
|
|
1187
|
+
|
|
1188
|
+
The existing Charcole error handler checks `err.statusCode`. `PaymentError` sets this correctly. **No changes to `errorHandler.js` are needed** if `PaymentError` extends the base error class correctly.
|
|
1189
|
+
|
|
1190
|
+
Verify this by checking `template/*/src/middlewares/errorHandler.*` — if it uses `err.statusCode`, `PaymentError` will work without changes.
|
|
1191
|
+
|
|
1192
|
+
---
|
|
1193
|
+
|
|
1194
|
+
## 15. Webhook Handling — Security & Verification {#webhooks}
|
|
1195
|
+
|
|
1196
|
+
### The Raw Body Problem (Critical)
|
|
1197
|
+
|
|
1198
|
+
HTTP middleware in Express parses the body as JSON. Stripe/LemonSqueezy signature verification requires the **original raw bytes**. Once `express.json()` parses the body, the raw bytes are gone. The solution:
|
|
1199
|
+
|
|
1200
|
+
```js
|
|
1201
|
+
// app.js — ORDER MATTERS
|
|
1202
|
+
app.use("/payments/webhook", express.raw({ type: "application/json" }));
|
|
1203
|
+
// ^ Register BEFORE express.json()
|
|
1204
|
+
app.use(express.json());
|
|
1205
|
+
// ^ Global JSON parsing for all other routes
|
|
1206
|
+
```
|
|
1207
|
+
|
|
1208
|
+
When `express.raw()` runs first on `/payments/webhook`, `req.body` is a `Buffer`. The global `express.json()` does NOT re-process routes already handled.
|
|
1209
|
+
|
|
1210
|
+
### Idempotency — Duplicate Webhook Prevention
|
|
1211
|
+
|
|
1212
|
+
Webhook providers retry on failure. The same event can arrive multiple times. Without deduplication, a `payment_intent.succeeded` event could trigger multiple order fulfillments.
|
|
1213
|
+
|
|
1214
|
+
**Minimum implementation**: In-memory `Set` of processed event IDs. Clears on server restart (acceptable for v2.3.0 scope).
|
|
1215
|
+
|
|
1216
|
+
```js
|
|
1217
|
+
// In payments.service.js
|
|
1218
|
+
const processedWebhookIds = new Set();
|
|
1219
|
+
|
|
1220
|
+
export async function processWebhook(rawBody, signature) {
|
|
1221
|
+
const adapter = getAdapter();
|
|
1222
|
+
const { event, data } = await adapter.verifyWebhook(rawBody, signature);
|
|
1223
|
+
|
|
1224
|
+
const eventId = data.id ?? `${event}-${Date.now()}`;
|
|
1225
|
+
|
|
1226
|
+
if (processedWebhookIds.has(eventId)) {
|
|
1227
|
+
return { event, data, duplicate: true };
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
processedWebhookIds.add(eventId);
|
|
1231
|
+
return { event, data, duplicate: false };
|
|
1232
|
+
}
|
|
1233
|
+
```
|
|
1234
|
+
|
|
1235
|
+
**Production note** (document in README): For production, use Redis or a database table to persist processed event IDs.
|
|
1236
|
+
|
|
1237
|
+
### Webhook Events to Handle (Document, Not Implement)
|
|
1238
|
+
|
|
1239
|
+
| Stripe Event | LemonSqueezy Event | Meaning |
|
|
1240
|
+
| ------------------------------- | ------------------------ | ------------------ |
|
|
1241
|
+
| `payment_intent.succeeded` | `order_created` | Payment confirmed |
|
|
1242
|
+
| `payment_intent.payment_failed` | `order_refunded` | Payment failed |
|
|
1243
|
+
| `charge.dispute.created` | — | Chargeback created |
|
|
1244
|
+
| `customer.subscription.deleted` | `subscription_cancelled` | Subscription ended |
|
|
1245
|
+
|
|
1246
|
+
The controller logs these events. Actual business logic (fulfill order, send email) is the developer's responsibility.
|
|
1247
|
+
|
|
1248
|
+
---
|
|
1249
|
+
|
|
1250
|
+
## 16. Testing Strategy — Full Coverage Plan {#testing}
|
|
1251
|
+
|
|
1252
|
+
### Test Framework: Vitest
|
|
1253
|
+
|
|
1254
|
+
All tests use Vitest. Match the pattern of any existing tests in the repo.
|
|
1255
|
+
|
|
1256
|
+
### Test File Locations
|
|
1257
|
+
|
|
1258
|
+
```
|
|
1259
|
+
packages/payments/
|
|
1260
|
+
└── src/
|
|
1261
|
+
└── __tests__/
|
|
1262
|
+
├── StripeAdapter.test.js
|
|
1263
|
+
├── LemonSqueezyAdapter.test.js
|
|
1264
|
+
├── payments.service.test.js
|
|
1265
|
+
└── webhookUtils.test.js
|
|
1266
|
+
|
|
1267
|
+
template/js/src/modules/payments/
|
|
1268
|
+
└── __tests__/
|
|
1269
|
+
├── payments.controller.test.js
|
|
1270
|
+
└── payments.routes.test.js
|
|
1271
|
+
```
|
|
1272
|
+
|
|
1273
|
+
### Unit Tests: StripeAdapter
|
|
1274
|
+
|
|
1275
|
+
```js
|
|
1276
|
+
// StripeAdapter.test.js
|
|
1277
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
1278
|
+
import { StripeAdapter } from "../adapters/StripeAdapter.js";
|
|
1279
|
+
|
|
1280
|
+
vi.mock("stripe", () => {
|
|
1281
|
+
return {
|
|
1282
|
+
default: vi.fn().mockImplementation(() => ({
|
|
1283
|
+
paymentIntents: {
|
|
1284
|
+
create: vi.fn().mockResolvedValue({
|
|
1285
|
+
id: "pi_test_123",
|
|
1286
|
+
client_secret: "pi_test_123_secret_abc",
|
|
1287
|
+
status: "requires_payment_method",
|
|
1288
|
+
amount: 2999,
|
|
1289
|
+
currency: "usd",
|
|
1290
|
+
}),
|
|
1291
|
+
retrieve: vi.fn().mockResolvedValue({
|
|
1292
|
+
id: "pi_test_123",
|
|
1293
|
+
status: "succeeded",
|
|
1294
|
+
amount: 2999,
|
|
1295
|
+
currency: "usd",
|
|
1296
|
+
}),
|
|
1297
|
+
},
|
|
1298
|
+
refunds: {
|
|
1299
|
+
create: vi.fn().mockResolvedValue({
|
|
1300
|
+
id: "re_test_456",
|
|
1301
|
+
status: "succeeded",
|
|
1302
|
+
amount: 2999,
|
|
1303
|
+
}),
|
|
1304
|
+
},
|
|
1305
|
+
webhooks: {
|
|
1306
|
+
constructEvent: vi.fn().mockReturnValue({
|
|
1307
|
+
type: "payment_intent.succeeded",
|
|
1308
|
+
data: { object: { id: "pi_test_123" } },
|
|
1309
|
+
}),
|
|
1310
|
+
},
|
|
1311
|
+
})),
|
|
1312
|
+
};
|
|
1313
|
+
});
|
|
1314
|
+
|
|
1315
|
+
describe("StripeAdapter", () => {
|
|
1316
|
+
let adapter;
|
|
1317
|
+
|
|
1318
|
+
beforeEach(() => {
|
|
1319
|
+
adapter = new StripeAdapter({
|
|
1320
|
+
secretKey: "sk_test_fake",
|
|
1321
|
+
webhookSecret: "whsec_fake",
|
|
1322
|
+
});
|
|
1323
|
+
});
|
|
1324
|
+
|
|
1325
|
+
it("creates a payment intent", async () => {
|
|
1326
|
+
const result = await adapter.createPayment({
|
|
1327
|
+
amount: 2999,
|
|
1328
|
+
currency: "usd",
|
|
1329
|
+
});
|
|
1330
|
+
expect(result.id).toBe("pi_test_123");
|
|
1331
|
+
expect(result.clientSecret).toBeDefined();
|
|
1332
|
+
expect(result.status).toBe("requires_payment_method");
|
|
1333
|
+
});
|
|
1334
|
+
|
|
1335
|
+
it("refunds a payment", async () => {
|
|
1336
|
+
const result = await adapter.refundPayment({ paymentId: "pi_test_123" });
|
|
1337
|
+
expect(result.id).toBe("re_test_456");
|
|
1338
|
+
expect(result.status).toBe("succeeded");
|
|
1339
|
+
});
|
|
1340
|
+
|
|
1341
|
+
it("maps payment status correctly", async () => {
|
|
1342
|
+
const result = await adapter.getPaymentStatus("pi_test_123");
|
|
1343
|
+
expect(result.status).toBe("paid"); // "succeeded" maps to "paid"
|
|
1344
|
+
});
|
|
1345
|
+
|
|
1346
|
+
it("verifies a valid webhook", async () => {
|
|
1347
|
+
const result = await adapter.verifyWebhook(
|
|
1348
|
+
Buffer.from('{"test": true}'),
|
|
1349
|
+
"t=123,v1=abc",
|
|
1350
|
+
);
|
|
1351
|
+
expect(result.event).toBe("payment_intent.succeeded");
|
|
1352
|
+
});
|
|
1353
|
+
|
|
1354
|
+
it("throws PaymentError for invalid webhook signature", async () => {
|
|
1355
|
+
const { StripeAdapter } = await import("../adapters/StripeAdapter.js");
|
|
1356
|
+
// Force constructEvent to throw
|
|
1357
|
+
// ...
|
|
1358
|
+
});
|
|
1359
|
+
|
|
1360
|
+
it("throws CONFIG_ERROR when secretKey is missing", () => {
|
|
1361
|
+
expect(() => new StripeAdapter({ webhookSecret: "whsec_fake" })).toThrow(
|
|
1362
|
+
"STRIPE_SECRET_KEY is required",
|
|
1363
|
+
);
|
|
1364
|
+
});
|
|
1365
|
+
});
|
|
1366
|
+
```
|
|
1367
|
+
|
|
1368
|
+
### Unit Tests: LemonSqueezyAdapter (pattern only)
|
|
1369
|
+
|
|
1370
|
+
Test all 4 adapter methods with mocked `@lemonsqueezy/lemonsqueezy.js` functions. Test that HMAC verification throws on invalid signatures.
|
|
1371
|
+
|
|
1372
|
+
### Integration Tests: CLI Generation
|
|
1373
|
+
|
|
1374
|
+
```js
|
|
1375
|
+
// Test that selecting payments generates correct files
|
|
1376
|
+
it("generates payments module when selected", async () => {
|
|
1377
|
+
const result = await runCLI({ payments: true, provider: "stripe" });
|
|
1378
|
+
expect(result.files).toContain("src/modules/payments/payments.routes.js");
|
|
1379
|
+
expect(result.packageJson.dependencies).toHaveProperty("stripe");
|
|
1380
|
+
});
|
|
1381
|
+
|
|
1382
|
+
it("does not generate payments module when not selected", async () => {
|
|
1383
|
+
const result = await runCLI({ payments: false });
|
|
1384
|
+
expect(result.files).not.toContain("src/modules/payments");
|
|
1385
|
+
});
|
|
1386
|
+
```
|
|
1387
|
+
|
|
1388
|
+
### Integration Tests: HTTP Endpoints
|
|
1389
|
+
|
|
1390
|
+
Use `supertest` to test routes with a real Express app and mocked adapters.
|
|
1391
|
+
|
|
1392
|
+
```js
|
|
1393
|
+
it("POST /payments/create-intent returns 201 with payment data", async () => {
|
|
1394
|
+
const res = await request(app)
|
|
1395
|
+
.post("/payments/create-intent")
|
|
1396
|
+
.set("Authorization", `Bearer ${testToken}`)
|
|
1397
|
+
.send({ amount: 2999, currency: "usd" });
|
|
1398
|
+
|
|
1399
|
+
expect(res.status).toBe(201);
|
|
1400
|
+
expect(res.body.data.id).toBeDefined();
|
|
1401
|
+
});
|
|
1402
|
+
|
|
1403
|
+
it("POST /payments/webhook returns 200 without auth", async () => {
|
|
1404
|
+
const res = await request(app)
|
|
1405
|
+
.post("/payments/webhook")
|
|
1406
|
+
.set("stripe-signature", mockSignature)
|
|
1407
|
+
.set("Content-Type", "application/json")
|
|
1408
|
+
.send(Buffer.from('{"id": "evt_test"}'));
|
|
1409
|
+
|
|
1410
|
+
expect(res.status).toBe(200);
|
|
1411
|
+
expect(res.body.received).toBe(true);
|
|
1412
|
+
});
|
|
1413
|
+
```
|
|
1414
|
+
|
|
1415
|
+
---
|
|
1416
|
+
|
|
1417
|
+
## 17. Code Style Rules (Enforced) {#code-style}
|
|
1418
|
+
|
|
1419
|
+
These rules **must** be followed by AI agents generating code. Do not deviate.
|
|
1420
|
+
|
|
1421
|
+
### JS Template Rules
|
|
1422
|
+
|
|
1423
|
+
- No semicolons
|
|
1424
|
+
- 2 spaces indentation
|
|
1425
|
+
- ES modules (`import/export`)
|
|
1426
|
+
- `async/await` — never `.then()`
|
|
1427
|
+
- Arrow functions for callbacks and inline functions
|
|
1428
|
+
- Named exports for controllers/services, default export for router
|
|
1429
|
+
- No `console.log` in production code — use the `logger` utility
|
|
1430
|
+
- Error handling: always `try/catch` in controllers, always `next(err)`
|
|
1431
|
+
|
|
1432
|
+
### TS Template Rules
|
|
1433
|
+
|
|
1434
|
+
- Semicolons at end of statements
|
|
1435
|
+
- Explicit return types on all exported functions
|
|
1436
|
+
- No `any` — use `unknown` if type is truly unknown, then narrow
|
|
1437
|
+
- Interfaces for object shapes, `type` for unions/intersections
|
|
1438
|
+
- `readonly` on interface properties where mutation isn't intended
|
|
1439
|
+
|
|
1440
|
+
### Naming Conventions
|
|
1441
|
+
|
|
1442
|
+
| Type | Convention | Example |
|
|
1443
|
+
| ---------------- | --------------------------- | --------------------- |
|
|
1444
|
+
| Variables | camelCase | `paymentResult` |
|
|
1445
|
+
| Functions | camelCase | `createPayment` |
|
|
1446
|
+
| Classes | PascalCase | `StripeAdapter` |
|
|
1447
|
+
| Interfaces/Types | PascalCase | `PaymentAdapter` |
|
|
1448
|
+
| Constants | UPPER_CASE | `PAYMENT_PROVIDER` |
|
|
1449
|
+
| Files | kebab-case or dot-separated | `payments.service.js` |
|
|
1450
|
+
| Directories | kebab-case | `payments/` |
|
|
1451
|
+
|
|
1452
|
+
### Import Order
|
|
1453
|
+
|
|
1454
|
+
```js
|
|
1455
|
+
// 1. Node built-ins
|
|
1456
|
+
import { createHmac } from "crypto";
|
|
1457
|
+
import { existsSync } from "fs";
|
|
1458
|
+
|
|
1459
|
+
// 2. External packages
|
|
1460
|
+
import Stripe from "stripe";
|
|
1461
|
+
import { z } from "zod";
|
|
1462
|
+
|
|
1463
|
+
// 3. Internal absolute (config, utils)
|
|
1464
|
+
import { env } from "../../config/env.js";
|
|
1465
|
+
import { logger } from "../../utils/logger.js";
|
|
1466
|
+
|
|
1467
|
+
// 4. Internal relative (same module)
|
|
1468
|
+
import { PaymentAdapter } from "./PaymentAdapter.js";
|
|
1469
|
+
import { PaymentError } from "../errors/PaymentError.js";
|
|
1470
|
+
```
|
|
1471
|
+
|
|
1472
|
+
---
|
|
1473
|
+
|
|
1474
|
+
## 18. Swagger / OpenAPI Integration {#swagger}
|
|
1475
|
+
|
|
1476
|
+
### Auto-Documentation
|
|
1477
|
+
|
|
1478
|
+
If the `swagger` module is also selected during CLI setup, payment endpoints must be documented. Add JSDoc comments to `payments.routes.js` following the existing swagger pattern in `SWAGGER_GUIDE.md`.
|
|
1479
|
+
|
|
1480
|
+
### Swagger Comments Template
|
|
1481
|
+
|
|
1482
|
+
```js
|
|
1483
|
+
/**
|
|
1484
|
+
* @swagger
|
|
1485
|
+
* /payments/create-intent:
|
|
1486
|
+
* post:
|
|
1487
|
+
* summary: Create a payment intent or checkout session
|
|
1488
|
+
* tags: [Payments]
|
|
1489
|
+
* security:
|
|
1490
|
+
* - bearerAuth: []
|
|
1491
|
+
* requestBody:
|
|
1492
|
+
* required: true
|
|
1493
|
+
* content:
|
|
1494
|
+
* application/json:
|
|
1495
|
+
* schema:
|
|
1496
|
+
* type: object
|
|
1497
|
+
* required: [amount, currency]
|
|
1498
|
+
* properties:
|
|
1499
|
+
* amount:
|
|
1500
|
+
* type: integer
|
|
1501
|
+
* description: Amount in smallest currency unit (cents for USD, paisas for PKR)
|
|
1502
|
+
* example: 2999
|
|
1503
|
+
* currency:
|
|
1504
|
+
* type: string
|
|
1505
|
+
* description: ISO 4217 currency code
|
|
1506
|
+
* example: usd
|
|
1507
|
+
* metadata:
|
|
1508
|
+
* type: object
|
|
1509
|
+
* description: Optional metadata. LemonSqueezy requires variantId here.
|
|
1510
|
+
* responses:
|
|
1511
|
+
* 201:
|
|
1512
|
+
* description: Payment intent created
|
|
1513
|
+
* 400:
|
|
1514
|
+
* description: Validation error
|
|
1515
|
+
* 401:
|
|
1516
|
+
* description: Unauthorized
|
|
1517
|
+
*/
|
|
1518
|
+
```
|
|
1519
|
+
|
|
1520
|
+
Add equivalent comments for `/refund`, `/status/:paymentId`, and `/webhook`.
|
|
1521
|
+
|
|
1522
|
+
---
|
|
1523
|
+
|
|
1524
|
+
## 19. Migration Guide (Existing Projects) {#migration}
|
|
1525
|
+
|
|
1526
|
+
For developers who already have a Charcole project and want to add payments:
|
|
1527
|
+
|
|
1528
|
+
### Step 1 — Install Package
|
|
1529
|
+
|
|
1530
|
+
```bash
|
|
1531
|
+
npm install @charcoles/payments
|
|
1532
|
+
```
|
|
1533
|
+
|
|
1534
|
+
### Step 2 — Add Env Vars
|
|
1535
|
+
|
|
1536
|
+
Copy the payment section from `.env.example` into your `.env`:
|
|
1537
|
+
|
|
1538
|
+
```env
|
|
1539
|
+
PAYMENT_PROVIDER=lemonsqueezy
|
|
1540
|
+
LEMONSQUEEZY_API_KEY=your_key_here
|
|
1541
|
+
LEMONSQUEEZY_WEBHOOK_SECRET=your_secret_here
|
|
1542
|
+
LEMONSQUEEZY_STORE_ID=12345
|
|
1543
|
+
```
|
|
1544
|
+
|
|
1545
|
+
### Step 3 — Update `app.js`/`app.ts`
|
|
1546
|
+
|
|
1547
|
+
```js
|
|
1548
|
+
// Add BEFORE express.json()
|
|
1549
|
+
import { setupPayments } from "@charcoles/payments";
|
|
1550
|
+
app.use("/payments/webhook", express.raw({ type: "application/json" }));
|
|
1551
|
+
|
|
1552
|
+
// After express.json() and other middleware
|
|
1553
|
+
setupPayments(app);
|
|
1554
|
+
```
|
|
1555
|
+
|
|
1556
|
+
### Step 4 — Update Env Schema
|
|
1557
|
+
|
|
1558
|
+
Add payment vars to `src/config/env.js`:
|
|
1559
|
+
|
|
1560
|
+
```js
|
|
1561
|
+
PAYMENT_PROVIDER: z.enum(["stripe", "lemonsqueezy"]).optional(),
|
|
1562
|
+
LEMONSQUEEZY_API_KEY: z.string().optional(),
|
|
1563
|
+
// etc.
|
|
1564
|
+
```
|
|
1565
|
+
|
|
1566
|
+
### Step 5 — Test
|
|
1567
|
+
|
|
1568
|
+
```bash
|
|
1569
|
+
curl -X POST http://localhost:3000/payments/create-intent \
|
|
1570
|
+
-H "Authorization: Bearer <token>" \
|
|
1571
|
+
-H "Content-Type: application/json" \
|
|
1572
|
+
-d '{"amount": 999, "currency": "usd", "metadata": {"variantId": "12345"}}'
|
|
1573
|
+
```
|
|
1574
|
+
|
|
1575
|
+
---
|
|
1576
|
+
|
|
1577
|
+
## 20. Release Checklist {#release-checklist}
|
|
1578
|
+
|
|
1579
|
+
### Pre-Release
|
|
1580
|
+
|
|
1581
|
+
- [ ] All unit tests pass: `npm run test:run` in `packages/payments/`
|
|
1582
|
+
- [ ] All integration tests pass
|
|
1583
|
+
- [ ] Generated JS project starts without errors
|
|
1584
|
+
- [ ] Generated TS project starts without errors (no type errors)
|
|
1585
|
+
- [ ] Both providers tested with real test credentials
|
|
1586
|
+
- [ ] Stripe webhook tested with `stripe listen --forward-to localhost:3000/payments/webhook`
|
|
1587
|
+
- [ ] LemonSqueezy webhook tested with ngrok or similar tunnel
|
|
1588
|
+
- [ ] `setupPayments()` works in a blank Express app (independence test)
|
|
1589
|
+
- [ ] Swagger UI shows all payment endpoints when swagger module is also selected
|
|
1590
|
+
|
|
1591
|
+
### Documentation
|
|
1592
|
+
|
|
1593
|
+
- [ ] `packages/payments/README.md` — Full setup guide for standalone use
|
|
1594
|
+
- [ ] Payment section added to root `README.md`
|
|
1595
|
+
- [ ] `template/*/lib/swagger/SWAGGER_GUIDE.md` updated with payment examples
|
|
1596
|
+
- [ ] Migration guide reviewed and tested
|
|
1597
|
+
|
|
1598
|
+
### Release
|
|
1599
|
+
|
|
1600
|
+
- [ ] `packages/payments/package.json` version set to `1.0.0`
|
|
1601
|
+
- [ ] Root `package.json` version bumped to `2.3.0`
|
|
1602
|
+
- [ ] Root `CHANGELOG.md` entry added for `v2.3.0`
|
|
1603
|
+
- [ ] `packages/payments/CHANGELOG.md` entry added for `v1.0.0`
|
|
1604
|
+
- [ ] `npm run build` in `packages/payments/` produces tarball
|
|
1605
|
+
- [ ] `npm publish` for `@charcoles/payments`
|
|
1606
|
+
- [ ] Charcole `v2.3.0` tagged and published
|
|
1607
|
+
|
|
1608
|
+
---
|
|
1609
|
+
|
|
1610
|
+
## 21. AI Agent Instructions {#ai-agent-instructions}
|
|
1611
|
+
|
|
1612
|
+
You are implementing the payments module for Charcole v2.3.0. Read this entire document before writing a single line of code.
|
|
1613
|
+
|
|
1614
|
+
### Before Starting Any Task
|
|
1615
|
+
|
|
1616
|
+
1. Read the existing file you're modifying before making changes
|
|
1617
|
+
2. Never overwrite existing functionality — add to it
|
|
1618
|
+
3. Follow the code style rules in Section 17 exactly
|
|
1619
|
+
4. Run tests after every significant change
|
|
1620
|
+
5. The webhook raw body setup is the #1 integration failure point — always verify it's correct
|
|
1621
|
+
|
|
1622
|
+
### Implementation Order (Strict)
|
|
1623
|
+
|
|
1624
|
+
Follow this order. Do not parallelize phases that depend on each other.
|
|
1625
|
+
|
|
1626
|
+
```
|
|
1627
|
+
Phase 1 — Package Foundation
|
|
1628
|
+
1.1 Create packages/payments/ directory structure
|
|
1629
|
+
1.2 Implement PaymentError class
|
|
1630
|
+
1.3 Implement PaymentAdapter interface
|
|
1631
|
+
1.4 Implement StripeAdapter (with all 4 methods)
|
|
1632
|
+
1.5 Implement LemonSqueezyAdapter (with all 4 methods)
|
|
1633
|
+
1.6 Implement setupPayments() function
|
|
1634
|
+
1.7 Write and pass unit tests for both adapters
|
|
1635
|
+
1.8 Verify standalone usage works in a test Express app
|
|
1636
|
+
|
|
1637
|
+
Phase 2 — JS Template
|
|
1638
|
+
2.1 Create template/js/src/modules/payments/ files
|
|
1639
|
+
2.2 Modify template/js/src/app.js (webhook raw body)
|
|
1640
|
+
2.3 Modify template/js/src/routes/index.js (conditional import)
|
|
1641
|
+
2.4 Modify template/js/src/config/env.js (add payment vars)
|
|
1642
|
+
2.5 Modify template/js/.env.example (add payment vars)
|
|
1643
|
+
|
|
1644
|
+
Phase 3 — TS Template
|
|
1645
|
+
3.1 Create template/ts/src/modules/payments/ files
|
|
1646
|
+
3.2 Modify template/ts/src/app.ts (webhook raw body)
|
|
1647
|
+
3.3 Modify template/ts/src/routes/index.ts (conditional import)
|
|
1648
|
+
3.4 Modify template/ts/src/config/env.ts (add payment vars)
|
|
1649
|
+
3.5 Modify template/ts/.env.example (add payment vars)
|
|
1650
|
+
3.6 Verify no TypeScript compilation errors
|
|
1651
|
+
|
|
1652
|
+
Phase 4 — CLI
|
|
1653
|
+
4.1 Modify bin/index.js (add prompts + copy logic)
|
|
1654
|
+
4.2 Test CLI generation with payments selected
|
|
1655
|
+
4.3 Test CLI generation with payments NOT selected (regression)
|
|
1656
|
+
|
|
1657
|
+
Phase 5 — Tests & Docs
|
|
1658
|
+
5.1 Write integration tests for generated projects
|
|
1659
|
+
5.2 Update Swagger guide
|
|
1660
|
+
5.3 Update root README
|
|
1661
|
+
5.4 Write packages/payments/README.md
|
|
1662
|
+
|
|
1663
|
+
Phase 6 — Release Prep
|
|
1664
|
+
6.1 Update CHANGELOG files
|
|
1665
|
+
6.2 Bump version numbers
|
|
1666
|
+
6.3 Build tarball
|
|
1667
|
+
```
|
|
1668
|
+
|
|
1669
|
+
### Decision Rules for AI Agents
|
|
1670
|
+
|
|
1671
|
+
| Situation | Rule |
|
|
1672
|
+
| ------------------------------------------ | ------------------------------------------------------------------------------ |
|
|
1673
|
+
| File already exists | Read it first, then modify. Never overwrite. |
|
|
1674
|
+
| Unsure about a type | Use `unknown` in TS, add JSDoc in JS. Never use `any`. |
|
|
1675
|
+
| Unsure about error handling | Throw `PaymentError` with a specific code. Never `throw new Error()` directly. |
|
|
1676
|
+
| Need to add to app.js | Add ABOVE the existing middleware, not below. Webhook raw body must be first. |
|
|
1677
|
+
| Provider-specific logic needed | Put it in the adapter. Never in the controller or service. |
|
|
1678
|
+
| Test is failing | Fix the code, not the test. |
|
|
1679
|
+
| Template doesn't have a type for something | Create it in `payments.types.ts`. Don't modify existing type files. |
|
|
1680
|
+
|
|
1681
|
+
### What NOT to Do
|
|
1682
|
+
|
|
1683
|
+
- Do NOT add database integration (no Prisma, no knex, no pg)
|
|
1684
|
+
- Do NOT hardcode API keys anywhere
|
|
1685
|
+
- Do NOT change the `errorHandler.js` middleware
|
|
1686
|
+
- Do NOT change how existing routes (auth, health) work
|
|
1687
|
+
- Do NOT use `console.log` — use the `logger` utility
|
|
1688
|
+
- Do NOT add payment module imports that are unconditional in `routes/index.*` — use the `existsSync` pattern
|
|
1689
|
+
- Do NOT assume `req.body` is a Buffer in non-webhook routes
|
|
1690
|
+
- Do NOT use CommonJS (`require`) — use ES modules everywhere
|
|
1691
|
+
|
|
1692
|
+
---
|
|
1693
|
+
|
|
1694
|
+
## 22. Common Pitfalls & How to Avoid Them {#pitfalls}
|
|
1695
|
+
|
|
1696
|
+
### Pitfall 1: Webhook Raw Body
|
|
1697
|
+
|
|
1698
|
+
**Problem**: `express.json()` parses the body before the webhook handler runs, destroying the raw bytes needed for signature verification.
|
|
1699
|
+
|
|
1700
|
+
**Solution**: Register `express.raw({ type: 'application/json' })` on the webhook path BEFORE `express.json()` in `app.js`. This is documented in Section 9 and must be done in both JS and TS templates.
|
|
1701
|
+
|
|
1702
|
+
**How to verify**: `typeof req.body === 'object' && Buffer.isBuffer(req.body)` should be true in the webhook controller.
|
|
1703
|
+
|
|
1704
|
+
---
|
|
1705
|
+
|
|
1706
|
+
### Pitfall 2: LemonSqueezy Requires Product Variants
|
|
1707
|
+
|
|
1708
|
+
**Problem**: You try to create a payment with just `amount` and `currency`, but LemonSqueezy throws a 422 because it doesn't accept raw amounts.
|
|
1709
|
+
|
|
1710
|
+
**Solution**: Document clearly that LemonSqueezy requires `metadata.variantId`. Add validation in `createPaymentSchema` or the adapter constructor. Include setup instructions in README for creating a "custom amount" product in the LS dashboard.
|
|
1711
|
+
|
|
1712
|
+
---
|
|
1713
|
+
|
|
1714
|
+
### Pitfall 3: Status Code Mismatch
|
|
1715
|
+
|
|
1716
|
+
**Problem**: Stripe and LemonSqueezy use different status strings. Stripe: `succeeded`, `requires_payment_method`. LemonSqueezy: `paid`, `pending`.
|
|
1717
|
+
|
|
1718
|
+
**Solution**: The adapter normalizes all statuses to the `PaymentStatus.status` union: `'pending' | 'paid' | 'failed' | 'refunded'`. Use the `statusMap` objects in each adapter. The controller and service only ever see the normalized statuses.
|
|
1719
|
+
|
|
1720
|
+
---
|
|
1721
|
+
|
|
1722
|
+
### Pitfall 4: Webhook 4xx Response Causes Retries
|
|
1723
|
+
|
|
1724
|
+
**Problem**: If the webhook endpoint returns a 4xx, providers will retry the webhook. This can create a retry storm if there's a bug.
|
|
1725
|
+
|
|
1726
|
+
**Solution**: The webhook controller should return 200 for signature failures only after logging the error. Only return 4xx/5xx for genuine server errors. This is why the controller catches errors and returns `{ received: true }` even for some failure cases.
|
|
1727
|
+
|
|
1728
|
+
**Exception**: Return 401 for invalid signatures — this is correct behavior and providers expect it.
|
|
1729
|
+
|
|
1730
|
+
---
|
|
1731
|
+
|
|
1732
|
+
### Pitfall 5: TypeScript `strict` Mode
|
|
1733
|
+
|
|
1734
|
+
**Problem**: The TS template likely has `"strict": true` in `tsconfig.json`. Partial implementations with missing method bodies will fail compilation.
|
|
1735
|
+
|
|
1736
|
+
**Solution**: Implement ALL methods on ALL adapters. No `// TODO` stubs in production code. If a method is genuinely not supported (e.g., LemonSqueezy doesn't support partial refunds via API), throw a `PaymentError` with `NOT_SUPPORTED` code.
|
|
1737
|
+
|
|
1738
|
+
---
|
|
1739
|
+
|
|
1740
|
+
### Pitfall 6: Adapter Singleton and Test Isolation
|
|
1741
|
+
|
|
1742
|
+
**Problem**: `getAdapter()` caches the adapter in a module-level variable. Tests that change `PAYMENT_PROVIDER` between test cases will get the wrong adapter.
|
|
1743
|
+
|
|
1744
|
+
**Solution**: Export a `resetAdapter()` function that sets the cached adapter to `null`. Call it in `beforeEach` in tests.
|
|
1745
|
+
|
|
1746
|
+
```js
|
|
1747
|
+
// payments.adapter.js
|
|
1748
|
+
let adapter = null;
|
|
1749
|
+
export function resetAdapter() {
|
|
1750
|
+
adapter = null;
|
|
1751
|
+
}
|
|
1752
|
+
```
|
|
1753
|
+
|
|
1754
|
+
---
|
|
1755
|
+
|
|
1756
|
+
_End of plan-2.3.0-enhanced.md — Charcole v2.3.0 Payments Module_
|