feat(ADM-018): completed feature
This commit is contained in:
120
project/src/modules/cart/api/cart.routes.ts
Normal file
120
project/src/modules/cart/api/cart.routes.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
90
project/src/modules/cart/application/cart-service.ts
Normal file
90
project/src/modules/cart/application/cart-service.ts
Normal 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();
|
||||
}
|
||||
48
project/src/modules/cart/domain/cart.ts
Normal file
48
project/src/modules/cart/domain/cart.ts
Normal 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;
|
||||
}
|
||||
6
project/src/modules/cart/domain/errors.ts
Normal file
6
project/src/modules/cart/domain/errors.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export class InvalidCartQuantityError extends Error {
|
||||
constructor() {
|
||||
super('Cart quantity must be a positive integer');
|
||||
this.name = 'InvalidCartQuantityError';
|
||||
}
|
||||
}
|
||||
9
project/src/modules/cart/domain/ports.ts
Normal file
9
project/src/modules/cart/domain/ports.ts
Normal 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>;
|
||||
}
|
||||
5
project/src/modules/cart/index.ts
Normal file
5
project/src/modules/cart/index.ts
Normal 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';
|
||||
111
project/src/modules/cart/infrastructure/pg-cart-repository.ts
Normal file
111
project/src/modules/cart/infrastructure/pg-cart-repository.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
30
project/src/modules/cart/tests/boundary.test.ts
Normal file
30
project/src/modules/cart/tests/boundary.test.ts
Normal 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)/,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
76
project/src/modules/cart/tests/cart-service.test.ts
Normal file
76
project/src/modules/cart/tests/cart-service.test.ts
Normal 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 } });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user