feat(ADM-018): completed feature
This commit is contained in:
208
project/src/modules/shipping/api/shipping.routes.ts
Normal file
208
project/src/modules/shipping/api/shipping.routes.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type pg from 'pg';
|
||||
import { z } from 'zod';
|
||||
import { requireRole, type Authenticate } from '../../../shared/auth.js';
|
||||
import { AppError } from '../../../shared/errors.js';
|
||||
import { parseJson } from '../../../shared/http-input.js';
|
||||
import { ShippingService } from '../application/shipping-service.js';
|
||||
import { InvalidShippingAddressError, ShippingZoneNotFoundError } from '../domain/errors.js';
|
||||
import type { ShippingQuote } from '../domain/shipping.js';
|
||||
import { PgShippingRepository } from '../infrastructure/pg-shipping-repository.js';
|
||||
|
||||
export interface ShippingRoutesDeps {
|
||||
pool: pg.Pool;
|
||||
authenticate: Authenticate;
|
||||
}
|
||||
|
||||
const zoneBodySchema = z.object({
|
||||
name: z.string().min(1).max(120),
|
||||
country: z.string().min(2).max(80),
|
||||
postalCodePrefix: z.string().min(1).max(20).optional().nullable(),
|
||||
active: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const methodBodySchema = z.object({
|
||||
zoneId: z.uuid(),
|
||||
name: z.string().min(1).max(120),
|
||||
baseCostCents: z.number().int().min(0),
|
||||
freeShippingThresholdCents: z.number().int().min(0).optional().nullable(),
|
||||
active: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const calculateBodySchema = z
|
||||
.object({
|
||||
cartTotalCents: z.number().int().min(0),
|
||||
country: z.string().min(2).max(80),
|
||||
postalCode: z.string().min(1).max(20),
|
||||
})
|
||||
.strip();
|
||||
|
||||
export async function registerShippingRoutes(
|
||||
app: FastifyInstance,
|
||||
deps: ShippingRoutesDeps,
|
||||
): Promise<void> {
|
||||
const service = new ShippingService(new PgShippingRepository(deps.pool));
|
||||
|
||||
app.post('/shipping/zones', async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const input = parseJson(zoneBodySchema, request.body);
|
||||
const result = await deps.pool.query<{ id: string }>(
|
||||
`INSERT INTO shipping_zones (name, country, postal_code_prefix, active)
|
||||
VALUES ($1, $2, $3, $4) RETURNING id`,
|
||||
[input.name, input.country, input.postalCodePrefix ?? null, input.active ?? true],
|
||||
);
|
||||
return reply.code(201).send({ id: result.rows[0]?.id });
|
||||
});
|
||||
|
||||
app.post('/shipping/methods', async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const input = parseJson(methodBodySchema, request.body);
|
||||
const result = await deps.pool.query<{ id: string }>(
|
||||
`INSERT INTO shipping_methods (zone_id, name, base_cost_cents, free_shipping_threshold_cents, active)
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING id`,
|
||||
[
|
||||
input.zoneId,
|
||||
input.name,
|
||||
input.baseCostCents,
|
||||
input.freeShippingThresholdCents ?? null,
|
||||
input.active ?? true,
|
||||
],
|
||||
);
|
||||
return reply.code(201).send({ id: result.rows[0]?.id });
|
||||
});
|
||||
|
||||
// ── Admin management ───────────────────────────────────────────────────────
|
||||
app.get('/admin/shipping/zones', async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const result = await deps.pool.query<{
|
||||
id: string; name: string; country: string; postal_code_prefix: string | null; active: boolean;
|
||||
}>('SELECT * FROM shipping_zones ORDER BY created_at DESC');
|
||||
return reply.send({ items: result.rows.map(r => ({
|
||||
id: r.id, name: r.name, country: r.country,
|
||||
postalCodePrefix: r.postal_code_prefix, active: r.active,
|
||||
})) });
|
||||
});
|
||||
|
||||
app.patch('/admin/shipping/zones/:id', async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const id = (request.params as { id: string }).id;
|
||||
const patch = parseJson(z.object({
|
||||
name: z.string().min(1).max(120).optional(),
|
||||
country: z.string().min(2).max(80).optional(),
|
||||
postalCodePrefix: z.string().max(20).optional().nullable(),
|
||||
active: z.boolean().optional(),
|
||||
}), request.body);
|
||||
const sets: string[] = []; const values: unknown[] = []; let i = 1;
|
||||
if (patch.name !== undefined) { sets.push(`name = $${i++}`); values.push(patch.name); }
|
||||
if (patch.country !== undefined) { sets.push(`country = $${i++}`); values.push(patch.country); }
|
||||
if (patch.postalCodePrefix !== undefined) { sets.push(`postal_code_prefix = $${i++}`); values.push(patch.postalCodePrefix); }
|
||||
if (patch.active !== undefined) { sets.push(`active = $${i++}`); values.push(patch.active); }
|
||||
if (!sets.length) { return reply.send({ ok: true }); }
|
||||
values.push(id);
|
||||
await deps.pool.query(`UPDATE shipping_zones SET ${sets.join(', ')} WHERE id = $${i}`, values);
|
||||
return reply.send({ ok: true });
|
||||
});
|
||||
|
||||
app.delete('/admin/shipping/zones/:id', async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const id = (request.params as { id: string }).id;
|
||||
await deps.pool.query('DELETE FROM shipping_methods WHERE zone_id = $1', [id]);
|
||||
await deps.pool.query('DELETE FROM shipping_zones WHERE id = $1', [id]);
|
||||
return reply.code(204).send();
|
||||
});
|
||||
|
||||
app.get('/admin/shipping/methods', async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const result = await deps.pool.query<{
|
||||
id: string; zone_id: string; zone_name: string; name: string;
|
||||
base_cost_cents: number; free_shipping_threshold_cents: number | null; active: boolean;
|
||||
}>(
|
||||
`SELECT sm.*, sz.name as zone_name FROM shipping_methods sm
|
||||
JOIN shipping_zones sz ON sz.id = sm.zone_id
|
||||
ORDER BY sm.created_at DESC`
|
||||
);
|
||||
return reply.send({ items: result.rows.map(r => ({
|
||||
id: r.id, zoneId: r.zone_id, zoneName: r.zone_name, name: r.name,
|
||||
baseCostCents: r.base_cost_cents, freeShippingThresholdCents: r.free_shipping_threshold_cents, active: r.active,
|
||||
})) });
|
||||
});
|
||||
|
||||
app.post('/admin/shipping/methods', async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const input = parseJson(methodBodySchema, request.body);
|
||||
const result = await deps.pool.query<{ id: string }>(
|
||||
`INSERT INTO shipping_methods (zone_id, name, base_cost_cents, free_shipping_threshold_cents, active)
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING id`,
|
||||
[input.zoneId, input.name, input.baseCostCents, input.freeShippingThresholdCents ?? null, input.active ?? true],
|
||||
);
|
||||
return reply.code(201).send({ id: result.rows[0]?.id });
|
||||
});
|
||||
|
||||
app.patch('/admin/shipping/methods/:id', async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const id = (request.params as { id: string }).id;
|
||||
const patch = parseJson(z.object({
|
||||
name: z.string().min(1).max(120).optional(),
|
||||
baseCostCents: z.number().int().min(0).optional(),
|
||||
freeShippingThresholdCents: z.number().int().min(0).optional().nullable(),
|
||||
active: z.boolean().optional(),
|
||||
}), request.body);
|
||||
const sets: string[] = []; const values: unknown[] = []; let i = 1;
|
||||
if (patch.name !== undefined) { sets.push(`name = $${i++}`); values.push(patch.name); }
|
||||
if (patch.baseCostCents !== undefined) { sets.push(`base_cost_cents = $${i++}`); values.push(patch.baseCostCents); }
|
||||
if (patch.freeShippingThresholdCents !== undefined) { sets.push(`free_shipping_threshold_cents = $${i++}`); values.push(patch.freeShippingThresholdCents); }
|
||||
if (patch.active !== undefined) { sets.push(`active = $${i++}`); values.push(patch.active); }
|
||||
if (!sets.length) { return reply.send({ ok: true }); }
|
||||
values.push(id);
|
||||
await deps.pool.query(`UPDATE shipping_methods SET ${sets.join(', ')} WHERE id = $${i}`, values);
|
||||
return reply.send({ ok: true });
|
||||
});
|
||||
|
||||
app.delete('/admin/shipping/methods/:id', async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const id = (request.params as { id: string }).id;
|
||||
await deps.pool.query('DELETE FROM shipping_methods WHERE id = $1', [id]);
|
||||
return reply.code(204).send();
|
||||
});
|
||||
|
||||
// ── Customer-facing ───────────────────────────────────────────────────────
|
||||
app.post('/shipping/calculate', async (request, reply) => {
|
||||
const input = parseJson(calculateBodySchema, request.body);
|
||||
try {
|
||||
const quote = await service.calculate(input.cartTotalCents, {
|
||||
country: input.country,
|
||||
postalCode: input.postalCode,
|
||||
});
|
||||
return reply.send(serializeQuote(quote));
|
||||
} catch (error) {
|
||||
throw mapShippingError(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function mapShippingError(error: unknown): Error {
|
||||
if (error instanceof ShippingZoneNotFoundError)
|
||||
return new AppError(422, 'SHIPPING_ZONE_NOT_FOUND', error.message);
|
||||
if (error instanceof InvalidShippingAddressError)
|
||||
return new AppError(400, 'INVALID_SHIPPING_ADDRESS', error.message);
|
||||
return error instanceof Error ? error : new Error('Unknown shipping error');
|
||||
}
|
||||
|
||||
function serializeQuote(quote: ShippingQuote) {
|
||||
return {
|
||||
zoneId: quote.zoneId,
|
||||
methodId: quote.methodId,
|
||||
methodName: quote.methodName,
|
||||
costCents: quote.costCents,
|
||||
freeApplied: quote.freeApplied,
|
||||
};
|
||||
}
|
||||
52
project/src/modules/shipping/application/shipping-service.ts
Normal file
52
project/src/modules/shipping/application/shipping-service.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { InvalidShippingAddressError, ShippingZoneNotFoundError } from '../domain/errors.js';
|
||||
import type {
|
||||
ShippingRepository,
|
||||
ShippingService as ShippingServicePort,
|
||||
} from '../domain/ports.js';
|
||||
import type { ShippingAddress, ShippingQuote } from '../domain/shipping.js';
|
||||
|
||||
export class ShippingService implements ShippingServicePort {
|
||||
constructor(private readonly repo: ShippingRepository) {}
|
||||
|
||||
async calculate(cartTotalCents: number, address: ShippingAddress): Promise<ShippingQuote> {
|
||||
ensureValidAddress(address);
|
||||
ensureNonNegativeTotal(cartTotalCents);
|
||||
|
||||
const match = await this.repo.findMatchingZone(address.country, address.postalCode);
|
||||
if (!match) throw new ShippingZoneNotFoundError();
|
||||
|
||||
const candidates = match.methods
|
||||
.filter((method) => method.active)
|
||||
.map((method) => {
|
||||
const freeApplied =
|
||||
method.freeShippingThresholdCents !== null &&
|
||||
cartTotalCents >= method.freeShippingThresholdCents;
|
||||
return { method, freeApplied, costCents: freeApplied ? 0 : method.baseCostCents };
|
||||
});
|
||||
if (candidates.length === 0) throw new ShippingZoneNotFoundError();
|
||||
|
||||
candidates.sort((a, b) => a.costCents - b.costCents);
|
||||
const chosen = candidates[0];
|
||||
if (!chosen) throw new ShippingZoneNotFoundError();
|
||||
return {
|
||||
zoneId: match.zoneId,
|
||||
methodId: chosen.method.id,
|
||||
methodName: chosen.method.name,
|
||||
costCents: chosen.costCents,
|
||||
freeApplied: chosen.freeApplied,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function ensureValidAddress(address: ShippingAddress): void {
|
||||
if (!address || typeof address !== 'object') throw new InvalidShippingAddressError();
|
||||
if (typeof address.country !== 'string' || address.country.length === 0)
|
||||
throw new InvalidShippingAddressError('country is required');
|
||||
if (typeof address.postalCode !== 'string' || address.postalCode.length === 0)
|
||||
throw new InvalidShippingAddressError('postalCode is required');
|
||||
}
|
||||
|
||||
function ensureNonNegativeTotal(total: number): void {
|
||||
if (!Number.isInteger(total) || total < 0)
|
||||
throw new InvalidShippingAddressError('cartTotalCents must be a non-negative integer');
|
||||
}
|
||||
13
project/src/modules/shipping/domain/errors.ts
Normal file
13
project/src/modules/shipping/domain/errors.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export class ShippingZoneNotFoundError extends Error {
|
||||
constructor() {
|
||||
super('Address is outside supported shipping zones');
|
||||
this.name = 'ShippingZoneNotFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidShippingAddressError extends Error {
|
||||
constructor(message = 'Invalid shipping address') {
|
||||
super(message);
|
||||
this.name = 'InvalidShippingAddressError';
|
||||
}
|
||||
}
|
||||
24
project/src/modules/shipping/domain/ports.ts
Normal file
24
project/src/modules/shipping/domain/ports.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { ShippingAddress, ShippingQuote } from './shipping.js';
|
||||
|
||||
export interface ShippingService {
|
||||
calculate(cartTotalCents: number, address: ShippingAddress): Promise<ShippingQuote>;
|
||||
}
|
||||
|
||||
export interface ShippingRepository {
|
||||
findMatchingZone(
|
||||
country: string,
|
||||
postalCode: string,
|
||||
): Promise<
|
||||
| {
|
||||
zoneId: string;
|
||||
methods: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
baseCostCents: number;
|
||||
freeShippingThresholdCents: number | null;
|
||||
active: boolean;
|
||||
}>;
|
||||
}
|
||||
| undefined
|
||||
>;
|
||||
}
|
||||
29
project/src/modules/shipping/domain/shipping.ts
Normal file
29
project/src/modules/shipping/domain/shipping.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export interface ShippingAddress {
|
||||
country: string;
|
||||
postalCode: string;
|
||||
}
|
||||
|
||||
export interface ShippingZone {
|
||||
id: string;
|
||||
name: string;
|
||||
country: string;
|
||||
postalCodePrefix: string | null;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface ShippingMethod {
|
||||
id: string;
|
||||
zoneId: string;
|
||||
name: string;
|
||||
baseCostCents: number;
|
||||
freeShippingThresholdCents: number | null;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface ShippingQuote {
|
||||
zoneId: string;
|
||||
methodId: string;
|
||||
methodName: string;
|
||||
costCents: number;
|
||||
freeApplied: boolean;
|
||||
}
|
||||
18
project/src/modules/shipping/index.ts
Normal file
18
project/src/modules/shipping/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/** Public API of the shipping module. */
|
||||
import type pg from 'pg';
|
||||
import { ShippingService } from './application/shipping-service.js';
|
||||
import { PgShippingRepository } from './infrastructure/pg-shipping-repository.js';
|
||||
|
||||
export { registerShippingRoutes, type ShippingRoutesDeps } from './api/shipping.routes.js';
|
||||
export { ShippingService } from './application/shipping-service.js';
|
||||
export type { ShippingService as ShippingServicePort, ShippingRepository } from './domain/ports.js';
|
||||
export type {
|
||||
ShippingAddress,
|
||||
ShippingMethod,
|
||||
ShippingQuote,
|
||||
ShippingZone,
|
||||
} from './domain/shipping.js';
|
||||
|
||||
export function createShippingService(pool: pg.Pool): ShippingService {
|
||||
return new ShippingService(new PgShippingRepository(pool));
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type pg from 'pg';
|
||||
import type { ShippingRepository } from '../domain/ports.js';
|
||||
|
||||
interface ZoneRow {
|
||||
id: string;
|
||||
postal_code_prefix: string | null;
|
||||
}
|
||||
interface MethodRow {
|
||||
id: string;
|
||||
name: string;
|
||||
base_cost_cents: number;
|
||||
free_shipping_threshold_cents: number | null;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
interface ZoneMatch {
|
||||
zoneId: string;
|
||||
methods: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
baseCostCents: number;
|
||||
freeShippingThresholdCents: number | null;
|
||||
active: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
export class PgShippingRepository implements ShippingRepository {
|
||||
constructor(private readonly pool: pg.Pool) {}
|
||||
|
||||
async findMatchingZone(country: string, postalCode: string): Promise<ZoneMatch | undefined> {
|
||||
const zones = await this.pool.query<ZoneRow>(
|
||||
`SELECT id, postal_code_prefix FROM shipping_zones
|
||||
WHERE country = $1 AND active = true
|
||||
AND (postal_code_prefix IS NULL OR $2 LIKE postal_code_prefix || '%')
|
||||
ORDER BY postal_code_prefix NULLS LAST`,
|
||||
[country, postalCode],
|
||||
);
|
||||
if (zones.rows.length === 0) return undefined;
|
||||
const best = zones.rows[0];
|
||||
if (!best) return undefined;
|
||||
|
||||
const methods = await this.pool.query<MethodRow>(
|
||||
`SELECT id, name, base_cost_cents, free_shipping_threshold_cents, active
|
||||
FROM shipping_methods WHERE zone_id = $1`,
|
||||
[best.id],
|
||||
);
|
||||
return {
|
||||
zoneId: best.id,
|
||||
methods: methods.rows.map((row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
baseCostCents: row.base_cost_cents,
|
||||
freeShippingThresholdCents: row.free_shipping_threshold_cents,
|
||||
active: row.active,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
82
project/src/modules/shipping/tests/shipping-service.test.ts
Normal file
82
project/src/modules/shipping/tests/shipping-service.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ShippingService } from '../application/shipping-service.js';
|
||||
import { InvalidShippingAddressError, ShippingZoneNotFoundError } from '../domain/errors.js';
|
||||
import type { ShippingRepository } from '../domain/ports.js';
|
||||
|
||||
function repo(
|
||||
zoneId: string | undefined,
|
||||
methods: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
baseCostCents: number;
|
||||
freeShippingThresholdCents: number | null;
|
||||
active: boolean;
|
||||
}>,
|
||||
): ShippingRepository {
|
||||
return {
|
||||
findMatchingZone: async () => (zoneId ? { zoneId, methods } : undefined),
|
||||
};
|
||||
}
|
||||
|
||||
describe('ShippingService', () => {
|
||||
it('returns the cheapest active method for a known zone (AC1)', async () => {
|
||||
const service = new ShippingService(
|
||||
repo('zone-1', [
|
||||
{
|
||||
id: 'm-1',
|
||||
name: 'Standard',
|
||||
baseCostCents: 500,
|
||||
freeShippingThresholdCents: null,
|
||||
active: true,
|
||||
},
|
||||
{
|
||||
id: 'm-2',
|
||||
name: 'Express',
|
||||
baseCostCents: 1200,
|
||||
freeShippingThresholdCents: null,
|
||||
active: true,
|
||||
},
|
||||
]),
|
||||
);
|
||||
const quote = await service.calculate(1000, { country: 'ES', postalCode: '28001' });
|
||||
expect(quote).toMatchObject({
|
||||
zoneId: 'zone-1',
|
||||
methodId: 'm-1',
|
||||
costCents: 500,
|
||||
freeApplied: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns zero cost when cart is above free shipping threshold (AC3)', async () => {
|
||||
const service = new ShippingService(
|
||||
repo('zone-1', [
|
||||
{
|
||||
id: 'm-1',
|
||||
name: 'Standard',
|
||||
baseCostCents: 500,
|
||||
freeShippingThresholdCents: 3000,
|
||||
active: true,
|
||||
},
|
||||
]),
|
||||
);
|
||||
const quote = await service.calculate(5000, { country: 'ES', postalCode: '28001' });
|
||||
expect(quote).toMatchObject({ costCents: 0, freeApplied: true });
|
||||
});
|
||||
|
||||
it('returns SHIPPING_ZONE_NOT_FOUND when no zone matches (AC2)', async () => {
|
||||
const service = new ShippingService(repo(undefined, []));
|
||||
await expect(
|
||||
service.calculate(1000, { country: 'ES', postalCode: '99999' }),
|
||||
).rejects.toBeInstanceOf(ShippingZoneNotFoundError);
|
||||
});
|
||||
|
||||
it('validates address and total', async () => {
|
||||
const service = new ShippingService(repo('zone-1', []));
|
||||
await expect(service.calculate(0, { country: '', postalCode: '' })).rejects.toBeInstanceOf(
|
||||
InvalidShippingAddressError,
|
||||
);
|
||||
await expect(
|
||||
service.calculate(-1, { country: 'ES', postalCode: '28001' }),
|
||||
).rejects.toBeInstanceOf(InvalidShippingAddressError);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user