feat(POS-002): completed feature

This commit is contained in:
chattie
2026-08-21 21:55:43 +02:00
parent c68cb25ed6
commit 0a1d32ecc3
33 changed files with 1169 additions and 107 deletions

View File

@@ -1,4 +1,4 @@
import type { InventoryServicePort } from '../../inventory/index.js';
import { DEFAULT_STORE_ID, type InventoryServicePort } from '../../inventory/index.js';
import type { PricingServicePort } from '../../pricing/index.js';
import type { PromotionServicePort } from '../../promotions/index.js';
import { InvalidCartQuantityError, InsufficientCartStockError } from '../domain/errors.js';
@@ -30,7 +30,7 @@ export class CartService {
}
private async assertStockAvailable(variantId: string, quantity: number): Promise<void> {
const av = await this.inventory.checkAvailability(variantId, quantity);
const av = await this.inventory.checkAvailability(variantId, DEFAULT_STORE_ID, quantity);
if (!av.available) {
throw new InsufficientCartStockError(variantId, quantity, av.availableQuantity);
}
@@ -59,7 +59,7 @@ export class CartService {
if (error instanceof Error && error.name === 'PriceNotFoundError') return null;
throw error;
}),
this.inventory.checkAvailability(item.variantId, item.quantity),
this.inventory.checkAvailability(item.variantId, DEFAULT_STORE_ID, item.quantity),
]);
return {
...item,

View File

@@ -1,4 +1,4 @@
import type { InventoryServicePort } from '../../inventory/index.js';
import { DEFAULT_STORE_ID, type InventoryServicePort } from '../../inventory/index.js';
import type { OrderItemInput, OrderServicePort } from '../../orders/index.js';
import type { PricingServicePort } from '../../pricing/index.js';
import type { PromotionServicePort } from '../../promotions/index.js';
@@ -97,6 +97,7 @@ export class CheckoutService {
}
const availability = await this.deps.inventory.checkAvailability(
cartItem.variantId,
DEFAULT_STORE_ID,
cartItem.quantity,
);
if (!availability.available) {
@@ -174,12 +175,18 @@ export class CheckoutService {
const reserved: string[] = [];
try {
for (const item of cart.items) {
await this.deps.inventory.reserve({ variantId: item.variantId, quantity: item.quantity });
await this.deps.inventory.reserve({
variantId: item.variantId,
storeId: DEFAULT_STORE_ID,
quantity: item.quantity,
});
reserved.push(item.variantId);
}
} catch (error) {
for (const variantId of reserved) {
await this.deps.inventory.release({ variantId, quantity: 1 }).catch(() => undefined);
await this.deps.inventory
.release({ variantId, storeId: DEFAULT_STORE_ID, quantity: 1 })
.catch(() => undefined);
}
await this.deps.orders
.transition(orderView.id, 'CANCELLED', command.userId)

View File

@@ -211,7 +211,13 @@ describe('CheckoutService', () => {
idempotencyKey: 'k-1',
});
expect(result.order.state).toBe('AWAITING_PAYMENT');
expect(deps.reserved.calls).toEqual([{ variantId: 'v-1', quantity: 1 }]);
expect(deps.reserved.calls).toEqual([
{
variantId: 'v-1',
storeId: '00000000-0000-0000-0000-000000000001',
quantity: 1,
},
]);
expect(deps.metrics.success).toBe(1);
});

View File

@@ -13,6 +13,7 @@ import {
InvalidStockQuantityError,
} from '../domain/errors.js';
import type { SetAvailableStockCommand, StockItem } from '../domain/stock.js';
import { DEFAULT_STORE_ID } from '../domain/stock.js';
import { PgInventoryRepository } from '../infrastructure/pg-inventory-repository.js';
export interface InventoryRoutesDeps {
@@ -23,9 +24,16 @@ export interface InventoryRoutesDeps {
const variantParamSchema = z.object({ variantId: z.uuid() });
const availabilityQuerySchema = z.object({
quantity: z.coerce.number().int().positive().default(1),
storeId: z.uuid().optional(),
});
const stockBodySchema = z.object({
quantity: z.number().int().min(0),
storeId: z.uuid().optional(),
});
const stockCommandBodySchema = z.object({
quantity: z.number().int().positive(),
storeId: z.uuid().optional(),
});
const stockBodySchema = z.object({ quantity: z.number().int().min(0) });
const stockCommandBodySchema = z.object({ quantity: z.number().int().positive() });
const bulkAdjustItemSchema = z.object({
variantId: z.uuid(),
@@ -49,15 +57,25 @@ export async function registerInventoryRoutes(
required: ['variantId'],
properties: { variantId: { type: 'string', format: 'uuid' } },
},
querystring: { type: 'object', properties: { quantity: { type: 'integer', default: 1 } } },
querystring: {
type: 'object',
properties: {
quantity: { type: 'integer', default: 1 },
storeId: { type: 'string', format: 'uuid' },
},
},
};
app.get(
'/inventory/:variantId/availability',
{ schema: availabilitySchema },
async (request, reply) => {
const { variantId } = parseJson(variantParamSchema, request.params);
const { quantity } = parseJson(availabilityQuerySchema, request.query);
const availability = await inventory.checkAvailability(variantId, quantity);
const { quantity, storeId } = parseJson(availabilityQuerySchema, request.query);
const availability = await inventory.checkAvailability(
variantId,
storeId ?? DEFAULT_STORE_ID,
quantity,
);
return reply.send(availability);
},
);
@@ -81,9 +99,13 @@ export async function registerInventoryRoutes(
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const { variantId } = parseJson(variantParamSchema, request.params);
const { quantity } = parseJson(stockBodySchema, request.body);
const { quantity, storeId } = parseJson(stockBodySchema, request.body);
try {
const item = await inventory.setAvailable({ variantId, quantity });
const item = await inventory.setAvailable({
variantId,
storeId: storeId ?? DEFAULT_STORE_ID,
quantity,
});
return reply.send(serializeStockItem(item));
} catch (error) {
throw mapInventoryError(error);
@@ -112,9 +134,13 @@ export async function registerInventoryRoutes(
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const { variantId } = parseJson(variantParamSchema, request.params);
const { quantity } = parseJson(stockCommandBodySchema, request.body);
const { quantity, storeId } = parseJson(stockCommandBodySchema, request.body);
try {
const item = await inventory.reserve({ variantId, quantity });
const item = await inventory.reserve({
variantId,
storeId: storeId ?? DEFAULT_STORE_ID,
quantity,
});
return reply.code(201).send(serializeStockItem(item));
} catch (error) {
throw mapInventoryError(error);
@@ -144,9 +170,13 @@ export async function registerInventoryRoutes(
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const { variantId } = parseJson(variantParamSchema, request.params);
const { quantity } = parseJson(stockCommandBodySchema, request.body);
const { quantity, storeId } = parseJson(stockCommandBodySchema, request.body);
try {
const item = await inventory.release({ variantId, quantity });
const item = await inventory.release({
variantId,
storeId: storeId ?? DEFAULT_STORE_ID,
quantity,
});
return reply.send(serializeStockItem(item));
} catch (error) {
throw mapInventoryError(error);
@@ -176,9 +206,13 @@ export async function registerInventoryRoutes(
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const { variantId } = parseJson(variantParamSchema, request.params);
const { quantity } = parseJson(stockCommandBodySchema, request.body);
const { quantity, storeId } = parseJson(stockCommandBodySchema, request.body);
try {
const item = await inventory.confirm({ variantId, quantity });
const item = await inventory.confirm({
variantId,
storeId: storeId ?? DEFAULT_STORE_ID,
quantity,
});
return reply.send(serializeStockItem(item));
} catch (error) {
throw mapInventoryError(error);
@@ -223,6 +257,7 @@ export async function registerInventoryRoutes(
for (const item of items) {
const command: SetAvailableStockCommand = {
variantId: item.variantId,
storeId: DEFAULT_STORE_ID,
quantity: item.quantity,
};
@@ -230,6 +265,7 @@ export async function registerInventoryRoutes(
const result = await client.query<{
id: string;
variant_id: string;
store_id: string;
available: number;
reserved: number;
sold: number;
@@ -237,19 +273,19 @@ export async function registerInventoryRoutes(
created_at: Date;
updated_at: Date;
}>(
`INSERT INTO inventory_stock (variant_id, available)
VALUES ($1, $2)
ON CONFLICT (variant_id) DO UPDATE
`INSERT INTO inventory_stock (variant_id, store_id, available)
VALUES ($1, $2, $3)
ON CONFLICT (variant_id, store_id) DO UPDATE
SET available = EXCLUDED.available, updated_at = now()
RETURNING *`,
[command.variantId, command.quantity],
[command.variantId, command.storeId, command.quantity],
);
// Log the movement
await client.query(
`INSERT INTO inventory_movements (variant_id, operation, quantity)
VALUES ($1, $2, $3)`,
[command.variantId, 'bulk_adjust', command.quantity],
`INSERT INTO inventory_movements (variant_id, store_id, operation, quantity)
VALUES ($1, $2, $3, $4)`,
[command.variantId, command.storeId, 'bulk_adjust', command.quantity],
);
const row = result.rows[0];
@@ -259,6 +295,7 @@ export async function registerInventoryRoutes(
results.push({
id: row.id,
variantId: row.variant_id,
storeId: row.store_id,
available: row.available,
reserved: row.reserved,
sold: row.sold,

View File

@@ -17,9 +17,9 @@ import type {
export class InventoryService implements InventoryServicePort {
constructor(private readonly repository: InventoryRepository) {}
async checkAvailability(variantId: string, quantity: number): Promise<Availability> {
async checkAvailability(variantId: string, storeId: string, quantity: number): Promise<Availability> {
ensurePositiveQuantity(quantity);
const item = await this.repository.findByVariantId(variantId);
const item = await this.repository.findByVariantId(variantId, storeId);
const availableQuantity = item?.available ?? 0;
return { available: availableQuantity >= quantity, availableQuantity };
}

View File

@@ -1,7 +1,7 @@
import type { Availability, SetAvailableStockCommand, StockCommand, StockItem } from './stock.js';
export interface InventoryService {
checkAvailability(variantId: string, quantity: number): Promise<Availability>;
checkAvailability(variantId: string, storeId: string, quantity: number): Promise<Availability>;
reserve(input: StockCommand): Promise<StockItem>;
release(input: StockCommand): Promise<StockItem>;
confirm(input: StockCommand): Promise<StockItem>;
@@ -9,7 +9,7 @@ export interface InventoryService {
}
export interface InventoryRepository {
findByVariantId(variantId: string): Promise<StockItem | undefined>;
findByVariantId(variantId: string, storeId: string): Promise<StockItem | undefined>;
setAvailable(input: SetAvailableStockCommand): Promise<StockItem>;
reserve(input: StockCommand): Promise<StockItem | undefined>;
release(input: StockCommand): Promise<StockItem | undefined>;

View File

@@ -1,8 +1,16 @@
export type StockState = 'available' | 'reserved' | 'sold' | 'incoming';
/**
* Well-known UUID of the default store seeded by migration 043 and used by
* the legacy ecommerce flow. Multi-store terminals pass their own storeId;
* the ecommerce flow passes this constant.
*/
export const DEFAULT_STORE_ID = '00000000-0000-0000-0000-000000000001';
export interface StockItem {
id: string;
variantId: string;
storeId: string;
available: number;
reserved: number;
sold: number;
@@ -13,6 +21,7 @@ export interface StockItem {
export interface StockCommand {
variantId: string;
storeId: string;
quantity: number;
}

View File

@@ -15,6 +15,7 @@ export type {
InventoryService as InventoryServicePort,
} from './domain/ports.js';
export type { Availability, StockCommand, StockItem, StockState } from './domain/stock.js';
export { DEFAULT_STORE_ID } from './domain/stock.js';
export function createInventoryService(pool: pg.Pool): InventoryService {
return new InventoryService(new PgInventoryRepository(pool));

View File

@@ -5,6 +5,7 @@ import type { SetAvailableStockCommand, StockCommand, StockItem } from '../domai
interface StockRow {
id: string;
variant_id: string;
store_id: string;
available: number;
reserved: number;
sold: number;
@@ -18,10 +19,10 @@ type InventoryOperation = 'reserve' | 'release' | 'confirm' | 'set_available';
export class PgInventoryRepository implements InventoryRepository {
constructor(private readonly pool: pg.Pool) {}
async findByVariantId(variantId: string): Promise<StockItem | undefined> {
async findByVariantId(variantId: string, storeId: string): Promise<StockItem | undefined> {
const result = await this.pool.query<StockRow>(
'SELECT * FROM inventory_stock WHERE variant_id = $1',
[variantId],
'SELECT * FROM inventory_stock WHERE variant_id = $1 AND store_id = $2',
[variantId, storeId],
);
const row = result.rows[0];
return row ? toStockItem(row) : undefined;
@@ -32,15 +33,15 @@ export class PgInventoryRepository implements InventoryRepository {
try {
await client.query('BEGIN');
const result = await client.query<StockRow>(
`INSERT INTO inventory_stock (variant_id, available)
VALUES ($1, $2)
ON CONFLICT (variant_id) DO UPDATE
`INSERT INTO inventory_stock (variant_id, store_id, available)
VALUES ($1, $2, $3)
ON CONFLICT (variant_id, store_id) DO UPDATE
SET available = EXCLUDED.available, updated_at = now()
RETURNING *`,
[input.variantId, input.quantity],
[input.variantId, input.storeId, input.quantity],
);
const item = rowOrThrow(result.rows[0], 'inventory_stock upsert returned no row');
await insertMovement(client, input.variantId, 'set_available', input.quantity);
await insertMovement(client, input.variantId, input.storeId, 'set_available', input.quantity);
await client.query('COMMIT');
return item;
} catch (error) {
@@ -56,9 +57,9 @@ export class PgInventoryRepository implements InventoryRepository {
client.query<StockRow>(
`UPDATE inventory_stock
SET available = available - $2, reserved = reserved + $2, updated_at = now()
WHERE variant_id = $1 AND available >= $2
WHERE variant_id = $1 AND store_id = $3 AND available >= $2
RETURNING *`,
[command.variantId, command.quantity],
[command.variantId, command.quantity, command.storeId],
),
);
}
@@ -68,9 +69,9 @@ export class PgInventoryRepository implements InventoryRepository {
client.query<StockRow>(
`UPDATE inventory_stock
SET reserved = reserved - $2, available = available + $2, updated_at = now()
WHERE variant_id = $1 AND reserved >= $2
WHERE variant_id = $1 AND store_id = $3 AND reserved >= $2
RETURNING *`,
[command.variantId, command.quantity],
[command.variantId, command.quantity, command.storeId],
),
);
}
@@ -80,9 +81,9 @@ export class PgInventoryRepository implements InventoryRepository {
client.query<StockRow>(
`UPDATE inventory_stock
SET reserved = reserved - $2, sold = sold + $2, updated_at = now()
WHERE variant_id = $1 AND reserved >= $2
WHERE variant_id = $1 AND store_id = $3 AND reserved >= $2
RETURNING *`,
[command.variantId, command.quantity],
[command.variantId, command.quantity, command.storeId],
),
);
}
@@ -101,7 +102,7 @@ export class PgInventoryRepository implements InventoryRepository {
await client.query('ROLLBACK');
return undefined;
}
await insertMovement(client, input.variantId, operation, input.quantity);
await insertMovement(client, input.variantId, input.storeId, operation, input.quantity);
await client.query('COMMIT');
return toStockItem(row);
} catch (error) {
@@ -116,13 +117,14 @@ export class PgInventoryRepository implements InventoryRepository {
async function insertMovement(
client: pg.PoolClient,
variantId: string,
storeId: string,
operation: InventoryOperation,
quantity: number,
): Promise<void> {
await client.query(
`INSERT INTO inventory_movements (variant_id, operation, quantity)
VALUES ($1, $2, $3)`,
[variantId, operation, quantity],
`INSERT INTO inventory_movements (variant_id, store_id, operation, quantity)
VALUES ($1, $2, $3, $4)`,
[variantId, storeId, operation, quantity],
);
}
@@ -137,6 +139,7 @@ function toStockItem(row: StockRow): StockItem {
return {
id: row.id,
variantId: row.variant_id,
storeId: row.store_id,
available: row.available,
reserved: row.reserved,
sold: row.sold,

View File

@@ -8,9 +8,12 @@ import {
import type { InventoryRepository } from '../domain/ports.js';
import type { SetAvailableStockCommand, StockCommand, StockItem } from '../domain/stock.js';
const STORE_ID = '00000000-0000-0000-0000-000000000001';
const STOCK: StockItem = {
id: 'stock-1',
variantId: 'variant-1',
storeId: STORE_ID,
available: 1,
reserved: 0,
sold: 0,
@@ -34,35 +37,35 @@ describe('InventoryService', () => {
it('rejects non-positive reserve quantities before persistence', async () => {
const service = new InventoryService(repository());
await expect(service.reserve({ variantId: 'variant-1', quantity: 0 })).rejects.toBeInstanceOf(
InvalidStockQuantityError,
);
await expect(
service.reserve({ variantId: 'variant-1', storeId: STORE_ID, quantity: 0 }),
).rejects.toBeInstanceOf(InvalidStockQuantityError);
});
it('maps failed reservation to insufficient stock', async () => {
const service = new InventoryService(repository({ reserve: async () => undefined }));
await expect(service.reserve({ variantId: 'variant-1', quantity: 1 })).rejects.toBeInstanceOf(
InsufficientStockError,
);
await expect(
service.reserve({ variantId: 'variant-1', storeId: STORE_ID, quantity: 1 }),
).rejects.toBeInstanceOf(InsufficientStockError);
});
it('maps failed release to insufficient reserved stock', async () => {
const service = new InventoryService(repository({ release: async () => undefined }));
await expect(service.release({ variantId: 'variant-1', quantity: 1 })).rejects.toBeInstanceOf(
InsufficientReservedStockError,
);
await expect(
service.release({ variantId: 'variant-1', storeId: STORE_ID, quantity: 1 }),
).rejects.toBeInstanceOf(InsufficientReservedStockError);
});
it('checks availability without mutating stock', async () => {
const service = new InventoryService(repository());
await expect(service.checkAvailability('variant-1', 1)).resolves.toEqual({
await expect(service.checkAvailability('variant-1', STORE_ID, 1)).resolves.toEqual({
available: true,
availableQuantity: 1,
});
await expect(service.checkAvailability('variant-1', 2)).resolves.toEqual({
await expect(service.checkAvailability('variant-1', STORE_ID, 2)).resolves.toEqual({
available: false,
availableQuantity: 1,
});

View File

@@ -5,6 +5,7 @@ export type OrderState =
| 'PROCESSING'
| 'SHIPPED'
| 'DELIVERED'
| 'COMPLETED'
| 'CANCELLED'
| 'REFUNDED'
| 'PARTIALLY_REFUNDED';
@@ -50,10 +51,11 @@ export interface OrderView extends Order {
export const ALLOWED_TRANSITIONS: Readonly<Record<OrderState, ReadonlyArray<OrderState>>> = {
PENDING: ['AWAITING_PAYMENT', 'CANCELLED'],
AWAITING_PAYMENT: ['PAID', 'CANCELLED'],
PAID: ['PROCESSING', 'SHIPPED', 'CANCELLED', 'REFUNDED'],
PROCESSING: ['PAID', 'SHIPPED', 'CANCELLED', 'REFUNDED'],
PAID: ['PROCESSING', 'SHIPPED', 'COMPLETED', 'CANCELLED', 'REFUNDED'],
PROCESSING: ['PAID', 'SHIPPED', 'COMPLETED', 'CANCELLED', 'REFUNDED'],
SHIPPED: ['PROCESSING', 'DELIVERED', 'PARTIALLY_REFUNDED'],
DELIVERED: ['SHIPPED', 'PARTIALLY_REFUNDED'],
COMPLETED: [],
CANCELLED: [],
REFUNDED: [],
PARTIALLY_REFUNDED: [],