From b3624cc0262095635280b3e3d9d1fbf10a421c14 Mon Sep 17 00:00:00 2001 From: mihir Date: Thu, 10 Sep 2026 11:23:23 +0530 Subject: [PATCH] feat(inventory): add immutable ledger and transactional reservations --- src/app.module.ts | 14 +- src/inventory/adjustment.store.ts | 65 +++++ src/inventory/inventory.module.ts | 21 ++ src/inventory/inventory.policy.ts | 19 ++ src/inventory/inventory.schemas.ts | 29 ++ src/inventory/reservation-transition.store.ts | 74 +++++ src/inventory/reservation.store.ts | 98 +++++++ src/inventory/reservations.controller.ts | 60 +++++ src/inventory/stock-lock.ts | 32 +++ src/inventory/stock.controller.ts | 90 +++++++ src/inventory/stock.store.ts | 113 ++++++++ test/commerce-policy.spec.ts | 81 ++++++ test/inventory-policy.spec.ts | 53 ++++ test/inventory.spec.ts | 255 ++++++++++++++++++ 14 files changed, 1003 insertions(+), 1 deletion(-) create mode 100644 src/inventory/adjustment.store.ts create mode 100644 src/inventory/inventory.module.ts create mode 100644 src/inventory/inventory.policy.ts create mode 100644 src/inventory/inventory.schemas.ts create mode 100644 src/inventory/reservation-transition.store.ts create mode 100644 src/inventory/reservation.store.ts create mode 100644 src/inventory/reservations.controller.ts create mode 100644 src/inventory/stock-lock.ts create mode 100644 src/inventory/stock.controller.ts create mode 100644 src/inventory/stock.store.ts create mode 100644 test/commerce-policy.spec.ts create mode 100644 test/inventory-policy.spec.ts create mode 100644 test/inventory.spec.ts diff --git a/src/app.module.ts b/src/app.module.ts index 5125700..21ce864 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,7 +1,19 @@ +import { CatalogModule } from './catalog/catalog.module'; +import { AddressesModule } from './addresses/addresses.module'; +import { InventoryModule } from './inventory/inventory.module'; import { Module } from '@nestjs/common'; import { EnvironmentModule } from './config/environment.module'; import { IdentityModule } from './identity/identity.module'; import { HealthModule } from './health/health.module'; -@Module({ imports: [EnvironmentModule, HealthModule, IdentityModule] }) +@Module({ + imports: [ + EnvironmentModule, + HealthModule, + IdentityModule, + CatalogModule, + AddressesModule, + InventoryModule, + ], +}) export class AppModule {} diff --git a/src/inventory/adjustment.store.ts b/src/inventory/adjustment.store.ts new file mode 100644 index 0000000..da6aa17 --- /dev/null +++ b/src/inventory/adjustment.store.ts @@ -0,0 +1,65 @@ +import { Injectable } from '@nestjs/common'; +import { AccessStore } from '../identity/access.store'; +import { recordAudit } from '../identity/audit'; +import type { Principal } from '../identity/identity.types'; +import type { AdjustmentInput } from './inventory.schemas'; +import { lockStock, reservedQuantity } from './stock-lock'; +import { adjustedBalance, assertReplay, commandHash } from './inventory.policy'; + +@Injectable() +export class AdjustmentStore { + constructor(private readonly access: AccessStore) {} + adjust(actor: Principal, input: AdjustmentInput) { + const requestHash = commandHash( + input.stockItemId, + input.delta, + input.reason, + ); + return this.access.mutate( + actor, + 'inventory.adjust', + async (tx) => { + const stock = await lockStock( + tx, + actor.organizationId, + input.stockItemId, + ); + const previous = await tx.stockLedger.findUnique({ + where: { + organizationId_idempotencyKey: { + organizationId: actor.organizationId, + idempotencyKey: input.idempotencyKey, + }, + }, + }); + if (previous) { + assertReplay(previous.requestHash, requestHash); + return previous; + } + const reserved = await reservedQuantity(tx, stock.id, new Date()); + const onHand = adjustedBalance(stock.onHand, reserved, input.delta); + await tx.stockItem.update({ + where: { id: stock.id }, + data: { onHand }, + }); + const entry = await tx.stockLedger.create({ + data: { + organizationId: actor.organizationId, + actorId: actor.userId, + ...input, + requestHash, + }, + }); + await recordAudit( + tx, + actor.organizationId, + actor.userId, + 'stock.adjusted', + entry.id, + ); + return entry; + }, + false, + ); + } +} diff --git a/src/inventory/inventory.module.ts b/src/inventory/inventory.module.ts new file mode 100644 index 0000000..e78ab12 --- /dev/null +++ b/src/inventory/inventory.module.ts @@ -0,0 +1,21 @@ +import { Module } from '@nestjs/common'; +import { DatabaseModule } from '../database/database.module'; +import { IdentityModule } from '../identity/identity.module'; +import { StockStore } from './stock.store'; +import { AdjustmentStore } from './adjustment.store'; +import { ReservationStore } from './reservation.store'; +import { ReservationTransitionStore } from './reservation-transition.store'; +import { StockController } from './stock.controller'; +import { ReservationsController } from './reservations.controller'; + +@Module({ + imports: [DatabaseModule, IdentityModule], + providers: [ + StockStore, + AdjustmentStore, + ReservationStore, + ReservationTransitionStore, + ], + controllers: [StockController, ReservationsController], +}) +export class InventoryModule {} diff --git a/src/inventory/inventory.policy.ts b/src/inventory/inventory.policy.ts new file mode 100644 index 0000000..c498f9a --- /dev/null +++ b/src/inventory/inventory.policy.ts @@ -0,0 +1,19 @@ +import { createHash } from 'node:crypto'; +import { AppError } from '../common/errors/app-error'; + +export function commandHash(...parts: (string | number)[]): string { + return createHash('sha256').update(JSON.stringify(parts)).digest('hex'); +} +export function assertReplay(expected: string, actual: string): void { + if (expected !== actual) throw new AppError('IDEMPOTENCY_CONFLICT'); +} +export function adjustedBalance( + onHand: number, + reserved: number, + delta: number, +): number { + const next = onHand + delta; + if (next < reserved || next < 0) throw new AppError('STOCK_INSUFFICIENT'); + if (next > 2_000_000_000) throw new AppError('STOCK_CAPACITY'); + return next; +} diff --git a/src/inventory/inventory.schemas.ts b/src/inventory/inventory.schemas.ts new file mode 100644 index 0000000..df65d7f --- /dev/null +++ b/src/inventory/inventory.schemas.ts @@ -0,0 +1,29 @@ +import { z } from 'zod'; +import { text } from '../common/input'; +export const warehouseSchema = z.object({ name: text(100) }).strict(); +export const stockSchema = z + .object({ variantId: z.uuid(), warehouseId: z.uuid() }) + .strict(); +export const adjustmentSchema = z + .object({ + stockItemId: z.uuid(), + delta: z + .number() + .int() + .min(-1_000_000) + .max(1_000_000) + .refine((value) => value !== 0), + reason: text(200), + idempotencyKey: z.uuid(), + }) + .strict(); +export const reservationSchema = z + .object({ + stockItemId: z.uuid(), + quantity: z.number().int().min(1).max(1_000_000), + ttlMinutes: z.number().int().min(1).max(60).default(15), + idempotencyKey: z.uuid(), + }) + .strict(); +export type AdjustmentInput = z.infer; +export type ReservationInput = z.infer; diff --git a/src/inventory/reservation-transition.store.ts b/src/inventory/reservation-transition.store.ts new file mode 100644 index 0000000..3233f0d --- /dev/null +++ b/src/inventory/reservation-transition.store.ts @@ -0,0 +1,74 @@ +import { Injectable } from '@nestjs/common'; +import { randomUUID } from 'node:crypto'; +import { AccessStore } from '../identity/access.store'; +import { recordAudit } from '../identity/audit'; +import type { Principal } from '../identity/identity.types'; +import { AppError } from '../common/errors/app-error'; +import { lockStock } from './stock-lock'; +import { commandHash } from './inventory.policy'; +import { reservationView } from './reservation.store'; + +@Injectable() +export class ReservationTransitionStore { + constructor(private readonly access: AccessStore) {} + transition(actor: Principal, id: string, commit: boolean) { + return this.access.mutate( + actor, + commit ? 'inventory.commit' : 'inventory.reserve', + async (tx) => { + const lookup = { + id, + organizationId: actor.organizationId, + ...(!commit ? { userId: actor.userId } : {}), + }; + const existing = await tx.stockReservation.findFirst({ where: lookup }); + if (!existing) throw new AppError('RESERVATION_NOT_FOUND'); + const stock = await lockStock( + tx, + actor.organizationId, + existing.stockItemId, + ); + const row = await tx.stockReservation.findUniqueOrThrow({ + where: { id }, + }); + const target = commit ? 'COMMITTED' : 'RELEASED'; + if (row.status === target) return reservationView(row); + if (row.status !== 'ACTIVE') throw new AppError('RESERVATION_CLOSED'); + if (commit) { + if (row.expiresAt <= new Date()) + throw new AppError('RESERVATION_EXPIRED'); + if (stock.onHand < row.quantity) + throw new AppError('STOCK_INSUFFICIENT'); + await tx.stockItem.update({ + where: { id: stock.id }, + data: { onHand: { decrement: row.quantity } }, + }); + await tx.stockLedger.create({ + data: { + stockItemId: stock.id, + organizationId: actor.organizationId, + actorId: actor.userId, + delta: -row.quantity, + reason: `Fulfil reservation ${id}`, + idempotencyKey: randomUUID(), + requestHash: commandHash(id, row.quantity), + }, + }); + } + const updated = await tx.stockReservation.update({ + where: { id }, + data: { status: target }, + }); + await recordAudit( + tx, + actor.organizationId, + actor.userId, + commit ? 'reservation.committed' : 'reservation.released', + id, + ); + return reservationView(updated); + }, + false, + ); + } +} diff --git a/src/inventory/reservation.store.ts b/src/inventory/reservation.store.ts new file mode 100644 index 0000000..0ea4cb2 --- /dev/null +++ b/src/inventory/reservation.store.ts @@ -0,0 +1,98 @@ +import { Injectable } from '@nestjs/common'; +import { DatabaseService } from '../database/database.service'; +import type { StockReservation } from '../generated/prisma/client'; +import { AccessStore } from '../identity/access.store'; +import { recordAudit } from '../identity/audit'; +import type { Principal } from '../identity/identity.types'; +import { AppError } from '../common/errors/app-error'; +import type { ReservationInput } from './inventory.schemas'; +import { lockStock, reservedQuantity } from './stock-lock'; +import { assertReplay, commandHash } from './inventory.policy'; + +export function reservationView(row: StockReservation) { + return { + ...row, + status: + row.status === 'ACTIVE' && row.expiresAt <= new Date() + ? 'EXPIRED' + : row.status, + }; +} +@Injectable() +export class ReservationStore { + constructor( + private readonly db: DatabaseService, + private readonly access: AccessStore, + ) {} + async get(actor: Principal, id: string) { + const row = await this.db.stockReservation.findFirst({ + where: { + id, + organizationId: actor.organizationId, + userId: actor.userId, + }, + }); + if (!row) throw new AppError('RESERVATION_NOT_FOUND'); + return reservationView(row); + } + reserve(actor: Principal, input: ReservationInput) { + const requestHash = commandHash( + input.stockItemId, + actor.userId, + input.quantity, + input.ttlMinutes, + ); + return this.access.mutate( + actor, + 'inventory.reserve', + async (tx) => { + const stock = await lockStock( + tx, + actor.organizationId, + input.stockItemId, + ); + const previous = await tx.stockReservation.findUnique({ + where: { + organizationId_idempotencyKey: { + organizationId: actor.organizationId, + idempotencyKey: input.idempotencyKey, + }, + }, + }); + if (previous) { + assertReplay(previous.requestHash, requestHash); + return reservationView(previous); + } + if ( + !stock.variant.active || + stock.variant.product.status !== 'PUBLISHED' + ) + throw new AppError('PRODUCT_UNAVAILABLE'); + const now = new Date(); + const reserved = await reservedQuantity(tx, stock.id, now); + if (input.quantity > stock.onHand - reserved) + throw new AppError('STOCK_INSUFFICIENT'); + const row = await tx.stockReservation.create({ + data: { + stockItemId: stock.id, + organizationId: actor.organizationId, + userId: actor.userId, + quantity: input.quantity, + idempotencyKey: input.idempotencyKey, + requestHash, + expiresAt: new Date(now.getTime() + input.ttlMinutes * 60_000), + }, + }); + await recordAudit( + tx, + actor.organizationId, + actor.userId, + 'stock.reserved', + row.id, + ); + return reservationView(row); + }, + false, + ); + } +} diff --git a/src/inventory/reservations.controller.ts b/src/inventory/reservations.controller.ts new file mode 100644 index 0000000..2d0b776 --- /dev/null +++ b/src/inventory/reservations.controller.ts @@ -0,0 +1,60 @@ +import { + Body, + Controller, + Get, + HttpCode, + Param, + ParseUUIDPipe, + Post, +} from '@nestjs/common'; +import { SchemaPipe } from '../common/validation.pipe'; +import { + CurrentPrincipal, + RequirePermission, +} from '../identity/access.decorator'; +import type { Principal } from '../identity/identity.types'; +import { ReservationStore } from './reservation.store'; +import { ReservationTransitionStore } from './reservation-transition.store'; +import { reservationSchema, type ReservationInput } from './inventory.schemas'; + +@Controller('inventory/reservations') +export class ReservationsController { + constructor( + private readonly reservations: ReservationStore, + private readonly transitions: ReservationTransitionStore, + ) {} + @Post() + @RequirePermission('inventory.reserve') + reserve( + @CurrentPrincipal() actor: Principal, + @Body(new SchemaPipe(reservationSchema)) input: ReservationInput, + ) { + return this.reservations.reserve(actor, input); + } + @Get(':id') + @RequirePermission('inventory.reserve') + get( + @CurrentPrincipal() actor: Principal, + @Param('id', ParseUUIDPipe) id: string, + ) { + return this.reservations.get(actor, id); + } + @Post(':id/release') + @HttpCode(200) + @RequirePermission('inventory.reserve') + release( + @CurrentPrincipal() actor: Principal, + @Param('id', ParseUUIDPipe) id: string, + ) { + return this.transitions.transition(actor, id, false); + } + @Post(':id/commit') + @HttpCode(200) + @RequirePermission('inventory.commit') + commit( + @CurrentPrincipal() actor: Principal, + @Param('id', ParseUUIDPipe) id: string, + ) { + return this.transitions.transition(actor, id, true); + } +} diff --git a/src/inventory/stock-lock.ts b/src/inventory/stock-lock.ts new file mode 100644 index 0000000..2e22f5b --- /dev/null +++ b/src/inventory/stock-lock.ts @@ -0,0 +1,32 @@ +import type { Prisma } from '../generated/prisma/client'; +import { AppError } from '../common/errors/app-error'; + +export async function lockStock( + tx: Prisma.TransactionClient, + organizationId: string, + id: string, +) { + await tx.$queryRaw`SELECT id FROM stock_items WHERE id = ${id}::uuid + AND organization_id = ${organizationId}::uuid FOR UPDATE`; + const stock = await tx.stockItem.findFirst({ + where: { id, organizationId }, + include: { variant: { include: { product: true } } }, + }); + if (!stock) throw new AppError('STOCK_NOT_FOUND'); + return stock; +} +export async function reservedQuantity( + tx: Prisma.TransactionClient, + stockItemId: string, + now: Date, +) { + const sum = await tx.stockReservation.aggregate({ + where: { + stockItemId, + status: 'ACTIVE', + expiresAt: { gt: now }, + }, + _sum: { quantity: true }, + }); + return sum._sum.quantity ?? 0; +} diff --git a/src/inventory/stock.controller.ts b/src/inventory/stock.controller.ts new file mode 100644 index 0000000..e1e8511 --- /dev/null +++ b/src/inventory/stock.controller.ts @@ -0,0 +1,90 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Post, + Query, +} from '@nestjs/common'; +import { SchemaPipe } from '../common/validation.pipe'; +import { + CurrentPrincipal, + RequirePermission, +} from '../identity/access.decorator'; +import type { Principal } from '../identity/identity.types'; +import { pageSchema, type PageInput } from '../identity/identity.schemas'; +import { StockStore } from './stock.store'; +import { AdjustmentStore } from './adjustment.store'; +import { + warehouseSchema, + stockSchema, + adjustmentSchema, + type AdjustmentInput, +} from './inventory.schemas'; + +@Controller('inventory') +export class StockController { + constructor( + private readonly stocks: StockStore, + private readonly adjustments: AdjustmentStore, + ) {} + @Get('warehouses') + @RequirePermission('inventory.read') + warehouses( + @CurrentPrincipal() actor: Principal, + @Query(new SchemaPipe(pageSchema)) page: PageInput, + ) { + return this.stocks.warehouses(actor, page); + } + @Post('warehouses') + @RequirePermission('inventory.manage') + warehouse( + @CurrentPrincipal() actor: Principal, + @Body(new SchemaPipe(warehouseSchema)) input: { name: string }, + ) { + return this.stocks.createWarehouse(actor, input.name); + } + @Get('stock-items') + @RequirePermission('inventory.read') + list( + @CurrentPrincipal() actor: Principal, + @Query(new SchemaPipe(pageSchema)) page: PageInput, + ) { + return this.stocks.list(actor, page); + } + @Post('stock-items') + @RequirePermission('inventory.manage') + create( + @CurrentPrincipal() actor: Principal, + @Body(new SchemaPipe(stockSchema)) + input: { variantId: string; warehouseId: string }, + ) { + return this.stocks.create(actor, input); + } + @Get('stock-items/:id') + @RequirePermission('inventory.read') + get( + @CurrentPrincipal() actor: Principal, + @Param('id', ParseUUIDPipe) id: string, + ) { + return this.stocks.get(actor, id); + } + @Get('stock-items/:id/ledger') + @RequirePermission('inventory.read') + ledger( + @CurrentPrincipal() actor: Principal, + @Param('id', ParseUUIDPipe) id: string, + @Query(new SchemaPipe(pageSchema)) page: PageInput, + ) { + return this.stocks.ledger(actor, id, page); + } + @Post('adjustments') + @RequirePermission('inventory.adjust') + adjust( + @CurrentPrincipal() actor: Principal, + @Body(new SchemaPipe(adjustmentSchema)) input: AdjustmentInput, + ) { + return this.adjustments.adjust(actor, input); + } +} diff --git a/src/inventory/stock.store.ts b/src/inventory/stock.store.ts new file mode 100644 index 0000000..6121b76 --- /dev/null +++ b/src/inventory/stock.store.ts @@ -0,0 +1,113 @@ +import { Injectable } from '@nestjs/common'; +import { DatabaseService } from '../database/database.service'; +import { AccessStore } from '../identity/access.store'; +import { recordAudit } from '../identity/audit'; +import type { Principal } from '../identity/identity.types'; +import type { PageInput } from '../identity/identity.schemas'; +import { AppError } from '../common/errors/app-error'; +import { lockStock, reservedQuantity } from './stock-lock'; + +@Injectable() +export class StockStore { + constructor( + private readonly db: DatabaseService, + private readonly access: AccessStore, + ) {} + warehouses(actor: Principal, page: PageInput) { + return this.db.warehouse.findMany({ + where: { organizationId: actor.organizationId }, + orderBy: { id: 'asc' }, + take: page.limit, + skip: page.offset, + }); + } + createWarehouse(actor: Principal, name: string) { + return this.access.mutate(actor, 'inventory.manage', async (tx) => { + const warehouse = await tx.warehouse.create({ + data: { organizationId: actor.organizationId, name }, + }); + await recordAudit( + tx, + actor.organizationId, + actor.userId, + 'warehouse.created', + warehouse.id, + ); + return warehouse; + }); + } + create(actor: Principal, input: { warehouseId: string; variantId: string }) { + return this.access.mutate(actor, 'inventory.manage', async (tx) => { + if ( + !(await tx.warehouse.findFirst({ + where: { + id: input.warehouseId, + organizationId: actor.organizationId, + }, + })) + ) { + throw new AppError('WAREHOUSE_NOT_FOUND'); + } + if ( + !(await tx.productVariant.findFirst({ + where: { id: input.variantId, organizationId: actor.organizationId }, + })) + ) { + throw new AppError('VARIANT_NOT_FOUND'); + } + const stock = await tx.stockItem.create({ + data: { ...input, organizationId: actor.organizationId }, + }); + await recordAudit( + tx, + actor.organizationId, + actor.userId, + 'stock.created', + stock.id, + ); + return stock; + }); + } + list(actor: Principal, page: PageInput) { + return this.db.stockItem.findMany({ + where: { organizationId: actor.organizationId }, + orderBy: { id: 'asc' }, + take: page.limit, + skip: page.offset, + }); + } + get(actor: Principal, id: string) { + return this.access.mutate( + actor, + 'inventory.read', + async (tx) => { + const stock = await lockStock(tx, actor.organizationId, id); + const reserved = await reservedQuantity(tx, id, new Date()); + return { + id, + variantId: stock.variantId, + warehouseId: stock.warehouseId, + onHand: stock.onHand, + reserved, + available: stock.onHand - reserved, + }; + }, + false, + ); + } + async ledger(actor: Principal, id: string, page: PageInput) { + if ( + !(await this.db.stockItem.findFirst({ + where: { id, organizationId: actor.organizationId }, + })) + ) { + throw new AppError('STOCK_NOT_FOUND'); + } + return this.db.stockLedger.findMany({ + where: { stockItemId: id, organizationId: actor.organizationId }, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + take: page.limit, + skip: page.offset, + }); + } +} diff --git a/test/commerce-policy.spec.ts b/test/commerce-policy.spec.ts new file mode 100644 index 0000000..1825564 --- /dev/null +++ b/test/commerce-policy.spec.ts @@ -0,0 +1,81 @@ +import { ProductStore } from '../src/catalog/product.store'; +import { VariantStore } from '../src/catalog/variant.store'; +import { AddressStore } from '../src/addresses/address.store'; +import { StockStore } from '../src/inventory/stock.store'; +import { AccessStore } from '../src/identity/access.store'; +import { DatabaseService } from '../src/database/database.service'; +import type { Principal } from '../src/identity/identity.types'; +import { addressInput, variantInput } from './helpers/commerce'; + +describe('commerce use-case constraints', () => { + const actor: Principal = { + userId: 'user', + organizationId: 'org', + sessionId: 'session', + permissions: [], + }; + const tx = { + product: { findFirst: jest.fn() }, + catalogGroup: { count: jest.fn() }, + productVariant: { count: jest.fn(), findFirst: jest.fn() }, + address: { count: jest.fn(), findFirst: jest.fn() }, + warehouse: { findFirst: jest.fn() }, + }; + const access = { mutate: jest.fn() }; + beforeEach(() => { + jest.resetAllMocks(); + access.mutate.mockImplementation((_actor, _permission, work) => + work(tx, actor), + ); + tx.product.findFirst.mockResolvedValue({ id: 'product', status: 'DRAFT' }); + }); + it('rejects group links outside the organization', async () => { + tx.catalogGroup.count.mockResolvedValue(0); + await expect( + new ProductStore( + {} as DatabaseService, + access as unknown as AccessStore, + ).save(actor, { + name: 'Product', + slug: 'product', + description: '', + groupIds: ['foreign'], + }), + ).rejects.toThrow('Catalogue group not found'); + }); + it('limits variants and hides unknown variant IDs', async () => { + const store = new VariantStore(access as unknown as AccessStore); + tx.productVariant.count.mockResolvedValue(100); + await expect(store.save(actor, 'product', variantInput)).rejects.toThrow( + 'variant limit', + ); + tx.productVariant.findFirst.mockResolvedValue(null); + await expect( + store.save(actor, 'product', variantInput, 'foreign'), + ).rejects.toThrow('variant not found'); + }); + it('limits user address count', async () => { + tx.address.count.mockResolvedValue(20); + await expect( + new AddressStore( + {} as DatabaseService, + access as unknown as AccessStore, + ).save(actor, addressInput), + ).rejects.toThrow('Address limit'); + }); + it('rejects foreign warehouses and variants before creating stock', async () => { + const store = new StockStore( + {} as DatabaseService, + access as unknown as AccessStore, + ); + tx.warehouse.findFirst.mockResolvedValue(null); + await expect( + store.create(actor, { warehouseId: 'bad', variantId: 'variant' }), + ).rejects.toThrow('Warehouse not found'); + tx.warehouse.findFirst.mockResolvedValue({ id: 'warehouse' }); + tx.productVariant.findFirst.mockResolvedValue(null); + await expect( + store.create(actor, { warehouseId: 'warehouse', variantId: 'bad' }), + ).rejects.toThrow('variant not found'); + }); +}); diff --git a/test/inventory-policy.spec.ts b/test/inventory-policy.spec.ts new file mode 100644 index 0000000..805d927 --- /dev/null +++ b/test/inventory-policy.spec.ts @@ -0,0 +1,53 @@ +import { randomUUID } from 'node:crypto'; +import { + adjustedBalance, + assertReplay, + commandHash, +} from '../src/inventory/inventory.policy'; +import { + reservationSchema, + adjustmentSchema, +} from '../src/inventory/inventory.schemas'; +import { variantSchema } from '../src/catalog/catalog.schemas'; + +describe('inventory and catalogue input policy', () => { + it('protects reserved stock and integer capacity', () => { + expect(adjustedBalance(10, 5, -5)).toBe(5); + expect(() => adjustedBalance(10, 5, -6)).toThrow( + 'Insufficient available stock', + ); + expect(() => adjustedBalance(0, 0, -1)).toThrow(); + expect(() => adjustedBalance(2_000_000_000, 0, 1)).toThrow( + 'Stock balance limit', + ); + }); + it('distinguishes idempotent request payloads', () => { + const hash = commandHash('stock', 1, 'reason'); + expect(() => assertReplay(hash, hash)).not.toThrow(); + expect(() => assertReplay(hash, commandHash('stock', 2, 'reason'))).toThrow( + 'different request', + ); + }); + it('bounds reservation quantity and duration', () => { + const input = { + stockItemId: randomUUID(), + quantity: 1, + idempotencyKey: randomUUID(), + }; + expect(reservationSchema.parse(input).ttlMinutes).toBe(15); + expect( + reservationSchema.safeParse({ ...input, ttlMinutes: 61 }).success, + ).toBe(false); + expect( + adjustmentSchema.safeParse({ ...input, delta: 0, reason: 'bad' }).success, + ).toBe(false); + }); + it.each(['0.00', '-1.00', '1.001', '01.00', '10000000000.00'])( + 'rejects invalid prices %s', + (price) => { + expect( + variantSchema.safeParse({ name: 'Test', sku: 'SKU', price }).success, + ).toBe(false); + }, + ); +}); diff --git a/test/inventory.spec.ts b/test/inventory.spec.ts new file mode 100644 index 0000000..b079720 --- /dev/null +++ b/test/inventory.spec.ts @@ -0,0 +1,255 @@ +import { randomUUID } from 'node:crypto'; +import { identityApp, type IdentityApp } from './helpers/identity-app'; +import { seedStock, secondActor } from './helpers/commerce'; + +describe('stock ledger and reservations', () => { + let ctx: IdentityApp; + beforeAll(async () => { + ctx = await identityApp(); + }, 60000); + afterAll(async () => { + await ctx?.close(); + }); + beforeEach(async () => { + await ctx.clearLimits(); + }); + const auth = { type: 'bearer' as const }; + const reserve = ( + stockItemId: string, + quantity: number, + idempotencyKey = randomUUID(), + ) => + ctx + .api() + .post('/api/v1/inventory/reservations') + .auth(ctx.token, auth) + .send({ stockItemId, quantity, idempotencyKey }); + + it('replays identical adjustments once and rejects changed payloads', async () => { + const { stock } = await seedStock(ctx); + const input = { + stockItemId: stock.id, + delta: 5, + reason: 'Count correction', + idempotencyKey: randomUUID(), + }; + const first = await ctx + .api() + .post('/api/v1/inventory/adjustments') + .auth(ctx.token, auth) + .send(input) + .expect(201); + const again = await ctx + .api() + .post('/api/v1/inventory/adjustments') + .auth(ctx.token, auth) + .send(input) + .expect(201); + expect(again.body.id).toBe(first.body.id); + const conflict = await ctx + .api() + .post('/api/v1/inventory/adjustments') + .auth(ctx.token, auth) + .send({ ...input, delta: 6 }) + .expect(409); + expect(conflict.body.code).toBe('IDEMPOTENCY_CONFLICT'); + const balance = await ctx + .api() + .get(`/api/v1/inventory/stock-items/${stock.id}`) + .auth(ctx.token, auth) + .expect(200); + expect(balance.body).toMatchObject({ + onHand: 15, + reserved: 0, + available: 15, + }); + }); + it('prevents overselling and adjustments below active reservations', async () => { + const { stock } = await seedStock(ctx); + const key = randomUUID(); + const first = await reserve(stock.id, 7, key).expect(201); + expect((await reserve(stock.id, 7, key).expect(201)).body.id).toBe( + first.body.id, + ); + await reserve(stock.id, 8, key).expect(409); + const denied = await reserve(stock.id, 4).expect(409); + expect(denied.body.code).toBe('STOCK_INSUFFICIENT'); + await ctx + .api() + .post('/api/v1/inventory/adjustments') + .auth(ctx.token, auth) + .send({ + stockItemId: stock.id, + delta: -4, + reason: 'Bad correction', + idempotencyKey: randomUUID(), + }) + .expect(409); + const balance = await ctx + .api() + .get(`/api/v1/inventory/stock-items/${stock.id}`) + .auth(ctx.token, auth) + .expect(200); + expect(balance.body).toMatchObject({ + onHand: 10, + reserved: 7, + available: 3, + }); + }); + it('releases a hold idempotently and prevents committing a released hold', async () => { + const { stock } = await seedStock(ctx); + const hold = await reserve(stock.id, 4).expect(201); + const route = `/api/v1/inventory/reservations/${hold.body.id}`; + await ctx.api().get(route).auth(ctx.token, auth).expect(200); + await ctx + .api() + .post(route + '/release') + .auth(ctx.token, auth) + .expect(200); + await ctx + .api() + .post(route + '/release') + .auth(ctx.token, auth) + .expect(200); + const denied = await ctx + .api() + .post(route + '/commit') + .auth(ctx.token, auth) + .expect(409); + expect(denied.body.code).toBe('RESERVATION_CLOSED'); + expect( + ( + await ctx + .api() + .get(`/api/v1/inventory/stock-items/${stock.id}`) + .auth(ctx.token, auth) + ).body.available, + ).toBe(10); + }); + it('fulfils once and reconciles on-hand balance with the ledger', async () => { + const { stock } = await seedStock(ctx); + const hold = await reserve(stock.id, 4).expect(201); + const route = `/api/v1/inventory/reservations/${hold.body.id}/commit`; + await ctx.api().post(route).auth(ctx.token, auth).expect(200); + await ctx.api().post(route).auth(ctx.token, auth).expect(200); + const ledger = await ctx + .api() + .get(`/api/v1/inventory/stock-items/${stock.id}/ledger`) + .auth(ctx.token, auth) + .expect(200); + expect( + ledger.body.reduce( + (sum: number, row: { delta: number }) => sum + row.delta, + 0, + ), + ).toBe(6); + expect(ledger.body).toHaveLength(2); + expect( + ( + await ctx + .api() + .get(`/api/v1/inventory/stock-items/${stock.id}`) + .auth(ctx.token, auth) + ).body.onHand, + ).toBe(6); + await expect( + ctx.executeSql( + `UPDATE stock_ledger SET delta = 99 WHERE id = '${ledger.body[0].id}'`, + ), + ).rejects.toThrow('append-only'); + }); + it('ignores expired holds without a cleanup job and rejects late commit', async () => { + const { stock } = await seedStock(ctx); + const hold = await reserve(stock.id, 10).expect(201); + await ctx.db.stockReservation.update({ + where: { id: hold.body.id }, + data: { expiresAt: new Date(0) }, + }); + expect( + ( + await ctx + .api() + .get(`/api/v1/inventory/reservations/${hold.body.id}`) + .auth(ctx.token, auth) + ).body.status, + ).toBe('EXPIRED'); + const response = await ctx + .api() + .post(`/api/v1/inventory/reservations/${hold.body.id}/commit`) + .auth(ctx.token, auth) + .expect(409); + expect(response.body.code).toBe('RESERVATION_EXPIRED'); + await reserve(stock.id, 10).expect(201); + }); + it('enforces scope, reservation ownership and product sellability', async () => { + const { stock, product } = await seedStock(ctx); + const outsider = await secondActor(ctx, false, [ + 'inventory.read', + 'inventory.reserve', + ]); + await ctx + .api() + .get(`/api/v1/inventory/stock-items/${stock.id}`) + .auth(outsider.token, auth) + .expect(404); + const hold = await reserve(stock.id, 1).expect(201); + const colleague = await secondActor(ctx, true, ['inventory.reserve']); + await ctx + .api() + .get(`/api/v1/inventory/reservations/${hold.body.id}`) + .auth(colleague.token, auth) + .expect(404); + await ctx + .api() + .post(`/api/v1/inventory/reservations/${hold.body.id}/release`) + .auth(colleague.token, auth) + .expect(404); + await ctx + .api() + .patch(`/api/v1/products/${product.id}/status`) + .auth(ctx.token, auth) + .send({ status: 'DRAFT' }) + .expect(200); + expect((await reserve(stock.id, 1).expect(409)).body.code).toBe( + 'PRODUCT_UNAVAILABLE', + ); + }); + it('lists scoped warehouses and stock and rejects malformed quantities', async () => { + const { stock } = await seedStock(ctx); + await ctx + .api() + .get('/api/v1/inventory/warehouses?limit=2') + .auth(ctx.token, auth) + .expect(200); + await ctx + .api() + .get('/api/v1/inventory/stock-items?limit=2') + .auth(ctx.token, auth) + .expect(200); + await reserve(stock.id, 0).expect(400); + await reserve(stock.id, 1.5).expect(400); + await ctx + .api() + .post('/api/v1/inventory/adjustments') + .auth(ctx.token, auth) + .send({ + stockItemId: stock.id, + delta: 0, + reason: 'Invalid', + idempotencyKey: randomUUID(), + }) + .expect(400); + }); + const nativeOnly = process.env.TEST_DATABASE_URL ? it : it.skip; + nativeOnly( + 'serializes competing reservations on native PostgreSQL', + async () => { + const { stock } = await seedStock(ctx, 1); + const results = await Promise.all([ + reserve(stock.id, 1), + reserve(stock.id, 1), + ]); + expect(results.map((row) => row.status).sort()).toEqual([201, 409]); + }, + ); +});