feat(ADM-018): completed feature

This commit is contained in:
chattie
2026-08-17 22:23:10 +02:00
parent cf1c69fc8b
commit d595b4871f
871 changed files with 47411 additions and 281 deletions

View File

@@ -0,0 +1,193 @@
import type { FastifyInstance } from 'fastify';
import type pg from 'pg';
import { z } from 'zod';
import type { Authenticate, CurrentUser } from '../../../shared/auth.js';
import { requireRole } from '../../../shared/auth.js';
import { AppError } from '../../../shared/errors.js';
import { parseJson } from '../../../shared/http-input.js';
import { OrderService } from '../application/order-service.js';
import { OrderNotFoundError, OrderStateTransitionError } from '../domain/errors.js';
import type { OrderState } from '../domain/order.js';
import { PgOrderRepository } from '../infrastructure/pg-order-repository.js';
import { NoOpOrderEventPublisher } from '../infrastructure/no-op-event-publisher.js';
export interface OrdersRoutesDeps {
pool: pg.Pool;
authenticate: Authenticate;
}
const orderItemSchema = z.object({
productId: z.uuid(),
variantId: z.uuid(),
sku: z.string().min(1).max(120),
ean: z.string().min(1).max(40).nullable().optional(),
name: z.string().min(1).max(200),
unitPriceCents: z.number().int().min(0),
discountCents: z.number().int().min(0),
taxCents: z.number().int().min(0),
quantity: z.number().int().positive(),
});
const createOrderSchema = z
.object({
idempotencyKey: z.string().min(1).max(120).optional().nullable(),
items: z.array(orderItemSchema).min(1),
totals: z.object({
subtotalCents: z.number().int().min(0),
discountCents: z.number().int().min(0),
taxCents: z.number().int().min(0),
totalCents: z.number().int().min(0),
}),
})
.strip();
const transitionSchema = z.object({
state: z.enum([
'PENDING',
'AWAITING_PAYMENT',
'PAID',
'PROCESSING',
'SHIPPED',
'DELIVERED',
'CANCELLED',
'REFUNDED',
'PARTIALLY_REFUNDED',
]),
});
const orderIdParamSchema = z.object({ id: z.uuid() });
export async function registerOrdersRoutes(
app: FastifyInstance,
deps: OrdersRoutesDeps,
): Promise<void> {
const service = new OrderService(new PgOrderRepository(deps.pool), new NoOpOrderEventPublisher());
app.post('/orders', async (request, reply) => {
const user = await deps.authenticate(request);
const input = parseJson(createOrderSchema, request.body);
try {
const order = await service.create({
userId: user.id,
idempotencyKey: input.idempotencyKey ?? null,
items: input.items.map((item) => ({ ...item, ean: item.ean ?? null })),
totals: input.totals,
});
return reply.code(201).send(serializeOrder(order));
} catch (error) {
throw mapOrderError(error);
}
});
app.post('/orders/:id/transitions', async (request, reply) => {
const user = await deps.authenticate(request);
const { id } = parseJson(orderIdParamSchema, request.params);
const { state } = parseJson(transitionSchema, request.body);
try {
const order = await service.transition(id, state as OrderState, user.id);
return reply.send(serializeOrder(order));
} catch (error) {
throw mapOrderError(error);
}
});
app.get('/orders/:id', async (request, reply) => {
const user = await deps.authenticate(request);
const { id } = parseJson(orderIdParamSchema, request.params);
const order = await service.getOrder(id, user.id);
if (!order) throw new AppError(404, 'ORDER_NOT_FOUND', 'Order not found');
return reply.send(serializeOrder(order));
});
// Admin-only routes
app.get('/orders', async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const orders = await service.listOrders();
return reply.send(orders.map(serializeOrder));
});
app.get('/orders/:id/admin', async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const { id } = parseJson(orderIdParamSchema, request.params);
const order = await service.getOrderAdmin(id);
if (!order) throw new AppError(404, 'ORDER_NOT_FOUND', 'Order not found');
return reply.send(serializeOrder(order));
});
app.post('/orders/:id/transitions/admin', async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const { id } = parseJson(orderIdParamSchema, request.params);
const { state } = parseJson(transitionSchema, request.body);
try {
const order = await service.transitionAdmin(id, state as OrderState);
return reply.send(serializeOrder(order));
} catch (error) {
throw mapOrderError(error);
}
});
}
function mapOrderError(error: unknown): Error {
if (error instanceof OrderStateTransitionError)
return new AppError(409, 'ORDER_STATE_TRANSITION_INVALID', error.message);
if (error instanceof OrderNotFoundError)
return new AppError(404, 'ORDER_NOT_FOUND', error.message);
return error instanceof Error ? error : new Error('Unknown order error');
}
function serializeOrder(order: {
id: string;
state: string;
currency: string;
subtotalCents: number;
discountCents: number;
taxCents: number;
totalCents: number;
createdAt: Date;
updatedAt: Date;
idempotencyKey: string | null;
userId?: string;
items: Array<{
id: string;
productId: string;
variantId: string;
sku: string;
ean: string | null;
name: string;
unitPriceCents: number;
discountCents: number;
taxCents: number;
quantity: number;
createdAt: Date;
}>;
}) {
return {
id: order.id,
userId: order.userId,
state: order.state,
currency: order.currency,
subtotalCents: order.subtotalCents,
discountCents: order.discountCents,
taxCents: order.taxCents,
totalCents: order.totalCents,
idempotencyKey: order.idempotencyKey,
items: order.items.map((item) => ({
id: item.id,
productId: item.productId,
variantId: item.variantId,
sku: item.sku,
ean: item.ean,
name: item.name,
unitPriceCents: item.unitPriceCents,
discountCents: item.discountCents,
taxCents: item.taxCents,
quantity: item.quantity,
createdAt: item.createdAt.toISOString(),
})),
createdAt: order.createdAt.toISOString(),
updatedAt: order.updatedAt.toISOString(),
};
}

