Skip to content

Repository files navigation

Payment Ledger — TypeScript Backend

About This Project

Payment Ledger is a backend service for an online course selling platform. It handles user authentication, course purchasing, and payment processing via Stripe.

What it does

  • 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

Business rules

  • A user can only purchase the same course once (enforced at the database level)
  • Payment status is either PENDING (intent created, awaiting confirmation) or PAID (confirmed by Stripe webhook)
  • userId is always derived from the authenticated JWT token — it cannot be supplied by the client

Tech Stack

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

Architecture

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)                  │
└──────────────────────────────────────────────────┘

Layer responsibilities

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.

Project structure

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

Stripe Architecture

Overview

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-by-step

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.

Webhook signature verification

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.

Stripe objects used

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.

Handled webhook events

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.


Getting Started

Prerequisites

  • Node.js 22+
  • Docker Desktop

1. Clone and install

git clone <repo-url>
cd ts-backend
npm install

2. Configure environment

cp .env.example .env

Fill in the Stripe keys in .env:

STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...

3. Start infrastructure

docker compose up -d

Starts PostgreSQL on localhost:5432 and Redis on localhost:6380.

4. Run migrations

npm run db:migrate

5. Start dev server

npm run dev

Server runs at http://localhost:3000.


API Reference

Base URL: http://localhost:3000/api/v1

Health

GET /health

Auth

POST /auth/register   { email, name, password }
POST /auth/login      { email, password }        → { user, accessToken }

Users (requires Bearer token)

GET /users/me
GET /users/:id

Payments (requires Bearer token)

POST /payment/create   { courseId, amount, currency }   → { paymentId, clientSecret }
POST /payment/webhook  (Stripe webhook — no auth)

Environment Variables

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_...)

Available Scripts

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)

Docker

# Local dev — infrastructure only (PostgreSQL + Redis)
docker compose up -d

# Full stack — app + infrastructure
docker compose -f docker-compose.full.yml up --build

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages