PostgreSQL Persistence and Refund Processing
Add a PostgreSQL database and refund processing to a payment API.
Introduction
30 Second Summary
Every time you buy a coffee with your card, a payment system somewhere records that transaction permanently. If the server restarted and your payment vanished, nobody would trust digital payments.
In this project, you will add PostgreSQL persistence and refund processing to a payment API. You will store payment data in a real database, process refunds through Stripe, and build an audit trail that tracks every status change.
What You'll Build
When you finish, you can create a payment, refund it, and trace every state change through a timeline endpoint that shows exactly what happened and when.
By the end of this project, you'll have:
- A PostgreSQL-backed payment API where you can create a payment, kill the server, restart it, and find the payment still there.
- A working refund endpoint that calls Stripe's Refund API, records the refund in your database atomically using database transactions, and maintains a complete audit trail of every payment lifecycle event.
- Interactive Swagger UI documentation at /api-docs and an integration test suite you can run with npm test to prove the full payment and refund flow works end-to-end.
- Secret Mission: Add partial refund support with cumulative tracking and validation that prevents over-refunding.
Are there any prerequisites?
This project builds on a basic Stripe payment API project where you created a POST /payments endpoint with Payment Intents, webhook verification, and Zod validation. You should have Node.js, Docker, and your Stripe test keys ready before starting.
Before We Start
Before We Start...
Before we dive in, let's lock in what you're about to build and why it matters. Your first payment API worked, but it had a critical limitation that this project solves.
Set Up the Project and PostgreSQL
Your first payment API stored everything in memory. Every time the server restarted, all payment data vanished.
To build a production-grade system, you need a real database that persists data independently of your application process. In this step, you'll scaffold the project, install the packages that power persistence and documentation, and spin up a PostgreSQL instance inside Docker.
In this step, get ready to:
- Create the project directory and install all npm dependencies.
- Launch a PostgreSQL container with Docker Compose.
- Configure environment variables and confirm database connectivity.
Create the project and install dependencies
Every Node.js project starts with a directory and a package.json that tracks its dependencies. You'll create both, then install the libraries that handle database connections, API documentation, and testing.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir payment-service
cd payment-service
- Initialize a new Node.js project by running:
npm init -y
You should see the generated package.json contents printed in your terminal.
- Install the main project dependencies by running:
npm install express stripe pg swagger-jsdoc swagger-ui-express dotenv zod
You should see output confirming packages were added.
- Install the development dependencies by running:
npm install -D vitest supertest
What did these installs do?
The main packages handle your core functionality: pg connects to PostgreSQL, stripe calls the Stripe API, swagger-jsdoc and swagger-ui-express generate interactive API docs, dotenv loads environment variables, and zod validates request data.
The dev dependencies (vitest and supertest) power your integration tests. They're only needed during development, not in production.
Now you need to configure your package.json with the correct project metadata and scripts. The generated defaults need updating.
- Open package.json in your editor and replace its entire contents with the following:
{
"name": "payment-service",
"version": "2.0.0",
"description": "Production-grade payment API with PostgreSQL persistence and refund processing",
"main": "src/server.js",
"scripts": {
"start": "node src/server.js",
"migrate": "node src/migrations/run.js",
"test": "vitest run"
},
"dependencies": {
"dotenv": "16.4.7",
"express": "4.21.2",
"pg": "8.22.0",
"stripe": "22.3.1",
"swagger-jsdoc": "6.2.8",
"swagger-ui-express": "5.0.1",
"zod": "3.24.2"
},
"devDependencies": {
"supertest": "7.2.2",
"vitest": "4.1.10"
}
}
What does this configure?
- The scripts section defines shortcuts: npm start runs the server, npm run migrate applies database schema changes, and npm test runs your test suite.
- Version 2.0.0 signals this is the second iteration of your payment service, now with database persistence.
- Pinning exact dependency versions ensures every learner gets the same behavior regardless of when they run the project.
- Save package.json.
- Re-install to sync your node_modules with the exact pinned versions by running:
npm install
You should see output confirming the dependency tree resolved successfully.
Seeing version conflicts?
If npm reports peer dependency warnings, that's normal and won't affect your project. If you see actual errors about missing packages, make sure you saved package.json before running npm install.
Help me troubleshoot npm install errors
Launch PostgreSQL with Docker Compose
Docker Compose lets you define your database as code. Instead of manually installing PostgreSQL on your machine, you declare what you want in a YAML file and Docker handles the rest.
- Create a file called docker-compose.yml in your payment-service folder and paste the following:
services:
postgres:
image: postgres:16-alpine
container_name: payments-postgres
environment:
POSTGRES_USER: payments
POSTGRES_PASSWORD: payments_dev
POSTGRES_DB: payments_db
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
What does this configure?
- The postgres:16-alpine image gives you PostgreSQL 16 in a lightweight Alpine Linux container.
- The environment block sets the database name, username, and password that PostgreSQL creates on first start.
- Port 5432 is mapped from the container to your host machine so your Node.js app can connect.
- The pgdata named volume persists database files even if you stop and restart the container.
- Save docker-compose.yml.
- Start the PostgreSQL container by running:
docker compose up -d
The -d flag runs the container in the background so you keep your terminal free.
Before you verify, what do you think will happen when you try to run a SQL query against the container? Will it succeed immediately, or might the database need time to initialize?
- Verify the database is accepting connections by running:
docker exec -it payments-postgres psql -U payments -d payments_db -c "SELECT 1"
You should see a table with a single column and the value 1. This confirms PostgreSQL is running and accepting queries.
Seeing "No such container" or connection refused?
Run docker compose ps to check if the container started. If its status shows "Exited", run docker compose logs to see what went wrong.
If the container is running but the query fails, wait 5 seconds and try again. PostgreSQL needs a moment to initialize on first launch.
Help me debug my Docker PostgreSQL container
Configure environment variables and test connectivity
Your application needs to know the database connection string and your Stripe keys. Environment variables keep these values out of your source code so you never accidentally commit secrets.
- Create a file called .env in your payment-service folder and paste the following:
DATABASE_URL=postgresql://payments:payments_dev@localhost:5432/payments_db
STRIPE_SECRET_KEY=sk_test_your-stripe-test-key-here
STRIPE_WEBHOOK_SECRET=whsec_your-webhook-secret-here
PORT=3000
What do these variables do?
- The DATABASE_URL matches the credentials you set in docker-compose.yml. It tells your app how to connect to PostgreSQL.
- Replace sk_test_your-stripe-test-key-here with your actual Stripe test secret key from the Stripe Dashboard.
- The STRIPE_WEBHOOK_SECRET will be used later when you set up webhook verification. Leave the placeholder for now.
- Save .env.
- Replace sk_test_your-stripe-test-key-here with your actual Stripe test secret key before moving on.
Now you need a database connection module that the rest of your application can import. This creates a reusable connection pool.
- Create the src directory by running:
mkdir src
- Create a file called src/db.js and paste the following:
const { Pool } = require('pg');
// Create a connection pool using the DATABASE_URL from .env
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
module.exports = pool;
What is a connection pool?
A Pool maintains a set of reusable database connections. Instead of opening and closing a connection for every query (which is slow), the pool hands out existing connections and recycles them. This is how production apps handle database traffic efficiently.
- Save src/db.js.
Before you run the connectivity test, do you think it will succeed? Your .env has the connection string and the Docker container is running. What could still go wrong?
- Test that your application can connect to PostgreSQL by running:
node -e "require('dotenv').config(); const pool = require('./src/db'); pool.query('SELECT NOW()').then(r => { console.log('Connected:', r.rows[0].now); pool.end(); }).catch(e => { console.error(e.message); pool.end(); })"
You should see output like Connected: 2026-07-16T... with the current timestamp. This confirms your Node.js application can reach PostgreSQL through the connection pool.
Seeing a connection error?
Make sure your .env file's DATABASE_URL matches the credentials in docker-compose.yml exactly: username payments, password payments_dev, database payments_db.
If you see MODULE_NOT_FOUND, confirm you are running the command from inside the payment-service directory where node_modules exists.
Help me debug my database connection
✔️ Awesome, I've got everything!
Great. Double check you've saved both .env and src/db.js. You're ready to build the schema.
ⓧ I'd like to double check the full code
Here are the key files you should have at this point. Confirm each one matches:
Your package.json:
{
"name": "payment-service",
"version": "2.0.0",
"description": "Production-grade payment API with PostgreSQL persistence and refund processing",
"main": "src/server.js",
"scripts": {
"start": "node src/server.js",
"migrate": "node src/migrations/run.js",
"test": "vitest run"
},
"dependencies": {
"dotenv": "16.4.7",
"express": "4.21.2",
"pg": "8.22.0",
"stripe": "22.3.1",
"swagger-jsdoc": "6.2.8",
"swagger-ui-express": "5.0.1",
"zod": "3.24.2"
},
"devDependencies": {
"supertest": "7.2.2",
"vitest": "4.1.10"
}
}
Your docker-compose.yml:
services:
postgres:
image: postgres:16-alpine
container_name: payments-postgres
environment:
POSTGRES_USER: payments
POSTGRES_PASSWORD: payments_dev
POSTGRES_DB: payments_db
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
Your .env:
DATABASE_URL=postgresql://payments:payments_dev@localhost:5432/payments_db
STRIPE_SECRET_KEY=sk_test_your-stripe-test-key-here
STRIPE_WEBHOOK_SECRET=whsec_your-webhook-secret-here
PORT=3000
Your src/db.js:
const { Pool } = require('pg');
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
module.exports = pool;
Your development environment is locked in. PostgreSQL is running, your dependencies are installed, and your code can talk to the database. Next up, you'll design the database schema and make payments survive a server restart.
Build the Schema, Migrations, and Payment Persistence
In the first version of your payment API, every payment vanished the moment the server stopped. Restart the process and all your data was gone.
This step fixes that by introducing versioned SQL migrations and PostgreSQL persistence. You will define the database schema in a migration file, write a runner that applies it, then wire Express endpoints to read and write from PostgreSQL so payments survive across restarts.
In this step, get ready to:
- Define the customers and payments tables in a versioned SQL migration.
- Build a migration runner that tracks which files have been applied.
- Create Express endpoints that persist payments in PostgreSQL and survive server restarts.
Write the database schema and run migrations
Database migrations are numbered SQL files that define your schema. A migration runner executes them in order and tracks which ones have already been applied, so your schema evolves repeatably.
- Create the src/migrations directory by running:
mkdir -p src/migrations
- Create src/migrations/001_initial_schema.sql with the following schema:
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
CREATE TABLE customers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL,
stripe_customer_id TEXT UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE payments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
customer_id UUID NOT NULL REFERENCES customers(id),
amount_cents BIGINT NOT NULL CHECK (amount_cents > 0),
currency CHAR(3) NOT NULL DEFAULT 'usd',
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'succeeded', 'failed', 'refunded', 'partially_refunded')),
stripe_payment_intent_id TEXT UNIQUE,
idempotency_key TEXT UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_payments_customer ON payments(customer_id);
CREATE INDEX idx_payments_status ON payments(status);
CREATE INDEX idx_payments_stripe_pi ON payments(stripe_payment_intent_id);
What does this schema do?
The customers table stores each customer's email and their Stripe customer ID so you can link payments to people.
The payments table stores the amount, currency, status, and Stripe PaymentIntent ID. The CHECK constraint on status ensures only valid values can be stored. The indexes speed up queries by customer, status, and Stripe ID.
- Now create the migration runner. Add the first part of src/migrations/run.js that loads dependencies and sets up migration tracking:
require('dotenv').config();
const fs = require('fs');
const path = require('path');
const pool = require('../db');
async function runMigrations() {
const migrationsDir = path.join(__dirname);
const files = fs.readdirSync(migrationsDir)
.filter(f => f.endsWith('.sql'))
.sort();
const client = await pool.connect();
try {
await client.query(`
CREATE TABLE IF NOT EXISTS schema_migrations (
filename TEXT PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`);
- Continue in the same file, adding the migration loop and error handling directly below the previous code:
for (const file of files) {
const { rows } = await client.query(
'SELECT filename FROM schema_migrations WHERE filename = $1',
[file]
);
if (rows.length === 0) {
const sql = fs.readFileSync(path.join(migrationsDir, file), 'utf8');
await client.query('BEGIN');
await client.query(sql);
await client.query(
'INSERT INTO schema_migrations (filename) VALUES ($1)',
[file]
);
await client.query('COMMIT');
console.log(`Applied: ${file}`);
} else {
console.log(`Skipped: ${file} (already applied)`);
}
}
console.log('All migrations complete.');
} catch (err) {
await client.query('ROLLBACK');
console.error('Migration failed:', err.message);
process.exit(1);
} finally {
client.release();
await pool.end();
}
}
runMigrations();
How does this runner work?
It creates a schema_migrations table to track which files have been applied. For each .sql file, it checks whether the filename already exists in the tracking table.
If the file is new, it executes the SQL inside a transaction and records the filename. If it fails, the whole migration rolls back so your database stays consistent.
- Save both files, then run the migrations:
node src/migrations/run.js
What do you expect to see in the output?
You should see:
Applied: 001_initial_schema.sql
All migrations complete.
Seeing a connection error?
Make sure your PostgreSQL container is running. Run docker compose up -d from your project directory and try again.
Check that the DATABASE_URL in your .env file matches the credentials in docker-compose.yml exactly.
help me debug my migration connection error
Build the Express app and payment endpoints
With the schema in place, you need an Express application that can receive payment requests and store them in PostgreSQL. You will start with a minimal app to confirm the server boots, then add the full payment routes.
- Create the src/routes directory:
mkdir -p src/routes
- Create src/app.js with a minimal Express setup and health check:
const express = require('express');
const app = express();
// Parse JSON request bodies
app.use(express.json());
// Health check endpoint
app.get('/health', (req, res) => {
res.json({ status: 'ok' });
});
module.exports = app;
- Create src/server.js to start the Express app:
require('dotenv').config();
const app = require('./app');
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Payment service running on port ${PORT}`);
console.log(`Swagger docs: http://localhost:${PORT}/api-docs`);
});
- Save both files, then start the server to confirm Express boots:
node src/server.js
You should see Payment service running on port 3000 in the terminal.
- Stop the server with Ctrl+C.
Now you know Express is working. Time to add the payment endpoints.
- Create src/routes/payments.js starting with the imports and validation schema:
const express = require('express');
const { z } = require('zod');
const Stripe = require('stripe');
const pool = require('../db');
const router = express.Router();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
// Validate incoming payment requests
const createPaymentSchema = z.object({
email: z.string().email(),
amount_cents: z.number().int().positive(),
currency: z.string().length(3).default('usd'),
payment_method: z.string(),
idempotency_key: z.string().optional(),
});
- Below the schema, add the first half of the POST handler that validates input and finds or creates a customer:
router.post('/', async (req, res) => {
const parsed = createPaymentSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
const { email, amount_cents, currency, payment_method, idempotency_key } = parsed.data;
const client = await pool.connect();
try {
await client.query('BEGIN');
let customerResult = await client.query(
'SELECT id, stripe_customer_id FROM customers WHERE email = $1',
[email]
);
let customerId;
let stripeCustomerId;
if (customerResult.rows.length === 0) {
const stripeCustomer = await stripe.customers.create({ email });
const insertResult = await client.query(
'INSERT INTO customers (email, stripe_customer_id) VALUES ($1, $2) RETURNING id',
[email, stripeCustomer.id]
);
customerId = insertResult.rows[0].id;
stripeCustomerId = stripeCustomer.id;
} else {
customerId = customerResult.rows[0].id;
stripeCustomerId = customerResult.rows[0].stripe_customer_id;
}
- Continue directly below, adding the Stripe PaymentIntent creation, database insert, and error handling:
const paymentIntent = await stripe.paymentIntents.create({
amount: amount_cents,
currency,
customer: stripeCustomerId,
payment_method,
confirm: true,
automatic_payment_methods: {
enabled: true,
allow_redirects: 'never',
},
}, {
idempotencyKey: idempotency_key,
});
const status = paymentIntent.status === 'succeeded' ? 'succeeded' : 'pending';
const paymentResult = await client.query(
`INSERT INTO payments (customer_id, amount_cents, currency, status, stripe_payment_intent_id, idempotency_key)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, customer_id, amount_cents, currency, status, stripe_payment_intent_id, created_at`,
[customerId, amount_cents, currency, status, paymentIntent.id, idempotency_key]
);
const payment = paymentResult.rows[0];
await client.query('COMMIT');
res.status(201).json({ payment });
} catch (err) {
await client.query('ROLLBACK');
if (err.type === 'StripeCardError') {
return res.status(402).json({ error: err.message });
}
console.error('Payment creation failed:', err.message);
res.status(500).json({ error: 'Payment processing failed' });
} finally {
client.release();
}
});
- Finally, add the GET handler to retrieve a payment by ID, and export the router:
router.get('/:id', async (req, res) => {
const { rows } = await pool.query(
`SELECT p.*, c.email as customer_email
FROM payments p
JOIN customers c ON p.customer_id = c.id
WHERE p.id = $1`,
[req.params.id]
);
if (rows.length === 0) {
return res.status(404).json({ error: 'Payment not found' });
}
res.json({ payment: rows[0] });
});
module.exports = router;
What does this payment flow do?
The POST handler validates the request with Zod, then starts a database transaction. It finds an existing customer by email or creates a new one in both Stripe and PostgreSQL.
It creates a Stripe PaymentIntent with confirm: true so the charge happens immediately using the test card. Then it inserts the payment into PostgreSQL and commits the transaction. If anything fails, the entire transaction rolls back.
- Now update src/app.js to import and mount the payments router. Find this line:
const express = require('express');
- Replace the entire file with this updated version that imports and mounts the payments router:
const express = require('express');
const paymentsRouter = require('./routes/payments');
const app = express();
// Parse JSON request bodies
app.use(express.json());
// Mount payment routes
app.use('/payments', paymentsRouter);
// Health check endpoint
app.get('/health', (req, res) => {
res.json({ status: 'ok' });
});
module.exports = app;
- Save src/routes/payments.js and src/app.js.
Module not found error?
If you see Cannot find module './routes/payments' when starting the server, make sure src/routes/payments.js exists and the file is saved.
Double-check that module.exports = router is at the bottom of payments.js.
help me fix a module not found error in my Express payments app
Test that payments persist across restarts
The whole point of adding PostgreSQL is that data survives when the server goes down. You will create a payment, kill the server, restart it, and prove the data is still there.
- Start the server:
node src/server.js
- Open a second terminal and create a payment using curl with Stripe's test card token pm_card_visa:
curl -s -X POST http://localhost:3000/payments \
-H "Content-Type: application/json" \
-d '{
"email": "test@example.com",
"amount_cents": 5000,
"currency": "usd",
"payment_method": "pm_card_visa"
}' | json_pp
You should see a JSON response with a payment object containing an id, status: "succeeded", and amount_cents: "5000".
- Copy the id value from the response. You will use it to query the payment after a restart.
- Switch back to the terminal running the server and press Ctrl+C to stop it.
- Start the server again:
node src/server.js
Before you query the payment, what do you think will happen? Will the payment still be there, or did it vanish when the server stopped?
- In your second terminal, query the payment by replacing the ID with the one you copied:
curl -s http://localhost:3000/payments/[[PAYMENT_ID="your-payment-id-from-above"]] | json_pp
The payment is back. Same ID, same amount, same status. The data survived because it lives in PostgreSQL, not in-process memory.
Why does this work?
Your first project stored payments in a JavaScript array. When the process died, the array disappeared. Now every payment is an INSERT into PostgreSQL. The database persists to the Docker volume you defined in docker-compose.yml, which survives container restarts too.
🙋♀️ Getting a 404 when querying the payment?
Make sure you copied the full UUID from the POST response. UUIDs look like a1b2c3d4-e5f6-7890-abcd-ef1234567890.
If the POST itself returned a 500 error, check that your STRIPE_SECRET_KEY in .env is a valid test key starting with sk_test_.
help me debug my payment persistence test
✔️ Awesome, I've got everything!
Great. Make sure you have saved all files: src/migrations/001_initial_schema.sql, src/migrations/run.js, src/app.js, src/routes/payments.js, and src/server.js.
ⓧ I'd like to double check the full code
Your src/routes/payments.js is too long to show in one block. Scroll up and confirm these four sections are present in order:
- Imports, router setup, and createPaymentSchema Zod object.
- POST / handler opening: validation, BEGIN, customer lookup/creation.
- POST / handler continuation: paymentIntents.create, INSERT into payments, COMMIT, error handling.
- GET /:id handler with JOIN query, plus module.exports = router.
Here is your src/app.js:
const express = require('express');
const paymentsRouter = require('./routes/payments');
const app = express();
// Parse JSON request bodies
app.use(express.json());
// Mount payment routes
app.use('/payments', paymentsRouter);
// Health check endpoint
app.get('/health', (req, res) => {
res.json({ status: 'ok' });
});
module.exports = app;
Here is your src/server.js:
require('dotenv').config();
const app = require('./app');
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Payment service running on port ${PORT}`);
console.log(`Swagger docs: http://localhost:${PORT}/api-docs`);
});
Here is your src/migrations/run.js:
require('dotenv').config();
const fs = require('fs');
const path = require('path');
const pool = require('../db');
async function runMigrations() {
const migrationsDir = path.join(__dirname);
const files = fs.readdirSync(migrationsDir)
.filter(f => f.endsWith('.sql'))
.sort();
const client = await pool.connect();
try {
await client.query(`
CREATE TABLE IF NOT EXISTS schema_migrations (
filename TEXT PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`);
for (const file of files) {
const { rows } = await client.query(
'SELECT filename FROM schema_migrations WHERE filename = $1',
[file]
);
if (rows.length === 0) {
const sql = fs.readFileSync(path.join(migrationsDir, file), 'utf8');
await client.query('BEGIN');
await client.query(sql);
await client.query(
'INSERT INTO schema_migrations (filename) VALUES ($1)',
[file]
);
await client.query('COMMIT');
console.log(`Applied: ${file}`);
} else {
console.log(`Skipped: ${file} (already applied)`);
}
}
console.log('All migrations complete.');
} catch (err) {
await client.query('ROLLBACK');
console.error('Migration failed:', err.message);
process.exit(1);
} finally {
client.release();
await pool.end();
}
}
runMigrations();
Your payments now live in PostgreSQL. Next up, you will add refund processing so you can return money through Stripe and record the refund in your database.
Add Refund Processing with Stripe
Your API can create payments, but real payment systems must handle the opposite direction too: giving money back. Refunds are one of the most critical operations in any payment service.
In this step, you will call Stripe's Refund API, persist the refund in your database, and update the payment status. The implementation is intentionally simple. You will discover its limitations firsthand at the end.
In this step, get ready to:
- Create a refunds migration and apply it to PostgreSQL.
- Implement a refund endpoint that calls Stripe's Refund API.
- Test the refund and identify what's missing from this approach.
Create the refunds migration
Before you can store refund data, you need a table for it. A new SQL migration file will define the refunds table with references back to the payment.
- Create a new file src/migrations/002_add_refunds.sql in your migrations folder and paste the following SQL:
CREATE TABLE refunds (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
payment_id UUID NOT NULL REFERENCES payments(id),
amount_cents BIGINT NOT NULL CHECK (amount_cents > 0),
reason TEXT CHECK (reason IN ('duplicate', 'fraudulent', 'requested_by_customer')),
stripe_refund_id TEXT UNIQUE,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'succeeded', 'failed')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_refunds_payment ON refunds(payment_id);
What does this migration do?
The refunds table stores one row per refund attempt. Each row links to a payment via payment_id and records the Stripe refund ID so you can reconcile.
The reason column only accepts Stripe's three official refund reasons: duplicate, fraudulent, or requested_by_customer. The index on payment_id speeds up lookups when you query all refunds for a given payment.
- Save the file, then apply the migration by running:
node src/migrations/run.js
You should see output showing the new migration applied.
Seeing "Migration failed"?
Check that your PostgreSQL container is still running with docker compose ps. If it stopped, restart it with docker compose start and retry.
Also confirm the SQL file is saved in the correct location: src/migrations/002_add_refunds.sql. Help me fix my migration error
Implement the refund route handler
The refund endpoint needs to look up the payment, confirm it can be refunded, call Stripe, and record the result. Your src/app.js already mounts the refunds router. You just need to create the route file itself.
- Create a new file src/routes/refunds.js and paste the imports and validation schema:
const express = require('express');
const { z } = require('zod');
const Stripe = require('stripe');
const pool = require('../db');
const router = express.Router();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
// Only allow Stripe's three official refund reasons
const createRefundSchema = z.object({
reason: z.enum(['duplicate', 'fraudulent', 'requested_by_customer']).optional(),
});
What does this setup do?
The schema uses Zod to validate the request body. The only accepted field is an optional reason that must match one of Stripe's allowed values. If the learner sends no body, that is valid too (reason defaults to undefined).
- Below the schema in the same file, add the route handler that validates the request and looks up the payment:
router.post('/:id/refund', async (req, res) => {
const parsed = createRefundSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
const { reason } = parsed.data;
const client = await pool.connect();
try {
// Look up the payment in PostgreSQL
const { rows: paymentRows } = await client.query(
'SELECT * FROM payments WHERE id = $1',
[req.params.id]
);
if (paymentRows.length === 0) {
return res.status(404).json({ error: 'Payment not found' });
}
const payment = paymentRows[0];
// Only succeeded payments can be refunded
if (payment.status !== 'succeeded') {
return res.status(400).json({
error: `Cannot refund payment with status: ${payment.status}`
});
}
What does this section do?
The handler first validates the request body. Then it gets a dedicated database client from the pool and queries for the payment by ID.
The status check prevents refunding a payment that has not yet succeeded or has already been refunded. This is your first line of defense against invalid refund attempts.
- Still in the same file, add the Stripe call, database writes, error handling, and the module export directly below the status check:
// Call Stripe's Refund API
const refund = await stripe.refunds.create({
payment_intent: payment.stripe_payment_intent_id,
reason: reason || undefined,
});
// Insert the refund record into PostgreSQL
const { rows: refundRows } = await client.query(
`INSERT INTO refunds (payment_id, amount_cents, reason, stripe_refund_id, status)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, payment_id, amount_cents, reason, stripe_refund_id, status, created_at`,
[payment.id, payment.amount_cents, reason || null, refund.id, refund.status]
);
// Update the payment status
await client.query(
`UPDATE payments SET status = 'refunded', updated_at = NOW() WHERE id = $1`,
[payment.id]
);
res.json({ refund: refundRows[0] });
} catch (err) {
console.error('Refund failed:', err.message);
res.status(500).json({ error: 'Refund processing failed' });
} finally {
client.release();
}
});
module.exports = router;
What does this section do?
The stripe.refunds.create() call hits Stripe's POST /v1/refunds endpoint with the payment_intent ID. Stripe processes the refund and returns a refund object.
After Stripe confirms the refund, the handler inserts a row into the refunds table and updates the payment status to refunded. Notice there is no BEGIN/COMMIT wrapping these writes. This is intentional for now.
- Save src/routes/refunds.js.
✔️ Awesome, I've got everything!
Great. Double check you have saved the file before continuing.
ⓧ I'd like to double check the full code
Your src/routes/refunds.js should contain the three sections you pasted above, in order:
- Imports, router setup, and the createRefundSchema Zod definition.
- The router.post('/:id/refund', ...) opening with validation and the payment lookup.
- The Stripe refund call, database INSERT and UPDATE, error handling, and module.exports = router.
Server won't start after adding the file?
Check for typos in the require paths. Make sure the file is saved at src/routes/refunds.js (not src/refunds.js).
If you see a syntax error, confirm you pasted all three chunks in sequence with no duplicated or missing lines. Help me debug my refunds.js file
Test the refund and identify what is missing
The route is wired and the migration is applied. Time to restart the server and run the full refund flow.
- Stop your running server with Ctrl+C, then start it again by running:
node src/server.js
- First, create a test payment by running this curl command:
curl -s -X POST http://localhost:3000/payments \
-H "Content-Type: application/json" \
-d '{
"email": "refund-test@example.com",
"amount_cents": 5000,
"currency": "usd",
"payment_method": "pm_card_visa"
}'
You should see a JSON response with a payment object. Copy the id field from the response. You need it for the refund request.
- Now refund that payment by running (replace the payment ID with yours):
curl -s -X POST http://localhost:3000/payments/[[PAYMENT_ID="paste-your-payment-id-here"]]/refund \
-H "Content-Type: application/json" \
-d '{"reason": "requested_by_customer"}'
Before you look at the response, predict: what status do you think Stripe returned for this refund?
You should see a 200 response with the refund details, including "status": "succeeded". The refund went through.
What just happened?
Your endpoint looked up the payment in PostgreSQL, confirmed it had status succeeded, called Stripe's POST /v1/refunds with the payment_intent ID, stored the refund row, and updated the payment status.
You can verify this in your Stripe Dashboard (Test Mode) too. Open it in your browser, navigate to Payments, and you will see the $50.00 payment marked as refunded.
- Now query the payment to see what your database actually recorded:
curl -s http://localhost:3000/payments/[[PAYMENT_ID="paste-your-payment-id-here"]]
Ask yourself: if you need to investigate this refund in 3 months, what does your database tell you?
The answer is just a status field that says refunded. No timestamp of when the status changed. No record of who requested it. No trail of what the previous status was.
Why is this a problem?
There are two gaps in this implementation. First, there is no history. You cannot answer "when did this payment transition from succeeded to refunded?"
Second, there is no atomicity. The Stripe call and the two database writes happen independently. If your server crashes after the Stripe call succeeds but before the database UPDATE runs, you have a refund in Stripe but status = 'succeeded' in your database. Your data is now inconsistent.
The next step fixes both problems with a payment_events audit trail and database transactions.
Your refund endpoint works, but it is fragile. Next up, you will add a payment_events audit trail and wrap everything in database transactions so your data stays consistent no matter what.
Add the Audit Trail, Transactions, and Webhooks
In Step 3 you refunded a payment and it worked. But ask yourself: if you need to investigate that refund in 3 months, what does your database tell you?
The answer is just a status field that says "refunded." There is no timestamp for the transition, no record of what the previous status was, and no trail you could audit. Worse, the refund handler has no transaction wrapping. If the server crashes between the Stripe call and the database write, Stripe has processed the refund but your database still says "succeeded." This step fixes both problems with a payment_events table, database transactions, and a webhook handler that treats Stripe as the source of truth.
In this step, get ready to:
- Create a payment_events migration and an events timeline endpoint.
- Wrap payment and refund operations in database transactions with audit logging.
- Handle Stripe webhooks to reconcile payment state.
Create the payment events migration and run it
Every status change in a payment's lifecycle needs its own record. The payment_events table captures what happened, when it happened, and who reported it (your API or a Stripe webhook).
- Create a new file src/migrations/003_payment_events.sql with the following SQL:
CREATE TABLE payment_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
payment_id UUID NOT NULL REFERENCES payments(id),
event_type TEXT NOT NULL,
source TEXT NOT NULL CHECK (source IN ('api', 'webhook', 'system')),
from_status TEXT,
to_status TEXT,
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_payment_events_payment ON payment_events(payment_id);
CREATE INDEX idx_payment_events_created ON payment_events(created_at);
What does this migration do?
Each row in payment_events is one state transition. The source column tracks whether the event came from your API code or from a Stripe webhook. The metadata JSONB column stores context like Stripe IDs without needing extra columns.
The two indexes speed up the queries you'll run most often: all events for a specific payment, and events sorted by time.
- Run the migration by executing this command:
node src/migrations/run.js
You should see Applied: 003_payment_events.sql in the output. The other two migrations show as skipped since they were already applied.
Seeing a migration error?
If you see a connection error, confirm your PostgreSQL container is still running with docker compose ps. If the container stopped, run docker compose start to bring it back.
If the migration says the table already exists, you can safely ignore it. The migration runner tracks applied files in the schema_migrations table.
Help me debug my migration error.
- Now add the events timeline endpoint to src/routes/payments.js. Place this route directly above module.exports = router; at the bottom of the file:
router.get('/:id/events', async (req, res) => {
try {
// Confirm the payment exists before returning events
const { rows: paymentRows } = await pool.query(
'SELECT id FROM payments WHERE id = $1',
[req.params.id]
);
if (paymentRows.length === 0) {
return res.status(404).json({ error: 'Payment not found' });
}
// Return all events for this payment in chronological order
const { rows: events } = await pool.query(
`SELECT id, payment_id, event_type, source, from_status, to_status, metadata, created_at
FROM payment_events
WHERE payment_id = $1
ORDER BY created_at ASC`,
[req.params.id]
);
res.json({ events });
} catch (err) {
console.error('Failed to fetch payment events:', err.message);
res.status(500).json({ error: 'Failed to retrieve payment events' });
}
});
What does this route do?
The handler first confirms the payment exists. If it does not, the route returns a 404 so you can distinguish "no events yet" from "invalid payment ID." Then it queries payment_events for all rows matching that payment_id, ordered by created_at ASC so the oldest event comes first.
The SELECT returns every column from the migration: id, payment_id, event_type, source, from_status, to_status, metadata, and created_at.
- Save src/routes/payments.js.
Refactor the refund handler with transactions and event recording
The current refund handler in src/routes/refunds.js writes to the database without a transaction. If the server crashes between the Stripe call and the status update, your data is inconsistent. You will now wrap the entire operation in a BEGIN/COMMIT/ROLLBACK transaction and add a FOR UPDATE lock to prevent race conditions.
What is SELECT FOR UPDATE?
SELECT ... FOR UPDATE locks the selected row so no other transaction can modify it until yours finishes. This prevents two simultaneous refund requests from both reading status "succeeded" and both trying to refund the same payment.
- In src/routes/refunds.js, find the JSDoc comment and the route handler. Replace the code from router.post('/:id/refund' all the way to the closing }); with the following. First, paste this opening section that adds the transaction and row lock:
router.post('/:id/refund', async (req, res) => {
const parsed = createRefundSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
const { reason } = parsed.data;
const client = await pool.connect();
try {
// Start a transaction so all writes succeed or fail together
await client.query('BEGIN');
// Lock the payment row to prevent concurrent refund attempts
const { rows: paymentRows } = await client.query(
'SELECT * FROM payments WHERE id = $1 FOR UPDATE',
[req.params.id]
);
if (paymentRows.length === 0) {
await client.query('ROLLBACK');
return res.status(404).json({ error: 'Payment not found' });
}
const payment = paymentRows[0];
if (payment.status !== 'succeeded') {
await client.query('ROLLBACK');
return res.status(400).json({ error: `Cannot refund payment with status: ${payment.status}` });
}
const refund = await stripe.refunds.create({
payment_intent: payment.stripe_payment_intent_id,
reason: reason || undefined,
});
What changed so far?
- BEGIN opens the transaction. Every query after this is part of a single atomic unit.
- FOR UPDATE on the SELECT locks the payment row. A second refund request hitting the same payment will wait here until this transaction finishes.
- ROLLBACK before early returns ensures the transaction is properly closed, releasing the lock.
- Directly below the Stripe refund call, paste the database writes that record the refund, update the status, and log the event atomically:
const { rows: refundRows } = await client.query(
`INSERT INTO refunds (payment_id, amount_cents, reason, stripe_refund_id, status)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, payment_id, amount_cents, reason, stripe_refund_id, status, created_at`,
[payment.id, payment.amount_cents, reason || null, refund.id, refund.status]
);
await client.query(
`UPDATE payments SET status = 'refunded', updated_at = NOW() WHERE id = $1`,
[payment.id]
);
// Record the state transition in the audit trail
await client.query(
`INSERT INTO payment_events (payment_id, event_type, source, from_status, to_status, metadata)
VALUES ($1, $2, $3, $4, $5, $6)`,
[payment.id, 'refund.created', 'api', payment.status, 'refunded', JSON.stringify({ stripe_refund_id: refund.id, amount_cents: payment.amount_cents, reason })]
);
// All writes succeeded, commit the transaction
await client.query('COMMIT');
res.json({ refund: refundRows[0] });
} catch (err) {
await client.query('ROLLBACK');
console.error('Refund failed:', err.message);
res.status(500).json({ error: 'Refund processing failed' });
} finally {
client.release();
}
});
What does this section do?
Three writes happen inside the transaction: inserting the refund record, updating the payment status, and appending to payment_events. If any one fails, ROLLBACK in the catch block undoes them all. The database never ends up in a half-refunded state.
The payment_events insert records from_status and to_status so you can reconstruct the full lifecycle later. The source: 'api' tag distinguishes events your code created from those Stripe reported via webhook.
- Also add the @openapi JSDoc comment above the route handler so Swagger can document this endpoint. Place it directly above router.post('/:id/refund':
/**
* @openapi
* /payments/{id}/refund:
* post:
* summary: Refund a payment (full refund)
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* format: uuid
* requestBody:
* content:
* application/json:
* schema:
* type: object
* properties:
* reason:
* type: string
* enum: [duplicate, fraudulent, requested_by_customer]
* responses:
* 200:
* description: Refund processed
* 400:
* description: Payment cannot be refunded
* 404:
* description: Payment not found
*/
What is this comment block?
The @openapi JSDoc annotation is metadata that swagger-jsdoc reads to generate your API documentation. You will see it rendered in Swagger UI in Step 5. For now it just needs to be present above the handler.
- Save src/routes/refunds.js.
- Now update the POST handler in src/routes/payments.js so that payment creation also records an audit event. Find the line await client.query('COMMIT'); inside the POST / handler and add this INSERT directly above it:
// Record the creation event in the audit trail
await client.query(
`INSERT INTO payment_events (payment_id, event_type, source, from_status, to_status, metadata)
VALUES ($1, $2, $3, $4, $5, $6)`,
[payment.id, 'payment.created', 'api', null, status, JSON.stringify({ stripe_payment_intent_id: paymentIntent.id })]
);
Why does this go before COMMIT?
The INSERT into payment_events sits inside the same transaction as the payment INSERT. If either write fails, ROLLBACK undoes both. You never end up with a payment that has no creation event, or an orphaned event pointing at a non-existent payment.
The from_status is null because no prior status existed. The to_status captures the initial state (succeeded or pending depending on Stripe's response).
- Save src/routes/payments.js.
Both endpoints now produce audit trail entries atomically. The POST handler records payment.created and the refund handler records refund.created, each inside their own transaction with BEGIN/COMMIT/ROLLBACK.
✔️ Awesome, I've got everything!
Great. Double check you have saved src/routes/refunds.js and that the @openapi comment sits directly above router.post.
ⓧ I'd like to double check the full code
Your src/routes/refunds.js should contain these sections in order. Scroll up and confirm each is present:
- Imports (express, zod, stripe, pool) and createRefundSchema definition.
- The @openapi JSDoc comment block.
- The router.post('/:id/refund') handler with BEGIN, FOR UPDATE, Stripe call, three database writes, and COMMIT.
- module.exports = router; at the end.
Create the webhook handler and verify the full lifecycle
Your API now writes events with source: 'api' when it creates or refunds a payment. But what if the server crashes before that write completes? Stripe still processed the change. You need a webhook handler that listens for Stripe's confirmation events and reconciles your database independently.
- Create a new file src/routes/webhooks.js and paste the signature verification and event routing:
const express = require('express');
const Stripe = require('stripe');
const pool = require('../db');
const router = express.Router();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
// Stripe sends the raw body for signature verification
router.post('/', express.raw({ type: 'application/json' }), async (req, res) => {
const signature = req.headers['stripe-signature'];
let event;
try {
// Verify the event came from Stripe using the endpoint secret
event = stripe.webhooks.constructEvent(
req.body,
signature,
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
console.error('Webhook signature verification failed:', err.message);
return res.status(400).json({ error: 'Invalid signature' });
}
const client = await pool.connect();
- Directly below the signature verification, paste the event handlers that update payment state and record audit events:
try {
await client.query('BEGIN');
switch (event.type) {
case 'payment_intent.succeeded': {
const paymentIntent = event.data.object;
// Lock the row to prevent conflicts with API writes
const { rows } = await client.query(
`SELECT * FROM payments WHERE stripe_payment_intent_id = $1 FOR UPDATE`,
[paymentIntent.id]
);
if (rows.length > 0) {
const payment = rows[0];
await client.query(
`UPDATE payments SET status = 'succeeded', updated_at = NOW() WHERE id = $1`,
[payment.id]
);
await client.query(
`INSERT INTO payment_events (payment_id, event_type, source, from_status, to_status, metadata)
VALUES ($1, $2, $3, $4, $5, $6)`,
[payment.id, 'payment_intent.succeeded', 'webhook', payment.status, 'succeeded', JSON.stringify({ stripe_event_id: event.id })]
);
}
break;
}
- Still in the same file, paste the refund handler case and the closing logic directly below the previous block:
case 'refund.created': {
const refund = event.data.object;
const { rows } = await client.query(
`SELECT * FROM payments WHERE stripe_payment_intent_id = $1 FOR UPDATE`,
[refund.payment_intent]
);
if (rows.length > 0) {
const payment = rows[0];
await client.query(
`UPDATE payments SET status = 'refunded', updated_at = NOW() WHERE id = $1`,
[payment.id]
);
await client.query(
`INSERT INTO payment_events (payment_id, event_type, source, from_status, to_status, metadata)
VALUES ($1, $2, $3, $4, $5, $6)`,
[payment.id, 'refund.created', 'webhook', payment.status, 'refunded', JSON.stringify({ stripe_event_id: event.id, stripe_refund_id: refund.id })]
);
}
break;
}
default:
console.log(`Unhandled webhook event type: ${event.type}`);
}
await client.query('COMMIT');
res.json({ received: true });
} catch (err) {
await client.query('ROLLBACK');
console.error('Webhook processing failed:', err.message);
res.status(500).json({ error: 'Webhook processing failed' });
} finally {
client.release();
}
});
module.exports = router;
What does this handler do?
The route uses express.raw() to receive the request body as a raw buffer. Stripe signs the raw payload, so parsing it as JSON first would break signature verification.
After verification passes, the handler uses a switch on event.type. For both payment_intent.succeeded and refund.created, it locks the payment row with FOR UPDATE, updates the status, and inserts a payment_events row with source: 'webhook'.
- Save src/routes/webhooks.js.
The webhook route needs to be mounted in src/app.js before the express.json() middleware. If express.json() runs first, it parses the body and breaks Stripe's signature verification.
- In src/app.js, find the line app.use(express.json()); and add this line directly above it:
// Mount webhook route before JSON parsing (Stripe needs the raw body)
app.use('/webhooks', require('./routes/webhooks'));
- Save src/app.js.
Why mount before express.json()?
Express middleware runs in declaration order. If express.json() comes first, it consumes and parses the request body. When Stripe's constructEvent() then tries to verify the signature against the raw bytes, it fails because the body has already been transformed into a JavaScript object.
💡 Why webhooks as a source of truth?
Your API writes events with source: 'api' immediately. But if the server crashes before recording the status, or Stripe's processing is delayed, the webhook fills the gap. Webhook events arrive with source: 'webhook' so you can always distinguish which system reported the change.
The handler verifies Stripe's cryptographic signature before trusting the payload. It also uses FOR UPDATE and a transaction when writing, just like your refund handler.
Now let's verify the full payment lifecycle produces a complete audit trail.
- Restart your server to pick up all the changes (refunds, payments, webhooks, and app.js):
node src/server.js
Before you run the next command, predict: what events do you expect to see in the timeline after creating a payment?
- Create a test payment by running:
curl -s -X POST http://localhost:3000/payments \
-H "Content-Type: application/json" \
-d '{"email": "audit@test.com", "amount_cents": 4000, "currency": "usd", "payment_method": "pm_card_visa"}' | json_pp
You should see a 201 response with your payment object. Copy the id value from the response.
- Refund the payment using the ID you copied:
curl -s -X POST http://localhost:3000/payments/PAYMENT_ID_HERE/refund \
-H "Content-Type: application/json" \
-d '{"reason": "requested_by_customer"}' | json_pp
You should see a 200 response with the refund details and a status of succeeded.
- Now query the events timeline for that payment:
curl -s http://localhost:3000/payments/PAYMENT_ID_HERE/events | json_pp
You should see an array with at least two entries: one with event_type: "payment.created" and one with event_type: "refund.created". Each entry has a created_at timestamp, source: "api", and the from_status/to_status transition. This is your complete audit trail.
Events array is empty?
If GET /payments/:id/events returns an empty array, the payment_events table may not exist yet. Run node src/migrations/run.js again and confirm you see "Applied: 003_payment_events.sql" in the output.
If the migration was already applied but events are still empty, the payment you are querying was created before the migration existed. Create a fresh payment and try again.
Help me debug why my payment events are empty.
Your payment service now records every state change with timestamps and sources. In the next step, you will document the entire API with Swagger UI and prove it all works with integration tests.
Add Swagger Documentation and Integration Tests
Your payment API handles real money flows: creating payments, processing refunds, and tracking every state change through an audit trail. But right now, the only way someone else can understand your API is by reading your source code.
A portfolio-grade API needs two things your project is currently missing: documentation that others can read, and tests that prove it works. In this step, you will add interactive Swagger UI documentation and write integration tests that exercise the full payment lifecycle against real Stripe Test Mode.
In this step, get ready to:
- Create the Swagger configuration that turns your route annotations into interactive API docs.
- Write integration tests that create payments, issue refunds, and verify the audit trail end-to-end.
- Run the full test suite and confirm everything passes against real Stripe Test Mode.
Create the Swagger configuration
Swagger needs three things to work: a configuration file that defines your API metadata, route annotations that describe each endpoint, and a mount point in src/app.js that serves the interactive docs. You will set up all three now.
- Create a new file called src/swagger.js and add the following configuration:
const swaggerJsdoc = require('swagger-jsdoc');
// Configure OpenAPI 3.0 spec metadata
const options = {
definition: {
openapi: '3.0.0',
info: {
title: 'Payment Service API',
version: '2.0.0',
description: 'Production-grade payment API with PostgreSQL persistence, refund processing, and audit trails',
},
servers: [
{ url: 'http://localhost:3000', description: 'Local development' },
],
},
// Scan all route files for @openapi annotations
apis: ['./src/routes/*.js'],
};
const swaggerSpec = swaggerJsdoc(options);
module.exports = { swaggerSpec };
What does this code do?
swagger-jsdoc reads the @openapi comment blocks in your route files and combines them with the metadata you define here (title, version, server URL) to generate a complete OpenAPI 3.0 specification.
The apis glob pattern './src/routes/*.js' tells it to scan every file in your routes folder for documentation annotations. The generated spec is what Swagger UI renders as an interactive page.
Now update src/app.js to import swagger-ui-express and your new swaggerSpec, then mount Swagger UI at /api-docs. The webhook route must stay above express.json() so Stripe's signature verification still works.
- Open src/app.js and replace its entire contents with the following:
const express = require('express');
const swaggerUi = require('swagger-ui-express');
const { swaggerSpec } = require('./swagger');
const paymentsRouter = require('./routes/payments');
const app = express();
// Mount webhook route before JSON parsing (Stripe needs the raw body)
app.use('/webhooks', require('./routes/webhooks'));
// Parse JSON request bodies
app.use(express.json());
// Serve interactive API documentation
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec));
// Mount payment routes
app.use('/payments', paymentsRouter);
// Health check endpoint
app.get('/health', (req, res) => {
res.json({ status: 'ok' });
});
module.exports = app;
What changed in app.js?
- swagger-ui-express serves the interactive documentation page at /api-docs.
- swaggerSpec is the generated OpenAPI spec from your new src/swagger.js file.
- The webhook route stays above express.json() so Stripe's raw body signature verification is not broken.
The last piece is adding @openapi annotations to src/routes/payments.js so Swagger knows how to document each endpoint. Your refund route already has its annotation from Step 4.
- In src/routes/payments.js, add this JSDoc comment directly above router.post('/', async (req, res):
/**
* @openapi
* /payments:
* post:
* summary: Create a new payment
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [email, amount_cents, payment_method]
* properties:
* email:
* type: string
* format: email
* amount_cents:
* type: integer
* currency:
* type: string
* default: usd
* payment_method:
* type: string
* idempotency_key:
* type: string
* responses:
* 201:
* description: Payment created
* 400:
* description: Invalid request body
* 402:
* description: Card declined
*/
- Add this annotation directly above router.get('/:id', async (req, res):
/**
* @openapi
* /payments/{id}:
* get:
* summary: Get a payment by ID
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* format: uuid
* responses:
* 200:
* description: Payment found
* 404:
* description: Payment not found
*/
- Add this annotation directly above router.get('/:id/events', async (req, res):
/**
* @openapi
* /payments/{id}/events:
* get:
* summary: Get the event timeline for a payment
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* format: uuid
* responses:
* 200:
* description: Events retrieved
* 404:
* description: Payment not found
*/
What about the refund annotation?
You already added the @openapi annotation for POST /payments/{id}/refund in Step 4 when you refactored src/routes/refunds.js. That annotation is already in place and Swagger will pick it up automatically.
- Save src/swagger.js, src/app.js, and src/routes/payments.js.
- Stop your running server with Ctrl+C and restart it by running:
node src/server.js
- Open http://localhost:3000/api-docs in your browser.
You should see an interactive Swagger UI page titled "Payment Service API" with all your endpoints listed: POST /payments, GET /payments/{id}, GET /payments/{id}/events, and POST /payments/{id}/refund.
Seeing a module not found error?
If the server crashes on startup with a module error for swagger-ui-express or swagger-jsdoc, confirm both packages are installed. Run npm install swagger-jsdoc swagger-ui-express to verify.
If the error is about ./swagger, double-check that the filename is exactly src/swagger.js (not swagger.config.js or similar). The import in app.js expects require('./swagger') which resolves to src/swagger.js.
help me debug why my server crashes when trying to load the swagger module
Write the integration test suite
Swagger documents what your API does. Now you need tests that prove it actually works. You will write integration tests that hit your real endpoints, talk to real Stripe Test Mode, and verify the full payment lifecycle.
- Create a new file called vitest.config.js in your project root (the same level as package.json) and add the following:
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
testTimeout: 30000,
},
});
What does this config do?
globals: true makes Vitest test functions available without explicit imports in every test file. testTimeout: 30000 gives each test 30 seconds to complete, which is necessary because your tests make real network calls to Stripe.
- Save vitest.config.js.
- Create a tests folder in your project root, then create tests/payments.test.js and add the test infrastructure:
const { describe, it, expect, beforeAll, afterAll } = require('vitest');
const request = require('supertest');
const app = require('../src/app');
const pool = require('../src/db');
require('dotenv').config();
describe('Payment API Integration Tests', () => {
beforeAll(async () => {
const fs = require('fs');
const path = require('path');
const client = await pool.connect();
try {
await client.query(`CREATE TABLE IF NOT EXISTS schema_migrations (filename TEXT PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW())`);
const migrationsDir = path.join(__dirname, '..', 'src', 'migrations');
const files = fs.readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort();
for (const file of files) {
const { rows } = await client.query('SELECT filename FROM schema_migrations WHERE filename = $1', [file]);
if (rows.length === 0) {
const sql = fs.readFileSync(path.join(migrationsDir, file), 'utf8');
await client.query('BEGIN');
await client.query(sql);
await client.query('INSERT INTO schema_migrations (filename) VALUES ($1)', [file]);
await client.query('COMMIT');
}
}
} finally {
client.release();
}
});
What does beforeAll do here?
Before any test runs, beforeAll ensures the database schema exists by running any unapplied migrations. This makes the test suite self-contained. A fresh database will get all tables created automatically.
- Add the teardown and first test directly below the previous code, still inside the describe block:
afterAll(async () => {
await pool.end();
});
it('creates a payment and persists it', async () => {
const res = await request(app)
.post('/payments')
.send({
email: 'test@example.com',
amount_cents: 5000,
currency: 'usd',
payment_method: 'pm_card_visa',
})
.expect(201);
expect(res.body.payment).toBeDefined();
expect(res.body.payment.amount_cents).toBe('5000');
expect(res.body.payment.status).toBe('succeeded');
const getRes = await request(app)
.get(`/payments/${res.body.payment.id}`)
.expect(200);
expect(getRes.body.payment.id).toBe(res.body.payment.id);
});
});
What does this test prove?
This test creates a real payment through your API using Stripe's test card pm_card_visa, then immediately fetches it back by ID. If the GET returns the same payment, you have proven that data persists in PostgreSQL through the full request cycle.
- Save tests/payments.test.js.
- Stop your running server with Ctrl+C (the tests spin up their own app instance). Run the test suite:
npm test
You should see 1 test passing with a green checkmark.
Test failing with a connection error?
Make sure your Docker PostgreSQL container is still running. Run docker compose ps to check. If it stopped, restart it with docker compose up -d.
Also confirm your .env file has the correct STRIPE_SECRET_KEY (starting with sk_test_). Integration tests hit real Stripe Test Mode, so an invalid key causes failures.
help me debug why my integration test is failing
Add the remaining tests and verify the full suite
One passing test proves the infrastructure works. Now you will add tests for refunds, the audit trail, and edge cases that prove your API handles errors correctly.
- In tests/payments.test.js, find the closing }); at the very end of the file (which closes the describe block). Add the following refund test directly above that line:
it('refunds a payment and records the event', async () => {
const createRes = await request(app)
.post('/payments')
.send({
email: 'refund-test@example.com',
amount_cents: 3000,
currency: 'usd',
payment_method: 'pm_card_visa',
})
.expect(201);
const paymentId = createRes.body.payment.id;
const refundRes = await request(app)
.post(`/payments/${paymentId}/refund`)
.send({ reason: 'requested_by_customer' })
.expect(200);
expect(refundRes.body.refund.status).toBe('succeeded');
expect(refundRes.body.refund.amount_cents).toBe('3000');
const eventsRes = await request(app)
.get(`/payments/${paymentId}/events`)
.expect(200);
expect(eventsRes.body.events.length).toBeGreaterThanOrEqual(2);
const eventTypes = eventsRes.body.events.map(e => e.event_type);
expect(eventTypes).toContain('payment.created');
expect(eventTypes).toContain('refund.created');
});
What does this test prove?
This test exercises the full refund lifecycle: create a payment, refund it, then query the events timeline. The assertions verify that the refund succeeded in Stripe AND that your audit trail captured both the payment.created and refund.created events.
- Add the edge case tests below the refund test (still above the final });):
it('rejects refund on non-succeeded payment', async () => {
const createRes = await request(app)
.post('/payments')
.send({
email: 'double-refund@example.com',
amount_cents: 2000,
currency: 'usd',
payment_method: 'pm_card_visa',
})
.expect(201);
const paymentId = createRes.body.payment.id;
await request(app)
.post(`/payments/${paymentId}/refund`)
.send({})
.expect(200);
await request(app)
.post(`/payments/${paymentId}/refund`)
.send({})
.expect(400);
});
it('returns 404 for non-existent payment', async () => {
await request(app)
.get('/payments/00000000-0000-0000-0000-000000000000')
.expect(404);
});
What do these edge case tests prove?
The first test creates a payment, refunds it once (succeeds), then tries to refund it again. The second attempt should fail with a 400 because the payment status is already refunded. This proves your status validation works.
The second test confirms that requesting a UUID that does not exist returns a proper 404 instead of a server crash.
- Save tests/payments.test.js.
Before you run the tests, how many do you expect to pass? Think about what each test does and whether your current code handles all those cases.
- Run the full test suite:
npm test
All 4 tests should pass with green checkmarks. Each test created real Stripe PaymentIntents and Refunds in Test Mode and verified the results persisted correctly in your PostgreSQL database.
- Restart your server by running:
node src/server.js
- Open http://localhost:3000/api-docs in your browser one more time.
- Click on POST /payments, then click Try it out to confirm you can interact with the documentation.
Tests passing locally but Swagger not loading?
The tests use their own app instance (via supertest) and do not need the server running. Make sure you restarted the server with node src/server.js before trying to access /api-docs in your browser.
help me figure out why Swagger UI is not loading after my tests pass
✔️ Awesome, I've got everything!
Great. Double check you have saved both src/swagger.js, vitest.config.js, and tests/payments.test.js.
ⓧ I'd like to double check the full code
Your tests/payments.test.js should contain these sections in order. Scroll up and confirm each is present:
- Imports (vitest, supertest, app, pool, dotenv).
- describe block opening with beforeAll (runs migrations) and afterAll (closes the pool).
- Test: "creates a payment and persists it" (POSTs with pm_card_visa, then GETs and verifies).
- Test: "refunds a payment and records the event" (creates, refunds, checks events timeline).
- Test: "rejects refund on non-succeeded payment" (double refund returns 400).
- Test: "returns 404 for non-existent payment" (fake UUID returns 404).
- Closing }); for the describe block.
Your src/swagger.js should match this exactly:
const swaggerJsdoc = require('swagger-jsdoc');
const options = {
definition: {
openapi: '3.0.0',
info: {
title: 'Payment Service API',
version: '2.0.0',
description: 'Production-grade payment API with PostgreSQL persistence, refund processing, and audit trails',
},
servers: [
{ url: 'http://localhost:3000', description: 'Local development' },
],
},
apis: ['./src/routes/*.js'],
};
const swaggerSpec = swaggerJsdoc(options);
module.exports = { swaggerSpec };
Your vitest.config.js should match this exactly:
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
testTimeout: 30000,
},
});
Your API is now fully documented and tested. Anyone can open /api-docs and understand your endpoints without reading source code, and your test suite proves the payment lifecycle works end-to-end against real Stripe. Next up, you will take on a Secret Mission to add partial refund support with cumulative tracking.
Secret mission
Partial Refund Support
Real payment systems rarely do full refunds only. A customer returns one item from a three-item order. Can you add partial refund support with cumulative tracking and over-refund prevention?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally using a Docker container, so there are no ongoing costs. Stripe Test Mode objects (PaymentIntents, Refunds) persist in your Stripe test dashboard but cost nothing.
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Docker PostgreSQL container (payments-postgres) with the pgdata named volume.
- Local project folder with source code and node_modules.
Keep everything running
No action needed. Choose this if you are still actively building on the payment service or plan to extend it further.
- Your Docker PostgreSQL container stays running and accepts connections on port 5432.
- The pgdata volume preserves all your customer, payment, refund, and event data between restarts.
- Stripe Test Mode objects remain in your Stripe Dashboard. They cost nothing and do not affect live transactions.
Pause - I'll come back to this later
Shut down the running container to free up memory and port 5432, while keeping your data intact for later.
- Stop the PostgreSQL container by running:
docker compose stop
- Your data stays in the pgdata volume. When you are ready to resume, navigate to your project folder and run:
docker compose start
- Start the API server again with node src/server.js and you are back where you left off.
Delete - I don't want to use this again
Remove all local project resources and start fresh. This is permanent.
- Stop and remove the Docker container and its data volume by running:
docker compose down -v
- This removes the payments-postgres container and deletes the pgdata volume. All database data is gone.
- Delete the project folder. On macOS/Linux:
cd ~/Desktop
rm -rf payment-service
- On Windows:
cd ~\Desktop
rmdir /s /q payment-service
- Stripe Test Mode objects (PaymentIntents and Refunds) remain in your Stripe Dashboard but cost nothing. You can safely ignore them.
Nice Work!
Nice Work!
You just built a production-grade payment service with PostgreSQL persistence, refund processing, a complete audit trail, and interactive API documentation. This is the kind of backend that real fintech teams ship.
You learned how to:
- Design a PostgreSQL payment schema with versioned SQL migrations, and wire Express endpoints to persist customers and payments across server restarts.
- Process refunds through Stripe's Refund API with database transactions ensuring atomicity, and maintain a payment_events audit trail that records every status transition with timestamps and sources.
- Document a full API with interactive Swagger UI using OpenAPI 3.0, and prove the system works end-to-end with integration tests running against real Stripe Test Mode.
- Secret Mission: Added partial refund support with cumulative tracking and validation that prevents over-refunding.
Ready to quiz yourself?