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.
Files changed (72) hide show
  1. package/CHANGELOG.md +34 -3
  2. package/README.md +94 -4
  3. package/bin/index.js +174 -4
  4. package/package.json +3 -3
  5. package/packages/payments/CHANGELOG.md +14 -0
  6. package/packages/payments/README.md +222 -0
  7. package/packages/payments/charcoles-payments-1.0.0.tgz +0 -0
  8. package/packages/payments/package.json +61 -0
  9. package/packages/payments/smoke-test.js +20 -0
  10. package/packages/payments/src/__tests__/LemonSqueezyAdapter.test.js +236 -0
  11. package/packages/payments/src/__tests__/StripeAdapter.test.js +139 -0
  12. package/packages/payments/src/__tests__/payments.service.test.js +131 -0
  13. package/packages/payments/src/__tests__/webhookUtils.test.js +47 -0
  14. package/packages/payments/src/adapters/LemonSqueezyAdapter.js +150 -0
  15. package/packages/payments/src/adapters/PaymentAdapter.js +109 -0
  16. package/packages/payments/src/adapters/StripeAdapter.js +114 -0
  17. package/packages/payments/src/controllers/payments.controller.js +48 -0
  18. package/packages/payments/src/errors/PaymentError.js +8 -0
  19. package/packages/payments/src/helpers/webhookUtils.js +27 -0
  20. package/packages/payments/src/index.d.ts +87 -0
  21. package/packages/payments/src/index.js +6 -0
  22. package/packages/payments/src/routes/payments.routes.js +41 -0
  23. package/packages/payments/src/schemas/payments.schemas.js +24 -0
  24. package/packages/payments/src/services/payments.service.js +68 -0
  25. package/packages/swagger/BACKWARD_COMPATIBILITY.md +1 -1
  26. package/packages/swagger/CHANGELOG.md +1 -1
  27. package/packages/swagger/README.md +3 -3
  28. package/packages/swagger/package.json +3 -3
  29. package/packages/swagger/src/setup.js +1 -1
  30. package/plan-2.3.0.md +1756 -0
  31. package/template/js/.env.example +20 -1
  32. package/template/js/README.md +140 -8
  33. package/template/js/basePackage.json +1 -1
  34. package/template/js/src/app.js +18 -1
  35. package/template/js/src/config/env.js +8 -0
  36. package/template/js/src/config/swagger.config.js +1 -1
  37. package/template/js/src/lib/swagger/SWAGGER_GUIDE.md +37 -3
  38. package/template/js/src/modules/payments/__tests__/payments.controller.test.js +342 -0
  39. package/template/js/src/modules/payments/__tests__/payments.routes.test.js +256 -0
  40. package/template/js/src/modules/payments/__tests__/payments.schemas.test.js +94 -0
  41. package/template/js/src/modules/payments/__tests__/payments.service.test.js +141 -0
  42. package/template/js/src/modules/payments/package.json +7 -0
  43. package/template/js/src/modules/payments/payments.adapter.js +47 -0
  44. package/template/js/src/modules/payments/payments.constants.js +20 -0
  45. package/template/js/src/modules/payments/payments.controller.js +85 -0
  46. package/template/js/src/modules/payments/payments.routes.js +125 -0
  47. package/template/js/src/modules/payments/payments.schemas.js +28 -0
  48. package/template/js/src/modules/payments/payments.service.js +34 -0
  49. package/template/js/src/modules/swagger/package.json +1 -1
  50. package/template/js/src/routes/index.js +16 -0
  51. package/template/ts/.env.example +18 -1
  52. package/template/ts/README.md +142 -8
  53. package/template/ts/basePackage.json +1 -1
  54. package/template/ts/src/app.ts +13 -0
  55. package/template/ts/src/config/env.ts +7 -0
  56. package/template/ts/src/config/swagger.config.ts +1 -1
  57. package/template/ts/src/lib/swagger/SWAGGER_GUIDE.md +36 -2
  58. package/template/ts/src/modules/payments/__tests__/payments.controller.test.ts +282 -0
  59. package/template/ts/src/modules/payments/__tests__/payments.routes.test.ts +256 -0
  60. package/template/ts/src/modules/payments/__tests__/payments.schemas.test.ts +94 -0
  61. package/template/ts/src/modules/payments/__tests__/payments.service.test.ts +135 -0
  62. package/template/ts/src/modules/payments/package.json +7 -0
  63. package/template/ts/src/modules/payments/payments.adapter.ts +74 -0
  64. package/template/ts/src/modules/payments/payments.constants.ts +18 -0
  65. package/template/ts/src/modules/payments/payments.controller.ts +104 -0
  66. package/template/ts/src/modules/payments/payments.routes.ts +125 -0
  67. package/template/ts/src/modules/payments/payments.schemas.ts +31 -0
  68. package/template/ts/src/modules/payments/payments.service.ts +51 -0
  69. package/template/ts/src/modules/payments/payments.types.ts +56 -0
  70. package/template/ts/src/modules/swagger/package.json +1 -1
  71. package/template/ts/src/routes/index.ts +8 -0
  72. 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_