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,120 @@
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 type { InventoryServicePort } from '../../inventory/index.js';
import type { PricingServicePort } from '../../pricing/index.js';
import type { PromotionServicePort } from '../../promotions/index.js';
import { CartService } from '../application/cart-service.js';
import type { CartItemView, CartView } from '../domain/cart.js';
import { InvalidCartQuantityError } from '../domain/errors.js';
import { PgCartRepository } from '../infrastructure/pg-cart-repository.js';
export interface CartRoutesDeps {
pool: pg.Pool;
authenticate: Authenticate;
pricing: PricingServicePort;
inventory: InventoryServicePort;
promotions?: PromotionServicePort;
}
const variantParamSchema = z.object({ variantId: z.uuid() });
const itemBodySchema = z
.object({ productId: z.uuid(), variantId: z.uuid(), quantity: z.number().int().positive() })
.strip();
const quantityBodySchema = z.object({ quantity: z.number().int().positive() }).strip();
const promoCodeBodySchema = z.object({ code: z.string().min(1).max(64) }).strip();
export async function registerCartRoutes(
app: FastifyInstance,
deps: CartRoutesDeps,
): Promise<void> {
const service = new CartService(
new PgCartRepository(deps.pool),
deps.pricing,
deps.inventory,
deps.promotions,
);
app.get('/cart', async (request, reply) => {
const user = await deps.authenticate(request);
return reply.send(serializeCart(await service.getCart(user.id)));
});
app.post('/cart/items', async (request, reply) => {
const user = await deps.authenticate(request);
const input = parseJson(itemBodySchema, request.body);
try {
return reply.code(201).send(serializeCart(await service.addItem(user.id, input)));
} catch (error) {
throw mapCartError(error);
}
});
app.patch('/cart/items/:variantId', async (request, reply) => {
const user = await deps.authenticate(request);
const { variantId } = parseJson(variantParamSchema, request.params);
const { quantity } = parseJson(quantityBodySchema, request.body);
try {
return reply.send(serializeCart(await service.changeQuantity(user.id, variantId, quantity)));
} catch (error) {
throw mapCartError(error);
}
});
app.delete('/cart/items/:variantId', async (request, reply) => {
const user = await deps.authenticate(request);
const { variantId } = parseJson(variantParamSchema, request.params);
return reply.send(serializeCart(await service.removeItem(user.id, variantId)));
});
app.post('/cart/promo-code', async (request, reply) => {
const user = await deps.authenticate(request);
const { code } = parseJson(promoCodeBodySchema, request.body);
try {
return reply.send(serializeCart(await service.applyPromoCode(user.id, code)));
} catch (error) {
throw mapCartError(error);
}
});
}
function mapCartError(error: unknown): Error {
if (error instanceof InvalidCartQuantityError)
return new AppError(422, 'INVALID_CART_QUANTITY', error.message);
if (error instanceof Error && error.name.includes('Promotion'))
return new AppError(422, 'PROMOTION_INVALID', error.message);
return error instanceof Error ? error : new Error('Unknown cart error');
}
function serializeCart(cart: CartView) {
return {
id: cart.id,
userId: cart.userId,
items: cart.items.map(serializeItem),
currency: cart.currency,
netSubtotalCents: cart.netSubtotalCents,
vatAmountCents: cart.vatAmountCents,
discount: cart.discount,
totalBeforeDiscountCents: cart.totalBeforeDiscountCents,
totalCents: cart.totalCents,
createdAt: cart.createdAt.toISOString(),
updatedAt: cart.updatedAt.toISOString(),
};
}
function serializeItem(item: CartItemView) {
return {
id: item.id,
productId: item.productId,
variantId: item.variantId,
quantity: item.quantity,
available: item.available,
availability: item.availability,
pricing: item.pricing,
createdAt: item.createdAt.toISOString(),
updatedAt: item.updatedAt.toISOString(),
};
}

View File

