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,118 @@
import type { FastifyInstance } from 'fastify';
import type pg from 'pg';
import { z } from 'zod';
import type { Authenticate } from '../../../shared/auth.js';
import { AppError } from '../../../shared/errors.js';
import { parseJson } from '../../../shared/http-input.js';
import { CartService } from '../../cart/index.js';
import { PgCartRepository } from '../../cart/infrastructure/pg-cart-repository.js';
import { createInventoryService } from '../../inventory/index.js';
import { OrderService } from '../../orders/index.js';
import { PgOrderRepository } from '../../orders/infrastructure/pg-order-repository.js';
import { NoOpOrderEventPublisher } from '../../orders/infrastructure/no-op-event-publisher.js';
import { createPricingService } from '../../pricing/index.js';
import { createShippingService } from '../../shipping/index.js';
import { CheckoutService } from '../application/checkout-service.js';
import { CheckoutError, type CheckoutResult } from '../domain/checkout.js';
import { InMemoryCheckoutMetrics } from '../infrastructure/metrics.js';
import { createNoOpTelemetry, type Tracer } from '../../observability/index.js';
import { StubPaymentProvider } from '../infrastructure/payment-provider.js';
export interface CheckoutRoutesDeps {
pool: pg.Pool;
authenticate: Authenticate;
tracer?: Tracer;
}
const checkoutBodySchema = z
.object({
address: z.object({
country: z.string().min(2).max(80),
postalCode: z.string().min(1).max(20),
}),
promoCode: z.string().min(1).max(64).optional().nullable(),
idempotencyKey: z.string().min(1).max(120),
})
.strip();
export async function registerCheckoutRoutes(
app: FastifyInstance,
deps: CheckoutRoutesDeps,
): Promise<void> {
const pricing = createPricingService(deps.pool);
const inventory = createInventoryService(deps.pool);
const shipping = createShippingService(deps.pool);
const orders = new OrderService(new PgOrderRepository(deps.pool), new NoOpOrderEventPublisher());
const cart = new CartService(new PgCartRepository(deps.pool), pricing, inventory);
const metrics = new InMemoryCheckoutMetrics();
const tracer = deps.tracer ?? createNoOpTelemetry().tracer;
const orderLookup = new PgIdempotencyLookup(deps.pool);
const service = new CheckoutService({
cart,
pricing,
inventory,
shipping,
orders,
payments: new StubPaymentProvider(),
orderLookup,
metrics,
tracer,
});
app.post('/checkout', async (request, reply) => {
const user = await deps.authenticate(request);
const input = parseJson(checkoutBodySchema, request.body);
try {
const result = await service.execute({
userId: user.id,
address: input.address,
promoCode: input.promoCode ?? null,
idempotencyKey: input.idempotencyKey,
});
return reply.send(serializeResult(result));
} catch (error) {
throw mapCheckoutError(error);
}
});
}
class PgIdempotencyLookup {
constructor(private readonly pool: pg.Pool) {}
async findByIdempotency(
userId: string,
idempotencyKey: string,
): Promise<{ orderId: string } | undefined> {
const result = await this.pool.query<{ id: string }>(
'SELECT id FROM orders_orders WHERE user_id = $1 AND idempotency_key = $2',
[userId, idempotencyKey],
);
const row = result.rows[0];
return row ? { orderId: row.id } : undefined;
}
}
function mapCheckoutError(error: unknown): Error {
if (error instanceof CheckoutError)
return new AppError(error.httpStatus, error.code, error.message);
return error instanceof Error ? error : new Error('Unknown checkout error');
}
function serializeResult(result: CheckoutResult) {
return {
order: {
id: result.order.id,
state: result.order.state,
totalCents: result.order.totalCents,
items: result.order.items.map((item) => ({
productId: item.productId,
variantId: item.variantId,
quantity: item.quantity,
unitPriceCents: item.unitPriceCents,
taxCents: item.taxCents,
})),
},
paymentIntent: result.paymentIntent,
reservedVariantIds: result.reservedVariantIds,
};
}

View File

