feat(inventory): add immutable ledger and transactional reservations
This commit is contained in:
parent
4457cfdf1a
commit
b3624cc026
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>;
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -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]);
|
||||
},
|
||||
);
|
||||
});
|
||||
Loading…
Reference in New Issue