View File

@@ -0,0 +1,62 @@
import { OrderNotFoundError, OrderStateTransitionError } from '../domain/errors.js';
import type {
OrderEventPublisher,
OrderRepository,
OrderServicePort,
CreateOrderCommand,
} from '../domain/ports.js';
import { isTransitionAllowed, type OrderState, type OrderView } from '../domain/order.js';
export class OrderService implements OrderServicePort {
constructor(
private readonly repo: OrderRepository,
private readonly events: OrderEventPublisher,
) {}
async create(input: CreateOrderCommand): Promise<OrderView> {
const order = await this.repo.create({
...input,
idempotencyKey: input.idempotencyKey ?? null,
});
await this.events.emit({ type: 'OrderCreated', orderId: order.id, userId: order.userId });
return order;
}
async listOrders(): Promise<OrderView[]> {
return this.repo.findAll();
}
async transition(id: string, next: OrderState, userId: string): Promise<OrderView> {
const existing = await this.repo.findByIdAndUserId(id, userId);
if (!existing) throw new OrderNotFoundError();
if (!isTransitionAllowed(existing.state, next))
throw new OrderStateTransitionError(existing.state, next);
const updated = await this.repo.updateState(id, next);
if (!updated) throw new OrderNotFoundError();
if (next === 'PAID') await this.events.emit({ type: 'OrderPaid', orderId: id, userId });
if (next === 'CANCELLED')
await this.events.emit({ type: 'OrderCancelled', orderId: id, userId });
return updated;
}
async getOrder(id: string, userId: string): Promise<OrderView | undefined> {
return this.repo.findByIdAndUserId(id, userId);
}
async getOrderAdmin(id: string): Promise<OrderView | undefined> {
return this.repo.findById(id);
}
async transitionAdmin(id: string, next: OrderState): Promise<OrderView> {
const existing = await this.repo.findById(id);
if (!existing) throw new OrderNotFoundError();
if (!isTransitionAllowed(existing.state, next))
throw new OrderStateTransitionError(existing.state, next);
const updated = await this.repo.updateState(id, next);
if (!updated) throw new OrderNotFoundError();
if (next === 'PAID') await this.events.emit({ type: 'OrderPaid', orderId: id, userId: existing.userId });
if (next === 'CANCELLED')
await this.events.emit({ type: 'OrderCancelled', orderId: id, userId: existing.userId });
return updated;
}
}

