Most checkout flows look like this: customer fills out their details, clicks confirm, gets redirected to a payment page, comes back. Intuitive enough. But there's a structural problem hiding in that sequence, the order gets created the moment the customer clicks confirm, before anyone has actually paid.
That means every abandoned checkout, every failed payment, every browser tab that just gets closed leaves a real record in your database. Over time you accumulate ghost orders, records that were never real transactions, but now have to be tracked, expired, and cleaned up. If your system allocates inventory on order creation, those ghosts are holding stock that can't be sold to anyone else.
The payment-first model flips the sequence. No order gets created until payment is confirmed. Here's what that actually looks like in practice.
The cart snapshot
Instead of persisting an order, you persist a snapshot of everything needed to reconstruct one. This lives alongside the payment session and gets thrown away once payment resolves, one way or the other.
// checkout-session.entity.ts
@Entity()
export class CheckoutSession {
@PrimaryGeneratedColumn()
id: number;
@Column({ unique: true })
session_token: string; // used as the txnid with the gateway
@Column('jsonb')
cart_snapshot: {
items: { product_id: number; variant_id: number; quantity: number; unit_price: number }[];
shipping_address: AddressSnapshot;
shipping_method: string;
shipping_fee: number;
voucher_ids: number[];
};
@Column({ nullable: true })
user_id: number;
@Column({ default: 'pending' })
status: 'pending' | 'paid' | 'failed' | 'expired';
@CreateDateColumn()
created_at: Date;
@Column({ type: 'timestamptz' })
expires_at: Date; // set to ~30 minutes from creation
}
The snapshot stores denormalized data intentionally. Prices, product names, and shipping rates can change. The snapshot captures what the customer agreed to at checkout time, not what the database says now.
Initiating checkout
When the customer confirms their cart, you create a session and send them to the gateway. No order yet.
// checkout.service.ts
async initiateCheckout(userId: number, input: CheckoutInput): Promise<{ checkoutUrl: string; sessionToken: string }> {
// Validate stock availability before creating the session
for (const item of input.items) {
const variant = await this.variantRepo.findOneOrFail({ where: { id: item.variant_id } });
if (variant.stock < item.quantity) {
throw new BadRequestException(`Insufficient stock for ${variant.sku}`);
}
}
const sessionToken = randomUUID();
const expiresAt = addMinutes(new Date(), 30);
await this.sessionRepo.save({
session_token: sessionToken,
user_id: userId,
status: 'pending',
expires_at: expiresAt,
cart_snapshot: {
items: input.items,
shipping_address: input.shipping_address,
shipping_method: input.shipping_method,
shipping_fee: input.shipping_fee,
voucher_ids: input.voucher_ids ?? [],
},
});
// Pass the session token as the transaction reference to the gateway
const checkoutUrl = await this.dragonpayService.initiate({
txnid: sessionToken,
amount: this.computeTotal(input),
description: `Order for user ${userId}`,
email: input.email,
});
return { checkoutUrl, sessionToken };
}
Notice stock is checked here but not reserved. This is a deliberate tradeoff, you avoid holding inventory for sessions that never complete. The downside is that two customers can both get past this check for the last unit. The payment callback handles that race condition explicitly.
The callback: where the order actually gets created
This is where the weight shifts. Instead of flipping a status flag on an existing order, the callback has to reconstruct the full order from the snapshot.
// checkout.service.ts
async handlePaymentCallback(sessionToken: string, gatewayStatus: string): Promise {
const session = await this.sessionRepo.findOne({ where: { session_token: sessionToken } });
if (!session) throw new NotFoundException('Session not found');
if (session.status !== 'pending') return; // idempotency, duplicate callbacks do nothing
if (gatewayStatus !== 'S') { // DragonPay success code
session.status = 'failed';
await this.sessionRepo.save(session);
return;
}
// Re-validate stock at the moment of payment confirmation
for (const item of session.cart_snapshot.items) {
const variant = await this.variantRepo.findOneOrFail({
where: { id: item.variant_id },
lock: { mode: 'pessimistic_write' }, // row-level lock to handle concurrent callbacks
});
if (variant.stock < item.quantity) {
// Payment came in but stock is gone, needs manual resolution or refund trigger
session.status = 'failed';
await this.sessionRepo.save(session);
this.notifyStockConflict(session);
return;
}
variant.stock -= item.quantity;
await this.variantRepo.save(variant);
}
// Now create the order
const order = await this.orderRepo.save({
user_id: session.user_id,
status: 'confirmed',
shipping_address: session.cart_snapshot.shipping_address,
shipping_method: session.cart_snapshot.shipping_method,
shipping_fee: session.cart_snapshot.shipping_fee,
items: session.cart_snapshot.items.map(i => ({
product_id: i.product_id,
variant_id: i.variant_id,
quantity: i.quantity,
unit_price: i.unit_price,
})),
payment_reference: sessionToken,
});
// Apply vouchers if any
if (session.cart_snapshot.voucher_ids.length) {
await this.voucherService.applyToOrder(order.id, session.cart_snapshot.voucher_ids);
}
session.status = 'paid';
await this.sessionRepo.save(session);
}
The idempotency check on line 8 matters a lot. Gateways can fire the same postback more than once, DragonPay especially. Without it, you'd create duplicate orders on retry. The session status acts as the gate: once it's no longer pending, the callback exits early.
Session expiry
Pending sessions need to be cleaned up on a schedule. A NestJS cron job handles this:
// checkout-expiry.task.ts
@Cron(CronExpression.EVERY_10_MINUTES)
async expirestaleSessions(): Promise {
await this.sessionRepo
.createQueryBuilder()
.update(CheckoutSession)
.set({ status: 'expired' })
.where('status = :status', { status: 'pending' })
.andWhere('expires_at < NOW()')
.execute();
}
Expired sessions are kept for audit purposes, you can query them to see drop-off rates without them polluting your orders table.
When this model doesn't fit
Payment-first works cleanly when the gateway fires a server-side postback you can act on, DragonPay's model fits this well. PayMongo works differently: you create a payment intent first, the customer pays, then a webhook fires. The intent is attached to an order ID you've already generated, so PayMongo implicitly pushes you toward order-first.
If you're supporting both gateways, there are a few options. You can use the checkout session as a universal abstraction and reconstruct the order from it regardless of which gateway fired, which is what makes the gateway-agnostic polling endpoint covered in the PayMongo post worth building. Or you can accept that the two gateways produce orders through different paths and reconcile them at the order level instead.
Either way, being deliberate about this decision upfront saves a lot of retrofitting later.