From 44b74e69c72a516f562f6b02f0b99d36c3455ed1 Mon Sep 17 00:00:00 2001 From: mihir Date: Fri, 11 Sep 2026 00:28:46 +0530 Subject: [PATCH] feat(orders): add atomic checkout snapshots stock holds and cancellation --- src/checkout/checkout-snapshot.ts | 90 +++++++++ src/checkout/checkout.module.ts | 14 +- src/checkout/checkout.store.ts | 84 ++++++++ src/checkout/order-admin.controller.ts | 45 +++++ src/checkout/order-view.ts | 34 ++++ src/checkout/order.store.ts | 98 +++++++++ src/checkout/orders.controller.ts | 52 +++++ src/checkout/stock-allocation.ts | 52 +++++ src/inventory/reservation-transition.store.ts | 1 + test/checkout-concurrency.spec.ts | 118 +++++++++++ test/checkout-limits.spec.ts | 102 ++++++++++ test/checkout-rollback.spec.ts | 107 ++++++++++ test/checkout.spec.ts | 180 +++++++++++++++++ test/coupons.spec.ts | 103 ++++++++++ test/orders.spec.ts | 191 ++++++++++++++++++ 15 files changed, 1269 insertions(+), 2 deletions(-) create mode 100644 src/checkout/checkout-snapshot.ts create mode 100644 src/checkout/checkout.store.ts create mode 100644 src/checkout/order-admin.controller.ts create mode 100644 src/checkout/order-view.ts create mode 100644 src/checkout/order.store.ts create mode 100644 src/checkout/orders.controller.ts create mode 100644 src/checkout/stock-allocation.ts create mode 100644 test/checkout-concurrency.spec.ts create mode 100644 test/checkout-limits.spec.ts create mode 100644 test/checkout-rollback.spec.ts create mode 100644 test/checkout.spec.ts create mode 100644 test/coupons.spec.ts create mode 100644 test/orders.spec.ts diff --git a/src/checkout/checkout-snapshot.ts b/src/checkout/checkout-snapshot.ts new file mode 100644 index 0000000..7b08d88 --- /dev/null +++ b/src/checkout/checkout-snapshot.ts @@ -0,0 +1,90 @@ +import type { Prisma } from '../generated/prisma/client'; +import type { Principal } from '../identity/identity.types'; +import { AppError } from '../common/errors/app-error'; +import { decimal, minor } from './money'; +import { priceCoupon } from '../coupons/coupon-policy'; +import type { CheckoutInput } from './checkout.schemas'; + +export async function checkoutSnapshot( + tx: Prisma.TransactionClient, + actor: Principal, + input: CheckoutInput, +) { + const cart = await tx.cart.findUnique({ + where: { + userId_organizationId: { + userId: actor.userId, + organizationId: actor.organizationId, + }, + }, + include: { + lines: { + include: { variant: { include: { product: true } } }, + orderBy: { variantId: 'asc' }, + }, + }, + }); + if (!cart || cart.lines.length === 0) throw new AppError('CART_EMPTY'); + if (cart.version !== input.cartVersion) throw new AppError('CART_CHANGED'); + const address = await tx.address.findFirst({ + where: { + id: input.addressId, + + userId: actor.userId, + organizationId: actor.organizationId, + }, + }); + if (!address) throw new AppError('ADDRESS_NOT_FOUND'); + const currency = cart.lines[0]!.variant.currency; + const lines = cart.lines.map(({ variant, quantity }) => { + if (!variant.active || variant.product.status !== 'PUBLISHED') + throw new AppError('CART_ITEM_UNAVAILABLE'); + if (variant.currency !== currency) throw new AppError('CART_CURRENCY'); + return { + variantId: variant.id, + sku: variant.sku, + productName: variant.product.name, + variantName: variant.name, + quantity, + unitPrice: variant.price, + lineTotal: decimal(minor(variant.price.toString()) * BigInt(quantity)), + }; + }); + const subtotal = lines.reduce((sum, line) => sum + minor(line.lineTotal), 0n); + const { coupon, discount } = await priceCoupon( + tx, + actor, + input.couponCode, + currency, + subtotal, + new Date(), + ); + return { + cartId: cart.id, + lines, + currency, + subtotal: decimal(subtotal), + discount: decimal(discount), + merchandiseTotal: decimal(subtotal - discount), + couponId: coupon?.id, + couponSnapshot: coupon + ? { + code: coupon.code, + kind: coupon.kind, + amount: coupon.amount?.toString() ?? null, + percentBps: coupon.percentBps, + minimumSubtotal: coupon.minimumSubtotal.toString(), + } + : undefined, + addressSnapshot: { + recipient: address.recipient, + line1: address.line1, + line2: address.line2, + city: address.city, + region: address.region, + postalCode: address.postalCode, + countryCode: address.countryCode, + phone: address.phone, + }, + }; +} diff --git a/src/checkout/checkout.module.ts b/src/checkout/checkout.module.ts index 33e9db0..b2b2e6b 100644 --- a/src/checkout/checkout.module.ts +++ b/src/checkout/checkout.module.ts @@ -3,11 +3,21 @@ import { DatabaseModule } from '../database/database.module'; import { IdentityModule } from '../identity/identity.module'; import { CartStore } from './cart.store'; import { CartController } from './cart.controller'; +import { CheckoutStore } from './checkout.store'; +import { OrderStore } from './order.store'; +import { OrdersController } from './orders.controller'; +import { OrderAdminController } from './order-admin.controller'; import { CouponStore } from '../coupons/coupon.store'; import { CouponsController } from '../coupons/coupons.controller'; + @Module({ imports: [DatabaseModule, IdentityModule], - controllers: [CartController, CouponsController], - providers: [CartStore, CouponStore], + controllers: [ + CartController, + OrdersController, + OrderAdminController, + CouponsController, + ], + providers: [CartStore, CheckoutStore, OrderStore, CouponStore], }) export class CheckoutModule {} diff --git a/src/checkout/checkout.store.ts b/src/checkout/checkout.store.ts new file mode 100644 index 0000000..90593fb --- /dev/null +++ b/src/checkout/checkout.store.ts @@ -0,0 +1,84 @@ +import { Injectable } from '@nestjs/common'; +import { AccessStore } from '../identity/access.store'; +import type { Principal } from '../identity/identity.types'; +import { recordAudit } from '../identity/audit'; +import { AppError } from '../common/errors/app-error'; +import { assertReplay, commandHash } from '../inventory/inventory.policy'; +import type { CheckoutInput } from './checkout.schemas'; +import { checkoutSnapshot } from './checkout-snapshot'; +import { holdOrderStock } from './stock-allocation'; +import { orderView } from './order-view'; + +@Injectable() +export class CheckoutStore { + constructor(private readonly access: AccessStore) {} + create(actor: Principal, input: CheckoutInput) { + const requestHash = commandHash( + input.cartVersion, + input.addressId, + input.couponCode ?? '', + ); + // The organization lock also serializes catalog, coupon and cart changes. + // Stock locks below coordinate with independent inventory transactions. + return this.access.mutate(actor, null, async (tx) => { + const previous = await tx.order.findUnique({ + where: { + userId_organizationId_idempotencyKey: { + userId: actor.userId, + organizationId: actor.organizationId, + idempotencyKey: input.idempotencyKey, + }, + }, + include: { lines: true }, + }); + if (previous) { + assertReplay(previous.requestHash, requestHash); + return orderView(previous); + } + const now = new Date(); + if ( + (await tx.order.count({ + where: { + userId: actor.userId, + organizationId: actor.organizationId, + status: 'PENDING_PAYMENT', + expiresAt: { gt: now }, + }, + })) >= 10 + ) + throw new AppError('ORDER_LIMIT'); + const { cartId, lines, ...snapshot } = await checkoutSnapshot( + tx, + actor, + input, + ); + const expiresAt = new Date(now.getTime() + 15 * 60000); + const order = await tx.order.create({ + data: { + ...snapshot, + organizationId: actor.organizationId, + userId: actor.userId, + idempotencyKey: input.idempotencyKey, + requestHash, + expiresAt, + lines: { create: lines }, + }, + include: { lines: true }, + }); + await holdOrderStock(tx, actor, order.id, expiresAt, lines); + await tx.cartLine.deleteMany({ where: { cartId } }); + await tx.cart.update({ + where: { id: cartId }, + data: { version: { increment: 1 } }, + }); + await recordAudit( + tx, + actor.organizationId, + actor.userId, + 'order.created', + order.id, + ); + return orderView(order); + }); + } +} diff --git a/src/checkout/order-admin.controller.ts b/src/checkout/order-admin.controller.ts new file mode 100644 index 0000000..1ccd0fb --- /dev/null +++ b/src/checkout/order-admin.controller.ts @@ -0,0 +1,45 @@ +import { + Controller, + Get, + Param, + ParseUUIDPipe, + Post, + Query, +} from '@nestjs/common'; +import { + CurrentPrincipal, + RequirePermission, +} from '../identity/access.decorator'; +import type { Principal } from '../identity/identity.types'; +import { SchemaPipe } from '../common/validation.pipe'; +import { pageSchema, type PageInput } from '../identity/identity.schemas'; +import { OrderStore } from './order.store'; + +@Controller('admin/orders') +export class OrderAdminController { + constructor(private readonly orders: OrderStore) {} + @Get() + @RequirePermission('orders.read') + list( + @CurrentPrincipal() actor: Principal, + @Query(new SchemaPipe(pageSchema)) page: PageInput, + ) { + return this.orders.list(actor, page, true); + } + @Get(':id') + @RequirePermission('orders.read') + get( + @CurrentPrincipal() actor: Principal, + @Param('id', ParseUUIDPipe) id: string, + ) { + return this.orders.get(actor, id, true); + } + @Post(':id/cancel') + @RequirePermission('orders.manage') + cancel( + @CurrentPrincipal() actor: Principal, + @Param('id', ParseUUIDPipe) id: string, + ) { + return this.orders.cancel(actor, id, true); + } +} diff --git a/src/checkout/order-view.ts b/src/checkout/order-view.ts new file mode 100644 index 0000000..5ed7d3e --- /dev/null +++ b/src/checkout/order-view.ts @@ -0,0 +1,34 @@ +import type { Order, OrderLine } from '../generated/prisma/client'; +export function orderStatus(order: Pick) { + return order.status === 'PENDING_PAYMENT' && order.expiresAt <= new Date() + ? 'EXPIRED' + : order.status; +} +export function orderView(order: Order & { lines: OrderLine[] }) { + return { + id: order.id, + status: orderStatus(order), + currency: order.currency, + subtotal: order.subtotal, + discount: order.discount, + merchandiseTotal: order.merchandiseTotal, + pricingStatus: 'UNFINALIZED', + taxTotal: null, + shippingTotal: null, + payableTotal: null, + paymentAvailable: false, + address: order.addressSnapshot, + coupon: order.couponSnapshot, + createdAt: order.createdAt, + expiresAt: order.expiresAt, + lines: order.lines.map((line) => ({ + variantId: line.variantId, + sku: line.sku, + productName: line.productName, + variantName: line.variantName, + quantity: line.quantity, + unitPrice: line.unitPrice, + lineTotal: line.lineTotal, + })), + }; +} diff --git a/src/checkout/order.store.ts b/src/checkout/order.store.ts new file mode 100644 index 0000000..d880975 --- /dev/null +++ b/src/checkout/order.store.ts @@ -0,0 +1,98 @@ +import { Injectable } from '@nestjs/common'; +import { DatabaseService } from '../database/database.service'; +import { AccessStore } from '../identity/access.store'; +import type { Principal } from '../identity/identity.types'; +import { recordAudit } from '../identity/audit'; +import { AppError } from '../common/errors/app-error'; +import { lockStock } from '../inventory/stock-lock'; +import { orderView, orderStatus } from './order-view'; + +@Injectable() +export class OrderStore { + constructor( + private readonly db: DatabaseService, + private readonly access: AccessStore, + ) {} + list( + actor: Principal, + page: { limit: number; offset: number }, + staff = false, + ) { + return this.db.order + .findMany({ + where: { + organizationId: actor.organizationId, + ...(!staff ? { userId: actor.userId } : {}), + }, + take: page.limit, + skip: page.offset, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + select: { + id: true, + status: true, + currency: true, + merchandiseTotal: true, + createdAt: true, + expiresAt: true, + }, + }) + .then((orders) => + orders.map((order) => ({ + ...order, + status: orderStatus(order), + })), + ); + } + async get(actor: Principal, id: string, staff = false) { + const order = await this.db.order.findFirst({ + where: { + id, + organizationId: actor.organizationId, + ...(!staff ? { userId: actor.userId } : {}), + }, + include: { lines: { orderBy: { variantId: 'asc' } } }, + }); + if (!order) throw new AppError('ORDER_NOT_FOUND'); + return orderView(order); + } + cancel(actor: Principal, id: string, staff = false) { + return this.access.mutate( + actor, + staff ? 'orders.manage' : null, + async (tx) => { + const order = await tx.order.findFirst({ + where: { + id, + organizationId: actor.organizationId, + ...(!staff ? { userId: actor.userId } : {}), + }, + include: { + lines: true, + reservations: { orderBy: { stockItemId: 'asc' } }, + }, + }); + if (!order) throw new AppError('ORDER_NOT_FOUND'); + if (order.status === 'CANCELLED') return orderView(order); + for (const reservation of order.reservations) + await lockStock(tx, actor.organizationId, reservation.stockItemId); + await tx.stockReservation.updateMany({ + where: { orderId: id, status: 'ACTIVE' }, + data: { status: 'RELEASED' }, + }); + const updated = await tx.order.update({ + where: { id }, + data: { status: 'CANCELLED' }, + include: { lines: true }, + }); + await recordAudit( + tx, + actor.organizationId, + actor.userId, + 'order.cancelled', + id, + ); + return orderView(updated); + }, + ); + } +} diff --git a/src/checkout/orders.controller.ts b/src/checkout/orders.controller.ts new file mode 100644 index 0000000..36c0a7f --- /dev/null +++ b/src/checkout/orders.controller.ts @@ -0,0 +1,52 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Post, + Query, +} from '@nestjs/common'; +import { CurrentPrincipal } from '../identity/access.decorator'; +import type { Principal } from '../identity/identity.types'; +import { SchemaPipe } from '../common/validation.pipe'; +import { pageSchema, type PageInput } from '../identity/identity.schemas'; +import { checkoutSchema, type CheckoutInput } from './checkout.schemas'; +import { CheckoutStore } from './checkout.store'; +import { OrderStore } from './order.store'; + +@Controller() +export class OrdersController { + constructor( + private readonly checkout: CheckoutStore, + private readonly orders: OrderStore, + ) {} + @Post('checkout') + create( + @CurrentPrincipal() actor: Principal, + @Body(new SchemaPipe(checkoutSchema)) input: CheckoutInput, + ) { + return this.checkout.create(actor, input); + } + @Get('orders') + list( + @CurrentPrincipal() actor: Principal, + @Query(new SchemaPipe(pageSchema)) page: PageInput, + ) { + return this.orders.list(actor, page); + } + @Get('orders/:id') + get( + @CurrentPrincipal() actor: Principal, + @Param('id', ParseUUIDPipe) id: string, + ) { + return this.orders.get(actor, id); + } + @Post('orders/:id/cancel') + cancel( + @CurrentPrincipal() actor: Principal, + @Param('id', ParseUUIDPipe) id: string, + ) { + return this.orders.cancel(actor, id); + } +} diff --git a/src/checkout/stock-allocation.ts b/src/checkout/stock-allocation.ts new file mode 100644 index 0000000..9840a90 --- /dev/null +++ b/src/checkout/stock-allocation.ts @@ -0,0 +1,52 @@ +import { randomUUID } from 'node:crypto'; +import type { Prisma } from '../generated/prisma/client'; +import type { Principal } from '../identity/identity.types'; +import { AppError } from '../common/errors/app-error'; +import { lockStock, reservedQuantity } from '../inventory/stock-lock'; +import { commandHash } from '../inventory/inventory.policy'; + +export async function holdOrderStock( + tx: Prisma.TransactionClient, + actor: Principal, + orderId: string, + expiresAt: Date, + lines: { variantId: string; quantity: number }[], +) { + const stocks = await tx.stockItem.findMany({ + where: { + organizationId: actor.organizationId, + variantId: { in: lines.map((line) => line.variantId) }, + }, + orderBy: { id: 'asc' }, + take: 201, + }); + if (stocks.length > 200) throw new AppError('STOCK_ALLOCATION_LIMIT'); + const now = new Date(); + const remaining = new Map( + lines.map((line) => [line.variantId, line.quantity]), + ); + for (const candidate of stocks) { + const stock = await lockStock(tx, actor.organizationId, candidate.id); + const needed = remaining.get(stock.variantId)!; + if (!needed) continue; + const available = + stock.onHand - (await reservedQuantity(tx, stock.id, now)); + const quantity = Math.min(needed, available); + if (quantity <= 0) continue; + await tx.stockReservation.create({ + data: { + stockItemId: stock.id, + organizationId: actor.organizationId, + userId: actor.userId, + orderId, + quantity, + expiresAt, + idempotencyKey: randomUUID(), + requestHash: commandHash(orderId, stock.id, quantity), + }, + }); + remaining.set(stock.variantId, needed - quantity); + } + if ([...remaining.values()].some((quantity) => quantity > 0)) + throw new AppError('STOCK_INSUFFICIENT'); +} diff --git a/src/inventory/reservation-transition.store.ts b/src/inventory/reservation-transition.store.ts index 3233f0d..d95f677 100644 --- a/src/inventory/reservation-transition.store.ts +++ b/src/inventory/reservation-transition.store.ts @@ -23,6 +23,7 @@ export class ReservationTransitionStore { }; const existing = await tx.stockReservation.findFirst({ where: lookup }); if (!existing) throw new AppError('RESERVATION_NOT_FOUND'); + if (existing.orderId) throw new AppError('ORDER_RESERVATION_MANAGED'); const stock = await lockStock( tx, actor.organizationId, diff --git a/test/checkout-concurrency.spec.ts b/test/checkout-concurrency.spec.ts new file mode 100644 index 0000000..84d85f3 --- /dev/null +++ b/test/checkout-concurrency.spec.ts @@ -0,0 +1,118 @@ +import { randomUUID } from 'node:crypto'; +import { identityApp, type IdentityApp } from './helpers/identity-app'; +import { checkoutFixture, couponInput } from './helpers/checkout'; +import { secondActor, addressInput } from './helpers/commerce'; + +const native = process.env.TEST_DATABASE_URL ? describe : describe.skip; +native('native PostgreSQL checkout concurrency', () => { + let ctx: IdentityApp; + beforeAll(async () => { + ctx = await identityApp(); + }, 60000); + afterAll(async () => { + await ctx.close(); + }); + beforeEach(async () => { + await ctx.clearLimits(); + }); + it('creates one order for two simultaneous identical requests', async () => { + const f = await checkoutFixture(ctx); + const responses = await Promise.all( + [1, 2].map(() => + ctx + .api() + .post('/api/v1/checkout') + .auth(f.actor.token, { type: 'bearer' }) + .send(f.input), + ), + ); + expect(responses.map((response) => response.status)).toEqual([201, 201]); + expect(responses[0]!.body.id).toBe(responses[1]!.body.id); + expect( + await ctx.db.stockReservation.count({ + where: { userId: f.actor.userId }, + }), + ).toBe(1); + }); + it('lets only one competing checkout claim the final stock and coupon use', async () => { + const f = await checkoutFixture(ctx, 2); + const other = await secondActor(ctx); + const address = await ctx + .api() + .post('/api/v1/addresses') + .auth(other.token, { type: 'bearer' }) + .send(addressInput) + .expect(201); + await ctx + .api() + .put('/api/v1/cart/lines/' + f.variant.id) + .auth(other.token, { type: 'bearer' }) + .send({ quantity: 2, version: 0 }) + .expect(200); + const coupon = await ctx + .api() + .post('/api/v1/coupons') + .auth(ctx.token, { type: 'bearer' }) + .send({ ...couponInput(), maxUses: 1 }) + .expect(201); + const commands = [ + { actor: f.actor, input: { ...f.input, couponCode: coupon.body.code } }, + { + actor: other, + input: { + ...f.input, + addressId: address.body.id, + idempotencyKey: randomUUID(), + couponCode: coupon.body.code, + }, + }, + ]; + const responses = await Promise.all( + commands.map(({ actor, input }) => + ctx + .api() + .post('/api/v1/checkout') + .auth(actor.token, { type: 'bearer' }) + .send(input), + ), + ); + expect(responses.map((response) => response.status).sort()).toEqual([ + 201, 409, + ]); + expect( + await ctx.db.order.count({ where: { couponId: coupon.body.id } }), + ).toBe(1); + const reserved = await ctx.db.stockReservation.aggregate({ + where: { stockItemId: f.stock.id }, + _sum: { quantity: true }, + }); + expect(reserved._sum.quantity).toBe(2); + }); + it('coordinates checkout with a standalone inventory reservation', async () => { + const f = await checkoutFixture(ctx, 2); + const responses = await Promise.all([ + ctx + .api() + .post('/api/v1/checkout') + .auth(f.actor.token, { type: 'bearer' }) + .send(f.input), + ctx + .api() + .post('/api/v1/inventory/reservations') + .auth(ctx.token, { type: 'bearer' }) + .send({ + stockItemId: f.stock.id, + quantity: 2, + idempotencyKey: randomUUID(), + }), + ]); + expect(responses.map((response) => response.status).sort()).toEqual([ + 201, 409, + ]); + const held = await ctx.db.stockReservation.aggregate({ + where: { stockItemId: f.stock.id }, + _sum: { quantity: true }, + }); + expect(held._sum.quantity).toBe(2); + }); +}); diff --git a/test/checkout-limits.spec.ts b/test/checkout-limits.spec.ts new file mode 100644 index 0000000..60123a2 --- /dev/null +++ b/test/checkout-limits.spec.ts @@ -0,0 +1,102 @@ +import { CartStore } from '../src/checkout/cart.store'; +import { CheckoutStore } from '../src/checkout/checkout.store'; +import { holdOrderStock } from '../src/checkout/stock-allocation'; +import { orderView } from '../src/checkout/order-view'; +import type { AccessStore } from '../src/identity/access.store'; +import type { Principal } from '../src/identity/identity.types'; +import { Prisma, type Order } from '../src/generated/prisma/client'; + +const actor = { userId: 'user', organizationId: 'org' } as Principal; +function accessFor(tx: unknown) { + return { + mutate: ( + _actor: unknown, + _permission: unknown, + work: (tx: unknown) => unknown, + ) => work(tx), + } as AccessStore; +} +describe('checkout resource limits and expiration', () => { + it('rejects a 21st cart line before writing', async () => { + const tx = { + cart: { upsert: jest.fn().mockResolvedValue({ id: 'cart', version: 1 }) }, + productVariant: { + findFirst: jest.fn().mockResolvedValue({ currency: 'INR' }), + }, + cartLine: { + findMany: jest.fn().mockResolvedValue( + Array.from({ length: 20 }, (_, index) => ({ + variantId: String(index), + variant: { currency: 'INR' }, + })), + ), + upsert: jest.fn(), + }, + }; + await expect( + new CartStore(accessFor(tx)).set(actor, 'new', 1, 1), + ).rejects.toMatchObject({ code: 'CART_LIMIT' }); + expect(tx.cartLine.upsert).not.toHaveBeenCalled(); + }); + it('rejects an eleventh active order before creating an order or stock hold', async () => { + const tx = { + order: { + findUnique: jest.fn().mockResolvedValue(null), + count: jest.fn().mockResolvedValue(10), + create: jest.fn(), + }, + }; + await expect( + new CheckoutStore(accessFor(tx)).create(actor, { + cartVersion: 1, + addressId: 'address', + idempotencyKey: 'key', + }), + ).rejects.toMatchObject({ code: 'ORDER_LIMIT' }); + expect(tx.order.create).not.toHaveBeenCalled(); + }); + it('bounds stock allocation work before acquiring locks', async () => { + const tx = { + stockItem: { findMany: jest.fn().mockResolvedValue(Array(201).fill({})) }, + $queryRaw: jest.fn(), + }; + await expect( + holdOrderStock( + tx as unknown as Prisma.TransactionClient, + actor, + 'order', + new Date(), + [], + ), + ).rejects.toMatchObject({ code: 'STOCK_ALLOCATION_LIMIT' }); + expect(tx.$queryRaw).not.toHaveBeenCalled(); + }); + it('exposes expiry without pretending an unfinalized order is payable', () => { + const order: Order & { lines: [] } = { + id: 'order', + organizationId: 'org', + userId: 'user', + status: 'PENDING_PAYMENT', + currency: 'INR', + subtotal: new Prisma.Decimal(1), + discount: new Prisma.Decimal(0), + merchandiseTotal: new Prisma.Decimal(1), + addressSnapshot: {}, + couponSnapshot: null, + couponId: null, + idempotencyKey: 'key', + requestHash: 'hash', + createdAt: new Date(0), + expiresAt: new Date(1), + lines: [], + }; + expect(orderView(order)).toMatchObject({ + status: 'EXPIRED', + payableTotal: null, + paymentAvailable: false, + }); + expect(orderView({ ...order, status: 'CANCELLED' }).status).toBe( + 'CANCELLED', + ); + }); +}); diff --git a/test/checkout-rollback.spec.ts b/test/checkout-rollback.spec.ts new file mode 100644 index 0000000..5138c1a --- /dev/null +++ b/test/checkout-rollback.spec.ts @@ -0,0 +1,107 @@ +import { randomUUID } from 'node:crypto'; +import { identityApp, type IdentityApp } from './helpers/identity-app'; +import { checkoutFixture } from './helpers/checkout'; +import * as audit from '../src/identity/audit'; + +describe('checkout allocation and final-write rollback', () => { + let ctx: IdentityApp; + beforeAll(async () => { + ctx = await identityApp(); + }, 60000); + afterAll(async () => { + await ctx.close(); + }); + beforeEach(async () => { + await ctx.clearLimits(); + }); + afterEach(() => { + jest.restoreAllMocks(); + }); + it('rolls back order, holds and cart clearing when the audit write fails', async () => { + const f = await checkoutFixture(ctx); + jest + .spyOn(audit, 'recordAudit') + .mockRejectedValueOnce(new Error('Synthetic audit failure')); + const result = await ctx + .api() + .post('/api/v1/checkout') + .auth(f.actor.token, { type: 'bearer' }) + .send(f.input) + .expect(500); + expect(result.body.code).toBe('INTERNAL_FAILURE'); + expect(JSON.stringify(result.body)).not.toContain('Synthetic'); + expect( + await ctx.db.order.count({ where: { userId: f.actor.userId } }), + ).toBe(0); + expect( + await ctx.db.stockReservation.count({ + where: { userId: f.actor.userId }, + }), + ).toBe(0); + const cart = await ctx + .api() + .get('/api/v1/cart') + .auth(f.actor.token, { type: 'bearer' }) + .expect(200); + expect(cart.body.version).toBe(1); + expect(cart.body.lines).toHaveLength(1); + await ctx + .api() + .post('/api/v1/checkout') + .auth(f.actor.token, { type: 'bearer' }) + .send(f.input) + .expect(201); + }); + it('splits a line across warehouses without consuming on-hand stock', async () => { + const f = await checkoutFixture(ctx, 1); + const warehouse = await ctx.db.warehouse.create({ + data: { organizationId: f.actor.organizationId, name: randomUUID() }, + }); + const stock = await ctx.db.stockItem.create({ + data: { + organizationId: f.actor.organizationId, + warehouseId: warehouse.id, + variantId: f.variant.id, + }, + }); + await ctx + .api() + .post('/api/v1/inventory/adjustments') + .auth(ctx.token, { type: 'bearer' }) + .send({ + stockItemId: stock.id, + delta: 1, + reason: 'Opening balance', + idempotencyKey: randomUUID(), + }) + .expect(201); + const result = await ctx + .api() + .post('/api/v1/checkout') + .auth(f.actor.token, { type: 'bearer' }) + .send(f.input) + .expect(201); + const holds = await ctx.db.stockReservation.findMany({ + where: { orderId: result.body.id }, + }); + expect(holds).toHaveLength(2); + expect(holds.map((hold) => hold.quantity)).toEqual([1, 1]); + }); + it('revalidates product availability and currency when checking out a saved cart', async () => { + const f = await checkoutFixture(ctx); + await ctx.db.productVariant.update({ + where: { id: f.variant.id }, + data: { active: false }, + }); + const response = await ctx + .api() + .post('/api/v1/checkout') + .auth(f.actor.token, { type: 'bearer' }) + .send(f.input) + .expect(409); + expect(response.body.code).toBe('CART_ITEM_UNAVAILABLE'); + expect( + await ctx.db.order.count({ where: { userId: f.actor.userId } }), + ).toBe(0); + }); +}); diff --git a/test/checkout.spec.ts b/test/checkout.spec.ts new file mode 100644 index 0000000..1b177fc --- /dev/null +++ b/test/checkout.spec.ts @@ -0,0 +1,180 @@ +import { randomUUID } from 'node:crypto'; +import { identityApp, type IdentityApp } from './helpers/identity-app'; +import { checkoutFixture, couponInput } from './helpers/checkout'; +import { secondActor, seedStock, addressInput } from './helpers/commerce'; + +describe('atomic checkout and order snapshots', () => { + let ctx: IdentityApp; + beforeAll(async () => { + ctx = await identityApp(); + }); + afterAll(async () => { + await ctx.close(); + }); + beforeEach(async () => { + await ctx.clearLimits(); + }); + it('snapshots server prices and address, holds stock, clears cart and replays safely', async () => { + const f = await checkoutFixture(ctx); + const created = await ctx + .api() + .post('/api/v1/checkout') + .auth(f.actor.token, { type: 'bearer' }) + .send(f.input) + .expect(201); + expect(created.body).toMatchObject({ + subtotal: '998', + discount: '0', + merchandiseTotal: '998', + status: 'PENDING_PAYMENT', + pricingStatus: 'UNFINALIZED', + payableTotal: null, + paymentAvailable: false, + }); + expect(created.body.requestHash).toBeUndefined(); + expect( + await ctx.db.stockReservation.count({ + where: { orderId: created.body.id }, + }), + ).toBe(1); + expect( + (await ctx.db.stockItem.findUniqueOrThrow({ where: { id: f.stock.id } })) + .onHand, + ).toBe(10); + await ctx.db.productVariant.update({ + where: { id: f.variant.id }, + data: { price: '1.00', name: 'Changed' }, + }); + await ctx + .api() + .put('/api/v1/addresses/' + f.address.id) + .auth(f.actor.token, { type: 'bearer' }) + .send({ ...addressInput, recipient: 'Changed' }) + .expect(200); + const replay = await ctx + .api() + .post('/api/v1/checkout') + .auth(f.actor.token, { type: 'bearer' }) + .send(f.input) + .expect(201); + expect(replay.body).toEqual(created.body); + expect(replay.body.address.recipient).toBe(addressInput.recipient); + const cart = await ctx + .api() + .get('/api/v1/cart') + .auth(f.actor.token, { type: 'bearer' }) + .expect(200); + expect(cart.body).toEqual({ version: 2, lines: [] }); + const conflict = await ctx + .api() + .post('/api/v1/checkout') + .auth(f.actor.token, { type: 'bearer' }) + .send({ ...f.input, cartVersion: 2 }) + .expect(409); + expect(conflict.body.code).toBe('IDEMPOTENCY_CONFLICT'); + }); + it('rolls back orders and partial holds on a stock shortage, preserving cart', async () => { + const f = await checkoutFixture(ctx); + const unavailable = await seedStock(ctx, 0); + await ctx + .api() + .put('/api/v1/cart/lines/' + unavailable.variant.id) + .auth(f.actor.token, { type: 'bearer' }) + .send({ quantity: 1, version: 1 }) + .expect(200); + const response = await ctx + .api() + .post('/api/v1/checkout') + .auth(f.actor.token, { type: 'bearer' }) + .send({ ...f.input, cartVersion: 2 }) + .expect(409); + expect(response.body.code).toBe('STOCK_INSUFFICIENT'); + expect( + await ctx.db.order.count({ where: { userId: f.actor.userId } }), + ).toBe(0); + expect( + await ctx.db.stockReservation.count({ + where: { userId: f.actor.userId }, + }), + ).toBe(0); + expect( + await ctx.db.cartLine.count({ + where: { cart: { userId: f.actor.userId } }, + }), + ).toBe(2); + }); + it('validates live cart version, private address and coupon eligibility before writes', async () => { + const f = await checkoutFixture(ctx); + for (const [change, code] of [ + [{ cartVersion: 0 }, 'CART_CHANGED'], + [{ addressId: randomUUID() }, 'ADDRESS_NOT_FOUND'], + [{ couponCode: 'MISSING' }, 'COUPON_INELIGIBLE'], + ] as const) { + const response = await ctx + .api() + .post('/api/v1/checkout') + .auth(f.actor.token, { type: 'bearer' }) + .send({ ...f.input, ...change }) + .expect(code === 'ADDRESS_NOT_FOUND' ? 404 : 409); + expect(response.body.code).toBe(code); + } + const other = await secondActor(ctx); + await ctx + .api() + .post('/api/v1/checkout') + .auth(other.token, { type: 'bearer' }) + .send(f.input) + .expect(409); + await ctx + .api() + .post('/api/v1/checkout') + .auth(f.actor.token, { type: 'bearer' }) + .send({ ...f.input, subtotal: '0.01' }) + .expect(400); + }); + it('applies a coupon exactly once and releases its usage on cancellation', async () => { + const f = await checkoutFixture(ctx); + const coupon = await ctx + .api() + .post('/api/v1/coupons') + .auth(ctx.token, { type: 'bearer' }) + .send(couponInput()) + .expect(201); + const input = { ...f.input, couponCode: coupon.body.code }; + const order = await ctx + .api() + .post('/api/v1/checkout') + .auth(f.actor.token, { type: 'bearer' }) + .send(input) + .expect(201); + expect(order.body).toMatchObject({ + subtotal: '998', + discount: '99.8', + merchandiseTotal: '898.2', + }); + await ctx + .api() + .put('/api/v1/cart/lines/' + f.variant.id) + .auth(f.actor.token, { type: 'bearer' }) + .send({ quantity: 1, version: 2 }) + .expect(200); + const next = { ...input, cartVersion: 3, idempotencyKey: randomUUID() }; + await ctx + .api() + .post('/api/v1/checkout') + .auth(f.actor.token, { type: 'bearer' }) + .send(next) + .expect(409); + await ctx + .api() + .post('/api/v1/orders/' + order.body.id + '/cancel') + .auth(f.actor.token, { type: 'bearer' }) + .expect(201); + await ctx + .api() + .post('/api/v1/checkout') + .auth(f.actor.token, { type: 'bearer' }) + .send(next) + .expect(201); + }); +}); diff --git a/test/coupons.spec.ts b/test/coupons.spec.ts new file mode 100644 index 0000000..e10a29d --- /dev/null +++ b/test/coupons.spec.ts @@ -0,0 +1,103 @@ +import { randomUUID } from 'node:crypto'; +import { identityApp, type IdentityApp } from './helpers/identity-app'; +import { checkoutFixture, couponInput } from './helpers/checkout'; +import { secondActor } from './helpers/commerce'; + +describe('coupon administration', () => { + let ctx: IdentityApp; + beforeAll(async () => { + ctx = await identityApp(); + }, 60000); + afterAll(async () => { + await ctx.close(); + }); + beforeEach(async () => { + await ctx.clearLimits(); + }); + it('requires permissions, scopes reads and deactivates immutable coupon rules', async () => { + const customer = await secondActor(ctx); + await ctx + .api() + .post('/api/v1/coupons') + .auth(customer.token, { type: 'bearer' }) + .send(couponInput()) + .expect(403); + const created = await ctx + .api() + .post('/api/v1/coupons') + .auth(ctx.token, { type: 'bearer' }) + .send(couponInput()) + .expect(201); + const list = await ctx + .api() + .get('/api/v1/coupons') + .auth(ctx.token, { type: 'bearer' }) + .expect(200); + expect( + list.body.some((row: { id: string }) => row.id === created.body.id), + ).toBe(true); + const foreign = await secondActor(ctx, false, ['coupons.manage']); + const empty = await ctx + .api() + .get('/api/v1/coupons') + .auth(foreign.token, { type: 'bearer' }) + .expect(200); + expect(empty.body).toEqual([]); + await ctx + .api() + .patch('/api/v1/coupons/' + created.body.id + '/status') + .auth(foreign.token, { type: 'bearer' }) + .send({ active: false }) + .expect(404); + await ctx + .api() + .patch('/api/v1/coupons/' + created.body.id + '/status') + .auth(ctx.token, { type: 'bearer' }) + .send({ active: false }) + .expect(200); + await expect( + ctx.executeSql( + `UPDATE coupons SET percent_bps = 5000 WHERE id = '${created.body.id}'`, + ), + ).rejects.toThrow(); + const f = await checkoutFixture(ctx); + await ctx + .api() + .post('/api/v1/checkout') + .auth(f.actor.token, { type: 'bearer' }) + .send({ ...f.input, couponCode: created.body.code }) + .expect(409); + }); + it('caps fixed discounts at subtotal and rejects duplicate normalized codes', async () => { + const input = couponInput(); + const { percentBps: _percent, ...common } = input; + const fixed = { ...common, kind: 'FIXED', amount: '9999.00' }; + const coupon = await ctx + .api() + .post('/api/v1/coupons') + .auth(ctx.token, { type: 'bearer' }) + .send(fixed) + .expect(201); + await ctx + .api() + .post('/api/v1/coupons') + .auth(ctx.token, { type: 'bearer' }) + .send({ ...fixed, code: fixed.code.toLowerCase() }) + .expect(409); + const f = await checkoutFixture(ctx); + const order = await ctx + .api() + .post('/api/v1/checkout') + .auth(f.actor.token, { type: 'bearer' }) + .send({ ...f.input, couponCode: coupon.body.code }) + .expect(201); + expect(order.body.merchandiseTotal).toBe('0'); + expect(order.body.paymentAvailable).toBe(false); + await ctx + .api() + .patch('/api/v1/coupons/' + randomUUID() + '/status') + .auth(ctx.token, { type: 'bearer' }) + .send({ active: false }) + .expect(404); + }); +}); diff --git a/test/orders.spec.ts b/test/orders.spec.ts new file mode 100644 index 0000000..9ba8826 --- /dev/null +++ b/test/orders.spec.ts @@ -0,0 +1,191 @@ +import { identityApp, type IdentityApp } from './helpers/identity-app'; +import { checkoutFixture } from './helpers/checkout'; +import { secondActor } from './helpers/commerce'; + +describe('order ownership and cancellation', () => { + let ctx: IdentityApp; + beforeAll(async () => { + ctx = await identityApp(); + }, 60000); + afterAll(async () => { + await ctx.close(); + }); + beforeEach(async () => { + await ctx.clearLimits(); + }); + it('isolates customers and organizations while allowing scoped staff reads', async () => { + const f = await checkoutFixture(ctx); + const order = await ctx + .api() + .post('/api/v1/checkout') + .auth(f.actor.token, { type: 'bearer' }) + .send(f.input) + .expect(201); + const other = await secondActor(ctx); + const foreign = await secondActor(ctx, false, [ + 'orders.read', + 'orders.manage', + ]); + for (const actor of [other, foreign]) { + await ctx + .api() + .get('/api/v1/orders/' + order.body.id) + .auth(actor.token, { type: 'bearer' }) + .expect(404); + await ctx + .api() + .post('/api/v1/orders/' + order.body.id + '/cancel') + .auth(actor.token, { type: 'bearer' }) + .expect(404); + } + await ctx + .api() + .get('/api/v1/admin/orders/' + order.body.id) + .auth(other.token, { type: 'bearer' }) + .expect(403); + await ctx + .api() + .get('/api/v1/admin/orders/' + order.body.id) + .auth(foreign.token, { type: 'bearer' }) + .expect(404); + await ctx + .api() + .post('/api/v1/admin/orders/' + order.body.id + '/cancel') + .auth(foreign.token, { type: 'bearer' }) + .expect(404); + await ctx + .api() + .get('/api/v1/admin/orders/' + order.body.id) + .auth(ctx.token, { type: 'bearer' }) + .expect(200); + const own = await ctx + .api() + .get('/api/v1/orders') + .auth(f.actor.token, { type: 'bearer' }) + .expect(200); + expect(own.body.map((row: { id: string }) => row.id)).toEqual([ + order.body.id, + ]); + const others = await ctx + .api() + .get('/api/v1/orders') + .auth(other.token, { type: 'bearer' }) + .expect(200); + expect(others.body).toEqual([]); + const staff = await ctx + .api() + .get('/api/v1/admin/orders?limit=1') + .auth(ctx.token, { type: 'bearer' }) + .expect(200); + expect(staff.body).toHaveLength(1); + expect(staff.body[0].address).toBeUndefined(); + }); + it('cancels once, releases holds and blocks standalone reservation transitions', async () => { + const f = await checkoutFixture(ctx); + const order = await ctx + .api() + .post('/api/v1/checkout') + .auth(f.actor.token, { type: 'bearer' }) + .send(f.input) + .expect(201); + const hold = await ctx.db.stockReservation.findFirstOrThrow({ + where: { orderId: order.body.id }, + }); + const denied = await ctx + .api() + .post('/api/v1/inventory/reservations/' + hold.id + '/commit') + .auth(ctx.token, { type: 'bearer' }) + .expect(409); + expect(denied.body.code).toBe('ORDER_RESERVATION_MANAGED'); + const result = await ctx + .api() + .post('/api/v1/admin/orders/' + order.body.id + '/cancel') + .auth(ctx.token, { type: 'bearer' }) + .expect(201); + expect(result.body.status).toBe('CANCELLED'); + await ctx + .api() + .post('/api/v1/orders/' + order.body.id + '/cancel') + .auth(f.actor.token, { type: 'bearer' }) + .expect(201); + expect( + ( + await ctx.db.stockReservation.findUniqueOrThrow({ + where: { id: hold.id }, + }) + ).status, + ).toBe('RELEASED'); + expect( + await ctx.db.auditEvent.count({ + where: { targetId: order.body.id, action: 'order.cancelled' }, + }), + ).toBe(1); + expect( + (await ctx.db.stockItem.findUniqueOrThrow({ where: { id: f.stock.id } })) + .onHand, + ).toBe(10); + }); + it('enforces immutable snapshots and line arithmetic through SQL', async () => { + const f = await checkoutFixture(ctx); + const order = await ctx + .api() + .post('/api/v1/checkout') + .auth(f.actor.token, { type: 'bearer' }) + .send(f.input) + .expect(201); + await expect( + ctx.executeSql( + `UPDATE orders SET subtotal = 1 WHERE id = '${order.body.id}'`, + ), + ).rejects.toThrow(); + await expect( + ctx.executeSql(`DELETE FROM orders WHERE id = '${order.body.id}'`), + ).rejects.toThrow(); + await expect( + ctx.executeSql( + `UPDATE order_lines SET quantity = 10 WHERE order_id = '${order.body.id}'`, + ), + ).rejects.toThrow(); + await expect( + ctx.executeSql( + `DELETE FROM order_lines WHERE order_id = '${order.body.id}'`, + ), + ).rejects.toThrow(); + await expect( + ctx.executeSql( + `UPDATE stock_reservations SET order_id = NULL WHERE order_id = '${order.body.id}'`, + ), + ).rejects.toThrow(); + await expect( + ctx.executeSql( + `DELETE FROM stock_reservations WHERE order_id = '${order.body.id}'`, + ), + ).rejects.toThrow(); + }); + it('rejects a later line insertion that would change a committed order snapshot', async () => { + const f = await checkoutFixture(ctx); + const result = await ctx + .api() + .post('/api/v1/checkout') + .auth(f.actor.token, { type: 'bearer' }) + .send(f.input) + .expect(201); + const variant = await ctx.db.productVariant.create({ + data: { + organizationId: f.actor.organizationId, + productId: f.product.id, + sku: result.body.id, + name: 'Extra line', + price: '1.00', + }, + }); + await expect( + ctx.executeSql(`INSERT INTO order_lines + (id, order_id, organization_id, variant_id, sku, product_name, variant_name, quantity, unit_price, line_total) + VALUES (gen_random_uuid(), '${result.body.id}', '${f.actor.organizationId}', '${variant.id}', 'EXTRA', 'Extra', 'Extra', 1, 1, 1)`), + ).rejects.toThrow('Order subtotal does not match lines'); + expect( + await ctx.db.orderLine.count({ where: { orderId: result.body.id } }), + ).toBe(1); + }); +});