feat(inventory): add immutable ledger and transactional reservations

This commit is contained in:
mihir 2026-09-10 11:23:23 +05:30
parent 4457cfdf1a
commit b3624cc026
14 changed files with 1003 additions and 1 deletions

View File

@ -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 { Module } from '@nestjs/common';
import { EnvironmentModule } from './config/environment.module'; import { EnvironmentModule } from './config/environment.module';
import { IdentityModule } from './identity/identity.module'; import { IdentityModule } from './identity/identity.module';
import { HealthModule } from './health/health.module'; import { HealthModule } from './health/health.module';
@Module({ imports: [EnvironmentModule, HealthModule, IdentityModule] }) @Module({
imports: [
EnvironmentModule,
HealthModule,
IdentityModule,
CatalogModule,
AddressesModule,
InventoryModule,
],
})
export class AppModule {} export class AppModule {}

View File

@ -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,
);
}
}

View File

@ -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 {}

View File

@ -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;
}

View File

@ -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<typeof adjustmentSchema>;
export type ReservationInput = z.infer<typeof reservationSchema>;

View File

@ -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,
);
}
}

View File

@ -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,
);
}
}

View File

@ -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);
}
}

View File

@ -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;
}

View File

@ -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);
}
}

View File

@ -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,
});
}
}

View File

@ -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');
});
});

View File

@ -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);
},
);
});

255
test/inventory.spec.ts Normal file
View File

@ -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]);
},
);
});