@@ -0,0 +1,199 @@
import 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';
import type { ShippingAddress, ShippingServicePort } from '../../shipping/index.js';
import { CheckoutError, type CheckoutResult, type PaymentIntent } from '../domain/checkout.js';
import type { Tracer } from '../../observability/index.js';
import type { CheckoutMetrics, PaymentProvider, CheckoutOrderLookup } from '../domain/ports.js';
export interface CartLike {
userId: string;
items: Array<{ productId: string; variantId: string; quantity: number }>;
}
export interface CartServicePort {
getCart(userId: string): Promise<CartLike>;
}
export interface CheckoutServiceDeps {
cart: CartServicePort;
pricing: PricingServicePort;
promotions?: PromotionServicePort;
inventory: InventoryServicePort;
shipping: ShippingServicePort;
orders: OrderServicePort;
payments: PaymentProvider;
orderLookup: CheckoutOrderLookup;
metrics: CheckoutMetrics;
tracer?: Tracer;
}
export interface CheckoutCommand {
userId: string;
address: ShippingAddress;
promoCode?: string | null;
idempotencyKey: string;
}
export class CheckoutService {
constructor(private readonly deps: CheckoutServiceDeps) {}
async execute(command: CheckoutCommand): Promise<CheckoutResult> {
const span = this.deps.tracer?.startSpan('checkout.execute') ?? {
end: () => undefined,
setError: () => undefined,
};
try {
const idempotentOrderId = (
await this.deps.orderLookup.findByIdempotency(command.userId, command.idempotencyKey)
)?.orderId;
if (idempotentOrderId) {
const existing = await this.deps.orders.getOrder(idempotentOrderId, command.userId);
if (!existing)
throw new CheckoutError(
'CHECKOUT_IDEMPOTENCY_CONFLICT',
'Idempotency key already used',
409,
);
return {
order: existing,
paymentIntent: {
id: `pi_${idempotentOrderId}`,
reference: `ref_${idempotentOrderId}`,
status: 'requires_payment',
},
reservedVariantIds: existing.items.map((item) => item.variantId),
};
}
const cart = await this.deps.cart.getCart(command.userId);
if (cart.items.length === 0) {
this.deps.metrics.incFailure();
throw new CheckoutError('CHECKOUT_CART_EMPTY', 'Cart is empty', 409);
}
let netSubtotalCents = 0;
let taxCents = 0;
const itemInputs: OrderItemInput[] = [];
for (const cartItem of cart.items) {
const calculation = await this.deps.pricing
.calculate({ variantId: cartItem.variantId, quantity: cartItem.quantity })
.catch((error: unknown) => {
if (error instanceof Error && error.name === 'PriceNotFoundError') return null;
throw error;
});
if (!calculation) {
this.deps.metrics.incFailure();
throw new CheckoutError(
'CHECKOUT_PRICE_MISSING',
`Price missing for variant ${cartItem.variantId}`,
409,
);
}
const availability = await this.deps.inventory.checkAvailability(
cartItem.variantId,
cartItem.quantity,
);
if (!availability.available) {
this.deps.metrics.incFailure();
throw new CheckoutError(
'CHECKOUT_STOCK_UNAVAILABLE',
`Variant ${cartItem.variantId} is out of stock`,
409,
);
}
netSubtotalCents += calculation.netSubtotalCents;
taxCents += calculation.vatAmountCents;
itemInputs.push({
productId: cartItem.productId,
variantId: cartItem.variantId,
sku: cartItem.variantId,
ean: null,
name: `Variant ${cartItem.variantId.slice(0, 8)}`,
unitPriceCents: calculation.netUnitAmountCents,
discountCents: 0,
taxCents: calculation.vatAmountCents,
quantity: cartItem.quantity,
});
}
let discountCents = 0;
if (command.promoCode && this.deps.promotions) {
try {
const applied = await this.deps.promotions.calculateDiscount(
command.promoCode,
netSubtotalCents + taxCents,
);
discountCents = applied.discountCents;
} catch (error) {
this.deps.metrics.incFailure();
throw new CheckoutError('CHECKOUT_PROMO_INVALID', (error as Error).message, 422);
}
}
const shipping = await this.deps.shipping
.calculate(Math.max(0, netSubtotalCents + taxCents - discountCents), command.address)
.catch((error: unknown) => {
if (error instanceof Error && error.name === 'ShippingZoneNotFoundError') return null;
throw error;
});
if (!shipping) {
this.deps.metrics.incFailure();
throw new CheckoutError(
'CHECKOUT_SHIPPING_ZONE_NOT_FOUND',
'Shipping zone not found for address',
422,
);
}
const subtotalCents = netSubtotalCents + taxCents;
const totalCents = Math.max(0, subtotalCents - discountCents) + shipping.costCents;
const orderView = await this.deps.orders.create({
userId: command.userId,
idempotencyKey: command.idempotencyKey,
items: itemInputs,
totals: { subtotalCents, discountCents, taxCents, totalCents },
});
const reserved: string[] = [];
try {
for (const item of cart.items) {
await this.deps.inventory.reserve({ variantId: item.variantId, 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.orders
.transition(orderView.id, 'CANCELLED', command.userId)
.catch(() => undefined);
this.deps.metrics.incFailure();
throw new CheckoutError('CHECKOUT_RESERVATION_FAILED', (error as Error).message, 409);
}
await this.deps.orders
.transition(orderView.id, 'AWAITING_PAYMENT', command.userId)
.catch(() => undefined);
const refreshed =
(await this.deps.orders.getOrder(orderView.id, command.userId)) ?? orderView;
const paymentIntent: PaymentIntent = await this.deps.payments.createIntent({
orderId: refreshed.id,
amountCents: refreshed.totalCents,
currency: 'EUR',
});
this.deps.metrics.incSuccess();
this.deps.metrics.checkoutCompleted(totalCents);
return { order: refreshed, paymentIntent, reservedVariantIds: reserved };
} catch (error) {
span.setError(error);
throw error;
} finally {
span.end();
}
}
}

View File

@@ -0,0 +1,38 @@
import type { ShippingAddress } from '../../shipping/index.js';
import type { OrderView } from '../../orders/index.js';
export interface CheckoutItem {
variantId: string;
quantity: number;
}
export interface CheckoutRequest {
items?: never;
itemsByVariant?: never;
address: ShippingAddress;
promoCode?: string | null;
idempotencyKey: string;
}
export interface PaymentIntent {
id: string;
reference: string;
status: 'requires_payment' | 'cancelled' | 'succeeded';
}
export interface CheckoutResult {
order: OrderView;
paymentIntent: PaymentIntent;
reservedVariantIds: string[];
}
export class CheckoutError extends Error {
constructor(
public readonly code: string,
message: string,
public readonly httpStatus: number = 409,
) {
super(message);
this.name = code;
}
}

View File

@@ -0,0 +1,22 @@
import type { PaymentIntent } from './checkout.js';
export interface CheckoutMetrics {
incSuccess(): void;
incFailure(): void;
checkoutCompleted(amountCents: number): void;
}
export interface PaymentProvider {
createIntent(input: {
orderId: string;
amountCents: number;
currency: 'EUR';
}): Promise<PaymentIntent>;
}
export interface CheckoutOrderLookup {
findByIdempotency(
userId: string,
idempotencyKey: string,
): Promise<{ orderId: string } | undefined>;
}

View File

@@ -0,0 +1,12 @@
/** Public API of the checkout module. */
export { registerCheckoutRoutes, type CheckoutRoutesDeps } from './api/checkout.routes.js';
export { CheckoutService } from './application/checkout-service.js';
export {
CheckoutError,
type CheckoutRequest,
type CheckoutResult,
type PaymentIntent,
} from './domain/checkout.js';
export type { CheckoutMetrics, PaymentProvider, CheckoutOrderLookup } from './domain/ports.js';
export { InMemoryCheckoutMetrics } from './infrastructure/metrics.js';
export { StubPaymentProvider } from './infrastructure/payment-provider.js';

View File

@@ -0,0 +1,16 @@
import type { CheckoutMetrics } from '../domain/ports.js';
/** Simple in-process counter. */
export class InMemoryCheckoutMetrics implements CheckoutMetrics {
success = 0;
failure = 0;
incSuccess(): void {
this.success += 1;
}
incFailure(): void {
this.failure += 1;
}
checkoutCompleted(_amountCents: number): void {
this.success += 0;
}
}

View File

@@ -0,0 +1,17 @@
import type { PaymentProvider } from '../domain/ports.js';
import type { PaymentIntent } from '../domain/checkout.js';
/** Stub payment provider for v1. F-023 will replace with Stripe adapter. */
export class StubPaymentProvider implements PaymentProvider {
async createIntent(input: {
orderId: string;
amountCents: number;
currency: 'EUR';
}): Promise<PaymentIntent> {
return {
id: `pi_${input.orderId}`,
reference: `ref_${input.orderId}`,
status: 'requires_payment',
};
}
}

View File

@@ -0,0 +1,34 @@
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] : [];
});
}
const SQL_TABLES = /\bcart_|\binventory_|\bpricing_|\bshipping_|\bpromotions_|\busers_/;
describe('checkout persistence boundary', () => {
it('does not reference other module tables in code or SQL', () => {
const dir = new URL('..', import.meta.url);
for (const file of sourceFiles(dir.pathname)) {
const source = readFileSync(file, 'utf8');
// Allow imports of public interfaces from sibling modules but not
// hardcoded SQL table references.
expect(source).not.toMatch(SQL_TABLES);
}
});
it('does not import internals of sibling modules', () => {
const dir = new URL('..', import.meta.url);
for (const file of sourceFiles(dir.pathname)) {
const source = readFileSync(file, 'utf8');
expect(source).not.toMatch(
/modules\/(cart|inventory|pricing|promotions|shipping|orders)\/(api|application|infrastructure|tests)/,
);
}
});
});