@@ -0,0 +1,90 @@
import type { InventoryServicePort } from '../../inventory/index.js';
import type { PricingServicePort } from '../../pricing/index.js';
import type { PromotionServicePort } from '../../promotions/index.js';
import { InvalidCartQuantityError } from '../domain/errors.js';
import type { CartItemInput, CartView } from '../domain/cart.js';
import type { CartRepository } from '../domain/ports.js';
export class CartService {
constructor(
private readonly carts: CartRepository,
private readonly pricing: PricingServicePort,
private readonly inventory: InventoryServicePort,
private readonly promotions?: PromotionServicePort,
) {}
async getCart(userId: string): Promise<CartView> {
return this.toView(await this.carts.getOrCreate(userId));
}
async addItem(userId: string, input: CartItemInput): Promise<CartView> {
ensurePositiveQuantity(input.quantity);
return this.toView(await this.carts.addItem(userId, input));
}
async changeQuantity(userId: string, variantId: string, quantity: number): Promise<CartView> {
ensurePositiveQuantity(quantity);
return this.toView(await this.carts.changeQuantity(userId, variantId, quantity));
}
async removeItem(userId: string, variantId: string): Promise<CartView> {
return this.toView(await this.carts.removeItem(userId, variantId));
}
async applyPromoCode(userId: string, code: string): Promise<CartView> {
if (!this.promotions) return this.toView(await this.carts.setPromoCode(userId, code));
await this.promotions.validateCode(code);
return this.toView(await this.carts.setPromoCode(userId, code.trim().toUpperCase()));
}
private async toView(
cart: Awaited<ReturnType<CartRepository['getOrCreate']>>,
): Promise<CartView> {
const items = await Promise.all(
cart.items.map(async (item) => {
const [pricing, availability] = await Promise.all([
this.pricing
.calculate({ variantId: item.variantId, quantity: item.quantity })
.catch((error) => {
if (error instanceof Error && error.name === 'PriceNotFoundError') return null;
throw error;
}),
this.inventory.checkAvailability(item.variantId, item.quantity),
]);
return {
...item,
pricing,
availability,
available: pricing !== null && availability.available,
};
}),
);
const totalBeforeDiscountCents = items.reduce(
(sum, item) => sum + (item.pricing?.totalCents ?? 0),
0,
);
const discount =
cart.promoCode && this.promotions
? await this.promotions.calculateDiscount(cart.promoCode, totalBeforeDiscountCents)
: null;
return {
id: cart.id,
userId: cart.userId,
items,
currency: 'EUR',
netSubtotalCents: items.reduce((sum, item) => sum + (item.pricing?.netSubtotalCents ?? 0), 0),
vatAmountCents: items.reduce((sum, item) => sum + (item.pricing?.vatAmountCents ?? 0), 0),
discount,
totalBeforeDiscountCents,
totalCents: totalBeforeDiscountCents - (discount?.discountCents ?? 0),
createdAt: cart.createdAt,
updatedAt: cart.updatedAt,
};
}
}
function ensurePositiveQuantity(quantity: number): void {
if (!Number.isInteger(quantity) || quantity <= 0) throw new InvalidCartQuantityError();
}

View File

@@ -0,0 +1,48 @@
import type { PriceCalculation } from '../../pricing/index.js';
import type { PromotionDiscount } from '../../promotions/index.js';
import type { Availability } from '../../inventory/index.js';
export interface CartItem {
id: string;
cartId: string;
productId: string;
variantId: string;
quantity: number;
createdAt: Date;
updatedAt: Date;
}
export interface Cart {
id: string;
userId: string;
promoCode: string | null;
items: CartItem[];
createdAt: Date;
updatedAt: Date;
}
export interface CartItemInput {
productId: string;
variantId: string;
quantity: number;
}
export interface CartItemView extends CartItem {
pricing: PriceCalculation | null;
availability: Availability;
available: boolean;
}
export interface CartView {
id: string;
userId: string;
items: CartItemView[];
currency: 'EUR';
netSubtotalCents: number;
vatAmountCents: number;
discount: PromotionDiscount | null;
totalBeforeDiscountCents: number;
totalCents: number;
createdAt: Date;
updatedAt: Date;
}

View File

@@ -0,0 +1,6 @@
export class InvalidCartQuantityError extends Error {
constructor() {
super('Cart quantity must be a positive integer');
this.name = 'InvalidCartQuantityError';
}
}

View File

@@ -0,0 +1,9 @@
import type { Cart, CartItemInput } from './cart.js';
export interface CartRepository {
getOrCreate(userId: string): Promise<Cart>;
addItem(userId: string, input: CartItemInput): Promise<Cart>;
changeQuantity(userId: string, variantId: string, quantity: number): Promise<Cart>;
removeItem(userId: string, variantId: string): Promise<Cart>;
setPromoCode(userId: string, code: string | null): Promise<Cart>;
}

View File

@@ -0,0 +1,5 @@
/** Public API of the cart module. */
export { registerCartRoutes, type CartRoutesDeps } from './api/cart.routes.js';
export { CartService } from './application/cart-service.js';
export type { Cart, CartItem, CartItemInput, CartItemView, CartView } from './domain/cart.js';
export type { CartRepository } from './domain/ports.js';

View File

