Payment Ledger is a backend service for an online course selling platform. It handles user authentication, course purchasing, and payment processing via Stripe.
- Users register and log in to receive a JWT access token
- Authenticated users can purchase a course by creating a Stripe Payment Intent
- The frontend completes the payment using Stripe.js with the returned
clientSecret - Stripe notifies the server via webhook when a payment succeeds, and the server records the purchase
- A user can only purchase the same course once (enforced at the database level)
- Payment status is either
PENDING(intent created, awaiting confirmation) orPAID(confirmed by Stripe webhook) userIdis always derived from the authenticated JWT token — it cannot be supplied by the client
| Category | Technology |
|---|---|
| Runtime | Node.js 22 |
| Language | TypeScript 5 |
| Framework | Express.js |
| ORM | Prisma 5 |
| Database | PostgreSQL 16 |
| Cache | Redis 7 |
| Payment | Stripe |
| Auth | JWT (jsonwebtoken) |
| Password Hashing | bcrypt (12 rounds) |
| Validation | Zod |
| Logger | Winston |
| Testing | Jest |
| Container | Docker / Docker Compose |
This project follows Clean Architecture. The rule is simple: dependencies only point inward. The domain layer knows nothing about Express, Prisma, or Stripe — it only defines what the system needs, not how it is done.
┌──────────────────────────────────────────────────┐
│ PRESENTATION LAYER │
│ │
│ Express routes · Controllers · Middleware │
│ (HTTP in → use case → HTTP out) │
├──────────────────────────────────────────────────┤
│ APPLICATION LAYER │
│ │
│ Use Cases · DTOs · Zod validation schemas │
│ (orchestrates business logic) │
├──────────────────────────────────────────────────┤
│ DOMAIN LAYER │
│ │
│ Entities · Repository interfaces │
│ Service interfaces · Domain errors │
│ (pure business rules — no external deps) │
├──────────────────────────────────────────────────┤
│ INFRASTRUCTURE LAYER │
│ │
│ Prisma · StripePaymentService · Redis │
│ JwtService · BcryptHashService · Winston │
│ (implements domain interfaces) │
└──────────────────────────────────────────────────┘
Domain — the core. Defines Payment and User entities, and declares interfaces like IPaymentGateway and IPaymentRepository. Has zero external dependencies.
Application — use cases that orchestrate business logic. Each use case accepts input via a DTO, calls domain interfaces, and returns a response DTO. Examples: CreatePaymentIntentUseCase, LoginUseCase.
Infrastructure — concrete implementations of domain interfaces. StripePaymentService implements IPaymentGateway. PrismaPaymentRepository implements IPaymentRepository. Swapping Stripe for another provider only requires a new file here.
Presentation — translates HTTP requests into use-case calls and formats the response. Contains Express controllers, route definitions, and middleware (auth, validation, error handling).
Dependency Injection — all layers are wired together in src/container.ts. No layer instantiates its own dependencies.
src/
├── domain/
│ ├── entities/ # Payment, User
│ ├── repositories/ # IPaymentRepository, IUserRepository
│ ├── services/ # IPaymentGateway, IHashService, ITokenService
│ └── errors/ # DomainError, NotFoundError, ConflictError, etc.
│
├── application/
│ ├── use-cases/
│ │ ├── auth/ # LoginUseCase
│ │ ├── user/ # CreateUserUseCase, GetUserUseCase
│ │ └── payment/ # CreatePaymentIntentUseCase, HandleWebhookUseCase
│ ├── dtos/ # Request/response shapes + Zod schemas
│ └── interfaces/ # ICacheService
│
├── infrastructure/
│ ├── database/
│ │ ├── PrismaClient.ts
│ │ └── repositories/ # PrismaUserRepository, PrismaPaymentRepository
│ ├── services/ # StripePaymentService, JwtService, BcryptHashService
│ ├── cache/ # RedisClient, CacheService
│ ├── config/ # env.ts (Zod-validated environment variables)
│ └── logger/ # Winston logger
│
├── presentation/
│ ├── controllers/ # AuthController, UserController, PaymentController
│ ├── routes/ # Route definitions per domain
│ └── middleware/ # authMiddleware, errorHandler, validateRequest
│
├── container.ts # Dependency injection wiring
├── main.ts # Bootstrap — connect DB, start server, graceful shutdown
└── types/
└── express.d.ts # Express Request augmentation (req.user)
prisma/
├── schema.prisma # DB schema
└── migrations/ # Migration history
This project uses Stripe Payment Intents — Stripe's recommended API for one-time payments. The flow is split between the server (creating the intent) and the client (confirming the payment), with Stripe webhooks closing the loop.
CLIENT (Browser / Mobile) SERVER STRIPE
│ │ │
│ POST /payment/create │ │
│ ──────────────────────────► │ │
│ │ Create PaymentIntent │
│ │ ────────────────────────► │
│ │ ◄──────────────────────── │
│ │ { id, client_secret } │
│ │ │
│ │ Save Payment (PENDING) │
│ │ to PostgreSQL │
│ │ │
│ { paymentId, clientSecret } │ │
│ ◄────────────────────────── │ │
│ │ │
│ confirmPayment(clientSecret) │ │
│ ──────────────────────────────────────────────────────────► │
│ │ │
│ │ Webhook: succeeded │
│ │ ◄──────────────────────── │
│ │ │
│ │ Update Payment → PAID │
│ │ Set paidAt timestamp │
│ │ in PostgreSQL │
Step 1 — Client requests a payment
The authenticated client sends POST /payment/create with courseId, amount, and currency. The server extracts userId from the JWT token.
Step 2 — Server creates a PaymentIntent
CreatePaymentIntentUseCase calls StripePaymentService.createPaymentIntent(), which calls the Stripe API and returns a clientSecret and intentId.
Step 3 — Server saves a PENDING record
A Payment entity is created (status: PENDING) and persisted to PostgreSQL with the stripe_payment_intent_id stored for later reconciliation.
Step 4 — Server returns clientSecret to client
The client receives { paymentId, clientSecret }. The clientSecret is a short-lived token used exclusively by Stripe.js to confirm the payment on the frontend — it is never stored on the server.
Step 5 — Client confirms payment via Stripe.js
The frontend calls stripe.confirmPayment({ clientSecret }). The payment card details go directly to Stripe's servers — they never touch this backend.
Step 6 — Stripe sends a webhook
After the payment is confirmed, Stripe sends a payment_intent.succeeded event to POST /payment/webhook.
Step 7 — Server verifies and records the payment
HandleWebhookUseCase calls stripe.webhooks.constructEvent() to verify the webhook signature using STRIPE_WEBHOOK_SECRET. If valid, it updates the Payment status to PAID and sets the paidAt timestamp in PostgreSQL.
Every webhook request is verified before processing:
POST /payment/webhook
│
├── raw body (Buffer) preserved by express.raw()
├── stripe-signature header extracted
│
└── stripe.webhooks.constructEvent(rawBody, signature, STRIPE_WEBHOOK_SECRET)
├── valid → process event
└── invalid → return 400, discard
The route uses express.raw({ type: 'application/json' }) instead of express.json() because Stripe's signature verification requires the exact raw bytes of the request body — parsing it to JSON first would invalidate the signature.
| Object | Purpose |
|---|---|
PaymentIntent |
Represents a single payment attempt. Created server-side, confirmed client-side. |
clientSecret |
Short-lived token returned to the client for Stripe.js to confirm the payment. Never stored. |
stripe-signature |
HMAC header sent with every webhook. Used to verify the event originated from Stripe. |
STRIPE_WEBHOOK_SECRET |
Shared secret between Stripe and this server for webhook verification. |
| Event | Action |
|---|---|
payment_intent.succeeded |
Set payment status → PAID, record paidAt timestamp |
Failed payments (payment_intent.payment_failed) are not stored as failed — the record stays PENDING and the PaymentIntent expires on Stripe's side automatically.
- Node.js 22+
- Docker Desktop
git clone <repo-url>
cd ts-backend
npm installcp .env.example .envFill in the Stripe keys in .env:
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...docker compose up -dStarts PostgreSQL on localhost:5432 and Redis on localhost:6380.
npm run db:migratenpm run devServer runs at http://localhost:3000.
Base URL: http://localhost:3000/api/v1
GET /health
POST /auth/register { email, name, password }
POST /auth/login { email, password } → { user, accessToken }
GET /users/me
GET /users/:id
POST /payment/create { courseId, amount, currency } → { paymentId, clientSecret }
POST /payment/webhook (Stripe webhook — no auth)
| Variable | Description | Default |
|---|---|---|
NODE_ENV |
Environment | development |
PORT |
HTTP port | 3000 |
DATABASE_URL |
PostgreSQL connection string | postgresql://postgres:postgres@localhost:5432/ts_backend |
REDIS_HOST |
Redis host | localhost |
REDIS_PORT |
Redis port | 6380 |
JWT_SECRET |
Signing secret (min 32 chars) | — |
JWT_EXPIRES_IN |
Token expiry | 15m |
CORS_ORIGIN |
Allowed CORS origin | http://localhost:3000 |
STRIPE_SECRET_KEY |
Stripe secret key (sk_test_...) |
— |
STRIPE_WEBHOOK_SECRET |
Webhook signing secret (whsec_...) |
— |
| Script | Description |
|---|---|
npm run dev |
Start dev server with hot reload |
npm run build |
Compile TypeScript to dist/ |
npm run start |
Run compiled app |
npm run typecheck |
TypeScript type check without emitting |
npm run test |
Run tests |
npm run test:coverage |
Run tests with coverage report |
npm run db:migrate |
Create and apply a new migration |
npm run db:migrate:prod |
Apply pending migrations (production) |
npm run db:generate |
Regenerate Prisma client |
npm run db:studio |
Open Prisma Studio (DB GUI) |
# Local dev — infrastructure only (PostgreSQL + Redis)
docker compose up -d
# Full stack — app + infrastructure
docker compose -f docker-compose.full.yml up --build