View File

@@ -0,0 +1,16 @@
export class OrderStateTransitionError extends Error {
constructor(
public readonly from: string,
public readonly to: string,
) {
super(`Order state transition from ${from} to ${to} is not allowed`);
this.name = 'OrderStateTransitionError';
}
}
export class OrderNotFoundError extends Error {
constructor() {
super('Order not found');
this.name = 'OrderNotFoundError';
}
}

View File

@@ -0,0 +1,62 @@
export type OrderState =
| 'PENDING'
| 'AWAITING_PAYMENT'
| 'PAID'
| 'PROCESSING'
| 'SHIPPED'
| 'DELIVERED'
| 'CANCELLED'
| 'REFUNDED'
| 'PARTIALLY_REFUNDED';
export interface OrderItemInput {
productId: string;
variantId: string;
sku: string;
ean: string | null;
name: string;
unitPriceCents: number;
discountCents: number;
taxCents: number;
quantity: number;
}
export interface OrderItem extends OrderItemInput {
id: string;
orderId: string;
createdAt: Date;
}
export interface Order {
id: string;
userId: string;
idempotencyKey: string | null;
state: OrderState;
currency: 'EUR';
subtotalCents: number;
discountCents: number;
taxCents: number;
totalCents: number;
createdAt: Date;
updatedAt: Date;
}
export interface OrderView extends Order {
items: OrderItem[];
}
export const ALLOWED_TRANSITIONS: Readonly<Record<OrderState, ReadonlyArray<OrderState>>> = {
PENDING: ['AWAITING_PAYMENT', 'CANCELLED'],
AWAITING_PAYMENT: ['PAID', 'CANCELLED'],
PAID: ['PROCESSING', 'SHIPPED', 'CANCELLED', 'REFUNDED'],
PROCESSING: ['SHIPPED', 'CANCELLED', 'REFUNDED'],
SHIPPED: ['DELIVERED', 'PARTIALLY_REFUNDED'],
DELIVERED: ['PARTIALLY_REFUNDED'],
CANCELLED: [],
REFUNDED: [],
PARTIALLY_REFUNDED: [],
};
export function isTransitionAllowed(from: OrderState, to: OrderState): boolean {
return ALLOWED_TRANSITIONS[from].includes(to);
}

View File

@@ -0,0 +1,41 @@
import type { Order, OrderItem, OrderItemInput, OrderState, OrderView } from './order.js';
export interface OrderRepository {
create(input: {
userId: string;
idempotencyKey: string | null;
items: OrderItemInput[];
totals: { subtotalCents: number; discountCents: number; taxCents: number; totalCents: number };
}): Promise<OrderView>;
findAll(): Promise<OrderView[]>;
findById(id: string): Promise<OrderView | undefined>;
findByIdAndUserId(id: string, userId: string): Promise<OrderView | undefined>;
updateState(id: string, state: OrderState): Promise<OrderView | undefined>;
}
export interface OrderEventPublisher {
emit(event: OrderEvent): Promise<void>;
}
export type OrderEvent =
| { type: 'OrderCreated'; orderId: string; userId: string }
| { type: 'OrderPaid'; orderId: string; userId: string }
| { type: 'OrderCancelled'; orderId: string; userId: string };
export interface OrderServicePort {
create(input: CreateOrderCommand): Promise<OrderView>;
listOrders(): Promise<OrderView[]>;
transition(id: string, next: OrderState, userId: string): Promise<OrderView>;
transitionAdmin(id: string, next: OrderState): Promise<OrderView>;
getOrder(id: string, userId: string): Promise<OrderView | undefined>;
getOrderAdmin(id: string): Promise<OrderView | undefined>;
}
export interface CreateOrderCommand {
userId: string;
idempotencyKey?: string | null;
items: OrderItemInput[];
totals: { subtotalCents: number; discountCents: number; taxCents: number; totalCents: number };
}
export type { Order, OrderItem, OrderItemInput, OrderState, OrderView };

