feat(ADM-018): completed feature
This commit is contained in:
193
project/src/modules/orders/api/orders.routes.ts
Normal file
193
project/src/modules/orders/api/orders.routes.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
62
project/src/modules/orders/application/order-service.ts
Normal file
62
project/src/modules/orders/application/order-service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
16
project/src/modules/orders/domain/errors.ts
Normal file
16
project/src/modules/orders/domain/errors.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
62
project/src/modules/orders/domain/order.ts
Normal file
62
project/src/modules/orders/domain/order.ts
Normal 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);
|
||||
}
|
||||
41
project/src/modules/orders/domain/ports.ts
Normal file
41
project/src/modules/orders/domain/ports.ts
Normal 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 };
|
||||
22
project/src/modules/orders/index.ts
Normal file
22
project/src/modules/orders/index.ts
Normal 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());
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
181
project/src/modules/orders/infrastructure/pg-order-repository.ts
Normal file
181
project/src/modules/orders/infrastructure/pg-order-repository.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
32
project/src/modules/orders/tests/boundary.test.ts
Normal file
32
project/src/modules/orders/tests/boundary.test.ts
Normal 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)/,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
93
project/src/modules/orders/tests/order-service.test.ts
Normal file
93
project/src/modules/orders/tests/order-service.test.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
39
project/src/modules/orders/tests/order-state-machine.test.ts
Normal file
39
project/src/modules/orders/tests/order-state-machine.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user