@@ -0,0 +1,111 @@
import type pg from 'pg';
import type { Cart, CartItem } from '../domain/cart.js';
import type { CartRepository } from '../domain/ports.js';
import type { CartItemInput } from '../domain/cart.js';
interface CartRow {
id: string;
user_id: string;
promo_code: string | null;
created_at: Date;
updated_at: Date;
}
interface ItemRow {
id: string;
cart_id: string;
product_id: string;
variant_id: string;
quantity: number;
created_at: Date;
updated_at: Date;
}
export class PgCartRepository implements CartRepository {
constructor(private readonly pool: pg.Pool) {}
async getOrCreate(userId: string): Promise<Cart> {
const cart = await this.ensureCart(userId);
return this.loadCart(cart);
}
async addItem(userId: string, input: CartItemInput): Promise<Cart> {
const cart = await this.ensureCart(userId);
await this.pool.query(
`INSERT INTO cart_items (cart_id, product_id, variant_id, quantity)
VALUES ($1, $2, $3, $4)
ON CONFLICT (cart_id, variant_id) DO UPDATE
SET quantity = cart_items.quantity + EXCLUDED.quantity, updated_at = now()`,
[cart.id, input.productId, input.variantId, input.quantity],
);
return this.loadCart(cart);
}
async changeQuantity(userId: string, variantId: string, quantity: number): Promise<Cart> {
const cart = await this.ensureCart(userId);
await this.pool.query(
`UPDATE cart_items SET quantity = $3, updated_at = now()
WHERE cart_id = $1 AND variant_id = $2`,
[cart.id, variantId, quantity],
);
return this.loadCart(cart);
}
async removeItem(userId: string, variantId: string): Promise<Cart> {
const cart = await this.ensureCart(userId);
await this.pool.query('DELETE FROM cart_items WHERE cart_id = $1 AND variant_id = $2', [
cart.id,
variantId,
]);
return this.loadCart(cart);
}
async setPromoCode(userId: string, code: string | null): Promise<Cart> {
const cart = await this.ensureCart(userId);
const result = await this.pool.query<CartRow>(
'UPDATE cart_carts SET promo_code = $2, updated_at = now() WHERE id = $1 RETURNING *',
[cart.id, code],
);
const row = result.rows[0];
if (!row) throw new Error('cart_carts promo update returned no row');
return this.loadCart(row);
}
private async ensureCart(userId: string): Promise<CartRow> {
const result = await this.pool.query<CartRow>(
`INSERT INTO cart_carts (user_id) VALUES ($1)
ON CONFLICT (user_id) DO UPDATE SET updated_at = cart_carts.updated_at
RETURNING *`,
[userId],
);
const row = result.rows[0];
if (!row) throw new Error('cart_carts upsert returned no row');
return row;
}
private async loadCart(row: CartRow): Promise<Cart> {
const result = await this.pool.query<ItemRow>(
'SELECT * FROM cart_items WHERE cart_id = $1 ORDER BY created_at, id',
[row.id],
);
return {
id: row.id,
userId: row.user_id,
promoCode: row.promo_code,
items: result.rows.map(toItem),
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
}
function toItem(row: ItemRow): CartItem {
return {
id: row.id,
cartId: row.cart_id,
productId: row.product_id,
variantId: row.variant_id,
quantity: row.quantity,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}

View File

@@ -0,0 +1,30 @@
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('cart persistence boundary', () => {
it('stores no price, tax, discount or stock columns', () => {
const migration = readFileSync(
new URL('../../../../migrations/013_cart.js', import.meta.url),
'utf8',
);
expect(migration).not.toMatch(/price|tax|vat|discount|stock/i);
});
it('does not import pricing or inventory internals', () => {
const cartDir = new URL('..', import.meta.url);
for (const file of sourceFiles(cartDir.pathname)) {
const source = readFileSync(file, 'utf8');
expect(source).not.toMatch(
/modules\/(pricing|inventory)\/(api|application|domain|infrastructure|tests)/,
);
}
});
});

View File

@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest';
import { CartService } from '../application/cart-service.js';
import type { CartRepository } from '../domain/ports.js';
import type { Cart } from '../domain/cart.js';
import type { PricingServicePort } from '../../pricing/index.js';
import type { InventoryServicePort } from '../../inventory/index.js';
const CART: Cart = {
id: 'cart-1',
userId: 'user-1',
promoCode: null,
createdAt: new Date('2026-01-01T00:00:00Z'),
updatedAt: new Date('2026-01-01T00:00:00Z'),
items: [
{
id: 'item-1',
cartId: 'cart-1',
productId: 'product-1',
variantId: 'variant-1',
quantity: 2,
createdAt: new Date('2026-01-01T00:00:00Z'),
updatedAt: new Date('2026-01-01T00:00:00Z'),
},
],
};
const repo: CartRepository = {
getOrCreate: async () => CART,
addItem: async () => CART,
changeQuantity: async () => CART,
removeItem: async () => ({ ...CART, items: [] }),
setPromoCode: async (_userId, code) => ({ ...CART, promoCode: code }),
};
const pricing: PricingServicePort = {
getVariantPrice: async () => undefined,
setVariantPrice: async () => {
throw new Error('not needed');
},
calculate: async ({ variantId, quantity }) => ({
variantId,
quantity,
currency: 'EUR',
vatRate: 'general',
vatBasisPoints: 2100,
netUnitAmountCents: 1000,
netSubtotalCents: quantity * 1000,
vatAmountCents: quantity * 210,
totalCents: quantity * 1210,
}),
};
const inventory: InventoryServicePort = {
checkAvailability: async () => ({ available: true, availableQuantity: 5 }),
reserve: async () => {
throw new Error('not needed');
},
release: async () => {
throw new Error('not needed');
},
confirm: async () => {
throw new Error('not needed');
},
setAvailable: async () => {
throw new Error('not needed');
},
};
describe('CartService', () => {
it('recalculates totals through PricingService and availability through InventoryService', async () => {
const view = await new CartService(repo, pricing, inventory).getCart('user-1');
expect(view.totalCents).toBe(2420);
expect(view.items[0]).toMatchObject({ available: true, pricing: { netUnitAmountCents: 1000 } });
});
});