View File

@@ -0,0 +1,22 @@
/** Public API of the orders module. */
import type pg from 'pg';
import { OrderService } from './application/order-service.js';
import { PgOrderRepository } from './infrastructure/pg-order-repository.js';
import { NoOpOrderEventPublisher } from './infrastructure/no-op-event-publisher.js';
export { registerOrdersRoutes, type OrdersRoutesDeps } from './api/orders.routes.js';
export { OrderService } from './application/order-service.js';
export { OrderStateTransitionError, OrderNotFoundError } from './domain/errors.js';
export { ALLOWED_TRANSITIONS, isTransitionAllowed } from './domain/order.js';
export type {
OrderEvent,
OrderEventPublisher,
OrderRepository,
OrderServicePort,
CreateOrderCommand,
} from './domain/ports.js';
export type { Order, OrderItem, OrderItemInput, OrderState, OrderView } from './domain/order.js';
export function createOrderService(pool: pg.Pool): OrderService {
return new OrderService(new PgOrderRepository(pool), new NoOpOrderEventPublisher());
}

View File

@@ -0,0 +1,8 @@
import type { OrderEvent, OrderEventPublisher } from '../domain/ports.js';
/** No-op event publisher: emits nothing now, used as a safe default until notifications wiring lands. */
export class NoOpOrderEventPublisher implements OrderEventPublisher {
async emit(_event: OrderEvent): Promise<void> {
return Promise.resolve();
}
}

View File