View File

@@ -0,0 +1,304 @@
import { describe, expect, it } from 'vitest';
import { CheckoutService } from '../application/checkout-service.js';
import { CheckoutError } from '../domain/checkout.js';
import type { CheckoutOrderLookup, PaymentProvider } from '../domain/ports.js';
import { InMemoryCheckoutMetrics } from '../infrastructure/metrics.js';
import { StubPaymentProvider } from '../infrastructure/payment-provider.js';
import type { InventoryServicePort } from '../../inventory/index.js';
import type {
CreateOrderCommand,
OrderItemInput,
OrderServicePort,
OrderState,
OrderView,
} from '../../orders/index.js';
import type { PricingServicePort } from '../../pricing/index.js';
import type { ShippingServicePort } from '../../shipping/index.js';
const PRICE = {
variantId: 'v-1',
quantity: 1,
currency: 'EUR' as const,
vatRate: 'general' as const,
vatBasisPoints: 2100,
netUnitAmountCents: 1000,
netSubtotalCents: 1000,
vatAmountCents: 210,
totalCents: 1210,
};
interface Deps {
service: CheckoutService;
metrics: InMemoryCheckoutMetrics;
reserved: { calls: Array<{ variantId: string; quantity: number }> };
released: { calls: Array<{ variantId: string; quantity: number }> };
cancelCalls: Array<{ id: string; state: OrderState }>;
}
function buildDeps(
overrides: Partial<{
stock: boolean;
promo: boolean;
shipping: boolean;
orderId: string;
idempotency?: { orderId: string };
}> = {},
): Deps {
const cart = {
getCart: async () => ({
userId: 'u-1',
items: [{ productId: 'p-1', variantId: 'v-1', quantity: 1 }],
}),
};
const pricing: PricingServicePort = {
getVariantPrice: async () => undefined,
setVariantPrice: async () => ({}) as never,
calculate: async () => PRICE,
};
const reserved = { calls: [] as Array<{ variantId: string; quantity: number }> };
const released = { calls: [] as Array<{ variantId: string; quantity: number }> };
const cancelCalls: Array<{ id: string; state: OrderState }> = [];
const inventory: InventoryServicePort = {
checkAvailability: async () => ({ available: overrides.stock ?? true, availableQuantity: 10 }),
reserve: async (input) => {
reserved.calls.push(input);
return { ...PRICE } as never;
},
release: async (input) => {
released.calls.push(input);
return { ...PRICE } as never;
},
confirm: async () => ({}) as never,
setAvailable: async () => ({}) as never,
};
const shipping: ShippingServicePort = {
calculate: async () =>
overrides.shipping === false
? Promise.reject(
Object.assign(new Error('not found'), { name: 'ShippingZoneNotFoundError' }),
)
: Promise.resolve({
zoneId: 'z-1',
methodId: 'm-1',
methodName: 'Standard',
costCents: 500,
freeApplied: false,
}),
};
const orderId = overrides.orderId ?? 'order-1';
const orders: OrderServicePort = {
create: async (input: CreateOrderCommand) => ({
id: orderId,
userId: input.userId,
idempotencyKey: input.idempotencyKey ?? null,
state: 'AWAITING_PAYMENT',
currency: 'EUR',
subtotalCents: input.totals.subtotalCents,
discountCents: input.totals.discountCents,
taxCents: input.totals.taxCents,
totalCents: input.totals.totalCents,
createdAt: new Date(),
updatedAt: new Date(),
items: input.items.map((item: OrderItemInput, index: number) => ({
id: `i-${index}`,
orderId,
productId: item.productId,
variantId: item.variantId,
sku: item.sku,
ean: null,
name: item.name,
unitPriceCents: item.unitPriceCents,
discountCents: item.discountCents,
taxCents: item.taxCents,
quantity: item.quantity,
createdAt: new Date(),
})),
}),
listOrders: async () => [],
transition: async (id, state) => {
cancelCalls.push({ id, state });
return {
id,
userId: 'u-1',
state,
currency: 'EUR',
idempotencyKey: 'k-1',
subtotalCents: 1210,
discountCents: 0,
taxCents: 210,
totalCents: 1710,
createdAt: new Date(),
updatedAt: new Date(),
items: [],
} satisfies OrderView;
},
getOrder: async (id) => ({
id,
userId: 'u-1',
state: 'AWAITING_PAYMENT',
currency: 'EUR',
idempotencyKey: 'k-1',
subtotalCents: 1210,
discountCents: 0,
taxCents: 210,
totalCents: 1710,
createdAt: new Date(),
updatedAt: new Date(),
items: [],
}),
getOrderAdmin: async (id) => ({
id,
userId: 'u-1',
state: 'AWAITING_PAYMENT',
currency: 'EUR',
idempotencyKey: 'k-1',
subtotalCents: 1210,
discountCents: 0,
taxCents: 210,
totalCents: 1710,
createdAt: new Date(),
updatedAt: new Date(),
items: [],
}),
transitionAdmin: async (id, state) => ({
id,
userId: 'u-1',
state,
currency: 'EUR',
idempotencyKey: 'k-1',
subtotalCents: 1210,
discountCents: 0,
taxCents: 210,
totalCents: 1710,
createdAt: new Date(),
updatedAt: new Date(),
items: [],
}),
};
const orderLookup: CheckoutOrderLookup = { findByIdempotency: async () => overrides.idempotency };
const payments: PaymentProvider = new StubPaymentProvider();
const metrics = new InMemoryCheckoutMetrics();
const service = new CheckoutService({
cart: cart as never,
pricing,
inventory,
shipping,
orders,
payments,
orderLookup,
metrics,
});
return { service, metrics, reserved, released, cancelCalls };
}
describe('CheckoutService', () => {
it('returns 409 when stock is unavailable (AC1)', async () => {
const { service } = buildDeps({ stock: false });
await expect(
service.execute({
userId: 'u-1',
address: { country: 'ES', postalCode: '28001' },
idempotencyKey: 'k-1',
}),
).rejects.toBeInstanceOf(CheckoutError);
});
it('creates an AWAITING_PAYMENT order with reserved stock on success', async () => {
const deps = buildDeps();
const result = await deps.service.execute({
userId: 'u-1',
address: { country: 'ES', postalCode: '28001' },
idempotencyKey: 'k-1',
});
expect(result.order.state).toBe('AWAITING_PAYMENT');
expect(deps.reserved.calls).toEqual([{ variantId: 'v-1', quantity: 1 }]);
expect(deps.metrics.success).toBe(1);
});
it('returns the same order on idempotent retry without reserving twice (AC2)', async () => {
const dep2 = buildDeps();
const lookup: CheckoutOrderLookup = { findByIdempotency: async () => ({ orderId: 'order-x' }) };
const service2 = new CheckoutService({
cart: {
getCart: async () => ({
userId: 'u-1',
items: [{ productId: 'p-1', variantId: 'v-1', quantity: 1 }],
}),
} as never,
pricing: {
getVariantPrice: async () => undefined,
setVariantPrice: async () => ({}) as never,
calculate: async () => PRICE,
},
inventory: {
checkAvailability: async () => ({ available: true, availableQuantity: 10 }),
reserve: async (i) => {
dep2.reserved.calls.push(i);
return {} as never;
},
release: async () => ({}) as never,
confirm: async () => ({}) as never,
setAvailable: async () => ({}) as never,
},
shipping: {
calculate: async () => ({
zoneId: 'z',
methodId: 'm',
methodName: 'Standard',
costCents: 0,
freeApplied: true,
}),
},
orders: {
create: async () => {
throw new Error('should not create on retry');
},
transition: async () => {
throw new Error('should not transition on retry');
},
listOrders: async () => [],
getOrderAdmin: async () => { throw new Error('not used'); },
transitionAdmin: async () => { throw new Error('not used'); },
getOrder: async (id) => ({
id,
userId: 'u-1',
state: 'AWAITING_PAYMENT',
currency: 'EUR',
idempotencyKey: 'k-1',
subtotalCents: 1210,
discountCents: 0,
taxCents: 210,
totalCents: 1710,
createdAt: new Date(),
updatedAt: new Date(),
items: [
{
id: 'i-1',
orderId: id,
productId: 'p-1',
variantId: 'v-1',
sku: 'v-1',
ean: null,
name: 'x',
unitPriceCents: 1000,
discountCents: 0,
taxCents: 210,
quantity: 1,
createdAt: new Date(),
},
],
}),
} as OrderServicePort,
payments: new StubPaymentProvider(),
orderLookup: lookup,
metrics: dep2.metrics,
});
const second = await service2.execute({
userId: 'u-1',
address: { country: 'ES', postalCode: '28001' },
idempotencyKey: 'k-1',
});
expect(second.order.id).toBe('order-x');
expect(dep2.reserved.calls).toHaveLength(0);
});
});