feat(ADM-018): completed feature
This commit is contained in:
118
project/src/modules/checkout/api/checkout.routes.ts
Normal file
118
project/src/modules/checkout/api/checkout.routes.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
199
project/src/modules/checkout/application/checkout-service.ts
Normal file
199
project/src/modules/checkout/application/checkout-service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
38
project/src/modules/checkout/domain/checkout.ts
Normal file
38
project/src/modules/checkout/domain/checkout.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
22
project/src/modules/checkout/domain/ports.ts
Normal file
22
project/src/modules/checkout/domain/ports.ts
Normal 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>;
|
||||
}
|
||||
12
project/src/modules/checkout/index.ts
Normal file
12
project/src/modules/checkout/index.ts
Normal 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';
|
||||
16
project/src/modules/checkout/infrastructure/metrics.ts
Normal file
16
project/src/modules/checkout/infrastructure/metrics.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
34
project/src/modules/checkout/tests/boundary.test.ts
Normal file
34
project/src/modules/checkout/tests/boundary.test.ts
Normal 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)/,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
304
project/src/modules/checkout/tests/checkout-service.test.ts
Normal file
304
project/src/modules/checkout/tests/checkout-service.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user