@@ -0,0 +1,181 @@
import type pg from 'pg';
import type { OrderRepository } from '../domain/ports.js';
import type { Order, OrderItem, OrderItemInput, OrderState, OrderView } from '../domain/order.js';
interface OrderRow {
id: string;
user_id: string;
idempotency_key: string | null;
state: OrderState;
currency: 'EUR';
subtotal_cents: number;
discount_cents: number;
tax_cents: number;
total_cents: number;
created_at: Date;
updated_at: Date;
}
interface ItemRow {
id: string;
order_id: string;
product_id: string;
variant_id: string;
sku: string;
ean: string | null;
name: string;
unit_price_cents: number;
discount_cents: number;
tax_cents: number;
quantity: number;
created_at: Date;
}
interface CreateInput {
userId: string;
idempotencyKey: string | null;
items: OrderItemInput[];
totals: { subtotalCents: number; discountCents: number; taxCents: number; totalCents: number };
}
export class PgOrderRepository implements OrderRepository {
constructor(private readonly pool: pg.Pool) {}
async create(input: CreateInput): Promise<OrderView> {
const client = await this.pool.connect();
try {
await client.query('BEGIN');
const orderResult = await client.query<OrderRow>(
`INSERT INTO orders_orders (user_id, idempotency_key, subtotal_cents, discount_cents, tax_cents, total_cents)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`,
[
input.userId,
input.idempotencyKey,
input.totals.subtotalCents,
input.totals.discountCents,
input.totals.taxCents,
input.totals.totalCents,
],
);
const order = orderResult.rows[0];
if (!order) throw new Error('orders_orders INSERT returned no row');
for (const item of input.items) {
await client.query<ItemRow>(
`INSERT INTO orders_items (order_id, product_id, variant_id, sku, ean, name, unit_price_cents, discount_cents, tax_cents, quantity)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING *`,
[
order.id,
item.productId,
item.variantId,
item.sku,
item.ean,
item.name,
item.unitPriceCents,
item.discountCents,
item.taxCents,
item.quantity,
],
);
}
await client.query('COMMIT');
const loaded = await this.findById(order.id);
if (!loaded) throw new Error('orders_orders load after create returned no row');
return loaded;
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
async findAll(): Promise<OrderView[]> {
const result = await this.pool.query<OrderRow>(
'SELECT * FROM orders_orders ORDER BY created_at DESC',
);
return Promise.all(
result.rows.map(async (order) => {
const items = await this.pool.query<ItemRow>(
'SELECT * FROM orders_items WHERE order_id = $1 ORDER BY id',
[order.id],
);
return { ...toOrder(order), items: items.rows.map(toItem) };
}),
);
}
async findById(id: string): Promise<OrderView | undefined> {
const orderResult = await this.pool.query<OrderRow>(
'SELECT * FROM orders_orders WHERE id = $1',
[id],
);
const order = orderResult.rows[0];
if (!order) return undefined;
const items = await this.pool.query<ItemRow>(
'SELECT * FROM orders_items WHERE order_id = $1 ORDER BY created_at, id',
[id],
);
return { ...toOrder(order), items: items.rows.map(toItem) };
}
async findByIdAndUserId(id: string, userId: string): Promise<OrderView | undefined> {
const orderResult = await this.pool.query<OrderRow>(
'SELECT * FROM orders_orders WHERE id = $1 AND user_id = $2',
[id, userId],
);
const order = orderResult.rows[0];
if (!order) return undefined;
const items = await this.pool.query<ItemRow>(
'SELECT * FROM orders_items WHERE order_id = $1 ORDER BY created_at, id',
[id],
);
return { ...toOrder(order), items: items.rows.map(toItem) };
}
async updateState(id: string, state: OrderState): Promise<OrderView | undefined> {
const result = await this.pool.query<OrderRow>(
`UPDATE orders_orders SET state = $2, updated_at = now()
WHERE id = $1 RETURNING *`,
[id, state],
);
const order = result.rows[0];
if (!order) return undefined;
const items = await this.pool.query<ItemRow>(
'SELECT * FROM orders_items WHERE order_id = $1 ORDER BY created_at, id',
[id],
);
return { ...toOrder(order), items: items.rows.map(toItem) };
}
}
function toOrder(row: OrderRow): Order {
return {
id: row.id,
userId: row.user_id,
idempotencyKey: row.idempotency_key,
state: row.state,
currency: row.currency,
subtotalCents: row.subtotal_cents,
discountCents: row.discount_cents,
taxCents: row.tax_cents,
totalCents: row.total_cents,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
function toItem(row: ItemRow): OrderItem {
return {
id: row.id,
orderId: row.order_id,
productId: row.product_id,
variantId: row.variant_id,
sku: row.sku,
ean: row.ean,
name: row.name,
unitPriceCents: row.unit_price_cents,
discountCents: row.discount_cents,
taxCents: row.tax_cents,
quantity: row.quantity,
createdAt: row.created_at,
};
}

View File

@@ -0,0 +1,32 @@
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
function sourceFiles(dir: string): string[] {
return readdirSync(dir).flatMap((entry) => {
const path = join(dir, entry);
return statSync(path).isDirectory() ? sourceFiles(path) : path.endsWith('.ts') ? [path] : [];
});
}
describe('orders persistence boundary', () => {
it('migration contains no external table references', () => {
const migration = readFileSync(
new URL('../../../../migrations/016_orders.js', import.meta.url),
'utf8',
);
expect(migration).not.toMatch(
/catalog_|inventory_|pricing_|cart_|shipping_|promotions_|users_/,
);
});
it('orders module does not import catalog/inventory/pricing/cart/shipping/promotions internals', () => {
const dir = new URL('..', import.meta.url);
for (const file of sourceFiles(dir.pathname)) {
const source = readFileSync(file, 'utf8');
expect(source).not.toMatch(
/modules\/(catalog|inventory|pricing|cart|shipping|promotions)\/(api|application|domain|infrastructure|tests)/,
);
}
});
});

View File

@@ -0,0 +1,93 @@
import { describe, expect, it } from 'vitest';
import { OrderService } from '../application/order-service.js';
import { OrderStateTransitionError } from '../domain/errors.js';
import type { OrderEvent, OrderEventPublisher, OrderRepository } from '../domain/ports.js';
import type { OrderItemInput, OrderState, OrderView } from '../domain/order.js';
const ORDER_VIEW: OrderView = {
id: 'order-1',
userId: 'user-1',
idempotencyKey: null,
state: 'PENDING',
currency: 'EUR',
subtotalCents: 1000,
discountCents: 0,
taxCents: 210,
totalCents: 1210,
createdAt: new Date('2026-01-01T00:00:00Z'),
updatedAt: new Date('2026-01-01T00:00:00Z'),
items: [
{
id: 'item-1',
orderId: 'order-1',
productId: 'product-1',
variantId: 'variant-1',
sku: 'SKU-1',
ean: null,
name: 'Product One',
unitPriceCents: 1000,
discountCents: 0,
taxCents: 210,
quantity: 1,
createdAt: new Date('2026-01-01T00:00:00Z'),
},
],
};
function repo(overrides: Partial<OrderRepository> = {}): OrderRepository {
return {
create: async () => ORDER_VIEW,
findAll: async () => [ORDER_VIEW],
findById: async () => ORDER_VIEW,
findByIdAndUserId: async () => ORDER_VIEW,
updateState: async (_id, state) => ({ ...ORDER_VIEW, state: state as OrderState }),
...overrides,
};
}
class CapturePublisher implements OrderEventPublisher {
events: OrderEvent[] = [];
async emit(event: OrderEvent): Promise<void> {
this.events.push(event);
}
}
describe('OrderService', () => {
it('creates an order and emits OrderCreated', async () => {
const publisher = new CapturePublisher();
const service = new OrderService(repo(), publisher);
const order = await service.create({
userId: 'user-1',
idempotencyKey: null,
items: [
{
productId: 'product-1',
variantId: 'variant-1',
sku: 'SKU-1',
ean: null,
name: 'Product One',
unitPriceCents: 1000,
discountCents: 0,
taxCents: 210,
quantity: 1,
} satisfies OrderItemInput,
],
totals: { subtotalCents: 1000, discountCents: 0, taxCents: 210, totalCents: 1210 },
});
expect(order.id).toBe('order-1');
expect(publisher.events).toEqual([
{ type: 'OrderCreated', orderId: 'order-1', userId: 'user-1' },
]);
});
it('rejects illegal state transitions', async () => {
const publisher = new CapturePublisher();
const repoWithState: OrderRepository = repo({
findByIdAndUserId: async () => ({ ...ORDER_VIEW, state: 'SHIPPED' }),
});
const service = new OrderService(repoWithState, publisher);
await expect(service.transition('order-1', 'PENDING', 'user-1')).rejects.toBeInstanceOf(
OrderStateTransitionError,
);
});
});

View File

@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest';
import { OrderStateTransitionError } from '../domain/errors.js';
import { ALLOWED_TRANSITIONS, isTransitionAllowed, type OrderState } from '../domain/order.js';
describe('Order state machine', () => {
it('allows the full happy-path transitions', () => {
const path: OrderState[] = [
'PENDING',
'AWAITING_PAYMENT',
'PAID',
'PROCESSING',
'SHIPPED',
'DELIVERED',
];
for (let i = 0; i < path.length - 1; i++) {
expect(isTransitionAllowed(path[i] as OrderState, path[i + 1] as OrderState)).toBe(true);
}
});
it('rejects SHIPPED back to PENDING', () => {
expect(isTransitionAllowed('SHIPPED', 'PENDING')).toBe(false);
expect(() => {
if (!isTransitionAllowed('SHIPPED', 'PENDING'))
throw new OrderStateTransitionError('SHIPPED', 'PENDING');
}).toThrow(OrderStateTransitionError);
});
it('keeps REFUNDED and PARTIALLY_REFUNDED terminal', () => {
expect(ALLOWED_TRANSITIONS.REFUNDED).toEqual([]);
expect(ALLOWED_TRANSITIONS.PARTIALLY_REFUNDED).toEqual([]);
expect(isTransitionAllowed('REFUNDED', 'PAID')).toBe(false);
expect(isTransitionAllowed('PARTIALLY_REFUNDED', 'PENDING')).toBe(false);
});
it('rejects illegal jumps', () => {
expect(isTransitionAllowed('PENDING', 'SHIPPED')).toBe(false);
expect(isTransitionAllowed('PAID', 'PENDING')).toBe(false);
});
});