feat(ADM-018): completed feature
This commit is contained in:
138
project/src/modules/pricing/api/pricing.routes.ts
Normal file
138
project/src/modules/pricing/api/pricing.routes.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
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 { PricingService } from '../application/pricing-service.js';
|
||||
import { InvalidPriceError, PriceNotFoundError } from '../domain/errors.js';
|
||||
import type { PriceCalculation, VariantPrice } from '../domain/price.js';
|
||||
import { PgPricingRepository } from '../infrastructure/pg-pricing-repository.js';
|
||||
|
||||
export interface PricingRoutesDeps {
|
||||
pool: pg.Pool;
|
||||
authenticate: Authenticate;
|
||||
}
|
||||
|
||||
const variantParamSchema = z.object({ variantId: z.uuid() });
|
||||
const vatRateSchema = z.enum(['general', 'reduced']);
|
||||
const setPriceBodySchema = z.object({
|
||||
netUnitAmountCents: z.number().int().min(0),
|
||||
offerCents: z.number().int().min(0).nullable().optional(),
|
||||
costCents: z.number().int().min(0).nullable().optional(),
|
||||
vatRate: vatRateSchema,
|
||||
});
|
||||
const calculateBodySchema = z
|
||||
.object({
|
||||
variantId: z.uuid(),
|
||||
quantity: z.number().int().positive(),
|
||||
})
|
||||
.strip();
|
||||
|
||||
export async function registerPricingRoutes(
|
||||
app: FastifyInstance,
|
||||
deps: PricingRoutesDeps,
|
||||
): Promise<void> {
|
||||
const pricing = new PricingService(new PgPricingRepository(deps.pool));
|
||||
|
||||
app.get('/pricing/variants/:variantId', async (request, reply) => {
|
||||
const { variantId } = parseJson(variantParamSchema, request.params);
|
||||
const price = await pricing.getVariantPrice(variantId);
|
||||
if (!price) {
|
||||
throw new AppError(404, 'PRICING_PRICE_NOT_FOUND', 'Variant price not found');
|
||||
}
|
||||
return reply.send(serializeVariantPrice(price));
|
||||
});
|
||||
|
||||
app.put('/pricing/variants/:variantId', async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { variantId } = parseJson(variantParamSchema, request.params);
|
||||
const input = parseJson(setPriceBodySchema, request.body);
|
||||
try {
|
||||
const price = await pricing.setVariantPrice({ variantId, ...input });
|
||||
return reply.send(serializeVariantPrice(price));
|
||||
} catch (error) {
|
||||
throw mapPricingError(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/pricing/calculate', async (request, reply) => {
|
||||
const input = parseJson(calculateBodySchema, request.body);
|
||||
try {
|
||||
const calculation = await pricing.calculate(input);
|
||||
return reply.send(serializeCalculation(calculation));
|
||||
} catch (error) {
|
||||
throw mapPricingError(error);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Admin tax rates ─────────────────────────────────────────────────────────
|
||||
app.get('/admin/tax-rates', async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const result = await deps.pool.query<{
|
||||
id: string; name: string; rate_percent: number; country: string; applies_to: string; active: boolean;
|
||||
}>('SELECT * FROM tax_rates ORDER BY rate_percent ASC');
|
||||
return reply.send({ items: result.rows.map(r => ({
|
||||
id: r.id, name: r.name, ratePercent: Number(r.rate_percent),
|
||||
country: r.country, appliesTo: r.applies_to, active: r.active,
|
||||
})) });
|
||||
});
|
||||
|
||||
app.patch('/admin/tax-rates/: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(40).optional(),
|
||||
ratePercent: z.number().min(0).max(100).optional(),
|
||||
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.ratePercent !== undefined) { sets.push(`rate_percent = $${i++}`); values.push(patch.ratePercent); }
|
||||
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 tax_rates SET ${sets.join(', ')} WHERE id = $${i}`, values);
|
||||
return reply.send({ ok: true });
|
||||
});
|
||||
}
|
||||
|
||||
function mapPricingError(error: unknown): Error {
|
||||
if (error instanceof PriceNotFoundError) {
|
||||
return new AppError(404, 'PRICING_PRICE_NOT_FOUND', error.message);
|
||||
}
|
||||
if (error instanceof InvalidPriceError) {
|
||||
return new AppError(422, 'INVALID_PRICE', error.message);
|
||||
}
|
||||
return error instanceof Error ? error : new Error('Unknown pricing error');
|
||||
}
|
||||
|
||||
function serializeVariantPrice(price: VariantPrice) {
|
||||
return {
|
||||
variantId: price.variantId,
|
||||
netUnitAmountCents: price.netUnitAmountCents,
|
||||
offerCents: price.offerCents,
|
||||
costCents: price.costCents,
|
||||
vatRate: price.vatRate,
|
||||
currency: price.currency,
|
||||
createdAt: price.createdAt.toISOString(),
|
||||
updatedAt: price.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function serializeCalculation(calculation: PriceCalculation) {
|
||||
return {
|
||||
variantId: calculation.variantId,
|
||||
quantity: calculation.quantity,
|
||||
currency: calculation.currency,
|
||||
vatRate: calculation.vatRate,
|
||||
vatBasisPoints: calculation.vatBasisPoints,
|
||||
netUnitAmountCents: calculation.netUnitAmountCents,
|
||||
netSubtotalCents: calculation.netSubtotalCents,
|
||||
vatAmountCents: calculation.vatAmountCents,
|
||||
totalCents: calculation.totalCents,
|
||||
};
|
||||
}
|
||||
57
project/src/modules/pricing/application/pricing-service.ts
Normal file
57
project/src/modules/pricing/application/pricing-service.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { InvalidPriceError, PriceNotFoundError } from '../domain/errors.js';
|
||||
import type { PricingRepository, PricingService as PricingServicePort } from '../domain/ports.js';
|
||||
import {
|
||||
calculateVatAmountCents,
|
||||
VAT_BASIS_POINTS,
|
||||
type PriceCalculation,
|
||||
type PriceCalculationCommand,
|
||||
type SetVariantPriceCommand,
|
||||
type VariantPrice,
|
||||
} from '../domain/price.js';
|
||||
|
||||
export class PricingService implements PricingServicePort {
|
||||
constructor(private readonly repository: PricingRepository) {}
|
||||
|
||||
async calculate(input: PriceCalculationCommand): Promise<PriceCalculation> {
|
||||
ensurePositiveInteger(input.quantity, 'Quantity must be a positive integer');
|
||||
const price = await this.repository.findByVariantId(input.variantId);
|
||||
if (!price) {
|
||||
throw new PriceNotFoundError();
|
||||
}
|
||||
|
||||
const netSubtotalCents = price.netUnitAmountCents * input.quantity;
|
||||
const vatAmountCents = calculateVatAmountCents(netSubtotalCents, price.vatRate);
|
||||
return {
|
||||
variantId: input.variantId,
|
||||
quantity: input.quantity,
|
||||
currency: price.currency,
|
||||
vatRate: price.vatRate,
|
||||
vatBasisPoints: VAT_BASIS_POINTS[price.vatRate],
|
||||
netUnitAmountCents: price.netUnitAmountCents,
|
||||
netSubtotalCents,
|
||||
vatAmountCents,
|
||||
totalCents: netSubtotalCents + vatAmountCents,
|
||||
};
|
||||
}
|
||||
|
||||
async getVariantPrice(variantId: string): Promise<VariantPrice | undefined> {
|
||||
return this.repository.findByVariantId(variantId);
|
||||
}
|
||||
|
||||
async setVariantPrice(input: SetVariantPriceCommand): Promise<VariantPrice> {
|
||||
ensureNonNegativeInteger(input.netUnitAmountCents, 'Price must be a non-negative integer');
|
||||
return this.repository.setVariantPrice(input);
|
||||
}
|
||||
}
|
||||
|
||||
function ensurePositiveInteger(value: number, message: string): void {
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
throw new InvalidPriceError(message);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureNonNegativeInteger(value: number, message: string): void {
|
||||
if (!Number.isInteger(value) || value < 0) {
|
||||
throw new InvalidPriceError(message);
|
||||
}
|
||||
}
|
||||
13
project/src/modules/pricing/domain/errors.ts
Normal file
13
project/src/modules/pricing/domain/errors.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export class PriceNotFoundError extends Error {
|
||||
constructor() {
|
||||
super('Variant price not found');
|
||||
this.name = 'PriceNotFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidPriceError extends Error {
|
||||
constructor(message = 'Invalid price command') {
|
||||
super(message);
|
||||
this.name = 'InvalidPriceError';
|
||||
}
|
||||
}
|
||||
17
project/src/modules/pricing/domain/ports.ts
Normal file
17
project/src/modules/pricing/domain/ports.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type {
|
||||
PriceCalculation,
|
||||
PriceCalculationCommand,
|
||||
SetVariantPriceCommand,
|
||||
VariantPrice,
|
||||
} from './price.js';
|
||||
|
||||
export interface PricingService {
|
||||
calculate(input: PriceCalculationCommand): Promise<PriceCalculation>;
|
||||
getVariantPrice(variantId: string): Promise<VariantPrice | undefined>;
|
||||
setVariantPrice(input: SetVariantPriceCommand): Promise<VariantPrice>;
|
||||
}
|
||||
|
||||
export interface PricingRepository {
|
||||
findByVariantId(variantId: string): Promise<VariantPrice | undefined>;
|
||||
setVariantPrice(input: SetVariantPriceCommand): Promise<VariantPrice>;
|
||||
}
|
||||
46
project/src/modules/pricing/domain/price.ts
Normal file
46
project/src/modules/pricing/domain/price.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
export type VatRate = 'general' | 'reduced';
|
||||
|
||||
export const VAT_BASIS_POINTS: Record<VatRate, number> = {
|
||||
general: 2100,
|
||||
reduced: 1000,
|
||||
};
|
||||
|
||||
export interface VariantPrice {
|
||||
variantId: string;
|
||||
netUnitAmountCents: number;
|
||||
offerCents: number | null;
|
||||
costCents: number | null;
|
||||
vatRate: VatRate;
|
||||
currency: 'EUR';
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface SetVariantPriceCommand {
|
||||
variantId: string;
|
||||
netUnitAmountCents: number;
|
||||
offerCents?: number | null;
|
||||
costCents?: number | null;
|
||||
vatRate: VatRate;
|
||||
}
|
||||
|
||||
export interface PriceCalculationCommand {
|
||||
variantId: string;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
export interface PriceCalculation {
|
||||
variantId: string;
|
||||
quantity: number;
|
||||
currency: 'EUR';
|
||||
vatRate: VatRate;
|
||||
vatBasisPoints: number;
|
||||
netUnitAmountCents: number;
|
||||
netSubtotalCents: number;
|
||||
vatAmountCents: number;
|
||||
totalCents: number;
|
||||
}
|
||||
|
||||
export function calculateVatAmountCents(netSubtotalCents: number, vatRate: VatRate): number {
|
||||
return Math.round((netSubtotalCents * VAT_BASIS_POINTS[vatRate]) / 10_000);
|
||||
}
|
||||
19
project/src/modules/pricing/index.ts
Normal file
19
project/src/modules/pricing/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/** Public API of the pricing module. */
|
||||
import type pg from 'pg';
|
||||
import { PricingService } from './application/pricing-service.js';
|
||||
import { PgPricingRepository } from './infrastructure/pg-pricing-repository.js';
|
||||
|
||||
export { registerPricingRoutes, type PricingRoutesDeps } from './api/pricing.routes.js';
|
||||
export { PricingService } from './application/pricing-service.js';
|
||||
export type { PricingRepository, PricingService as PricingServicePort } from './domain/ports.js';
|
||||
export type {
|
||||
PriceCalculation,
|
||||
PriceCalculationCommand,
|
||||
SetVariantPriceCommand,
|
||||
VariantPrice,
|
||||
VatRate,
|
||||
} from './domain/price.js';
|
||||
|
||||
export function createPricingService(pool: pg.Pool): PricingService {
|
||||
return new PricingService(new PgPricingRepository(pool));
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import type pg from 'pg';
|
||||
import type { PricingRepository } from '../domain/ports.js';
|
||||
import type { SetVariantPriceCommand, VariantPrice, VatRate } from '../domain/price.js';
|
||||
|
||||
interface PriceRow {
|
||||
variant_id: string;
|
||||
net_unit_amount_cents: number;
|
||||
offer_cents: number | null;
|
||||
cost_cents: number | null;
|
||||
vat_rate: VatRate;
|
||||
currency: 'EUR';
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export class PgPricingRepository implements PricingRepository {
|
||||
constructor(private readonly pool: pg.Pool) {}
|
||||
|
||||
async findByVariantId(variantId: string): Promise<VariantPrice | undefined> {
|
||||
const result = await this.pool.query<PriceRow>(
|
||||
'SELECT * FROM pricing_variant_prices WHERE variant_id = $1',
|
||||
[variantId],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
return row ? toVariantPrice(row) : undefined;
|
||||
}
|
||||
|
||||
async setVariantPrice(input: SetVariantPriceCommand): Promise<VariantPrice> {
|
||||
const client = await this.pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const previous = await client.query<PriceRow>(
|
||||
'SELECT * FROM pricing_variant_prices WHERE variant_id = $1 FOR UPDATE',
|
||||
[input.variantId],
|
||||
);
|
||||
const previousRow = previous.rows[0];
|
||||
const updated = await client.query<PriceRow>(
|
||||
`INSERT INTO pricing_variant_prices (variant_id, net_unit_amount_cents, offer_cents, cost_cents, vat_rate)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (variant_id) DO UPDATE
|
||||
SET net_unit_amount_cents = EXCLUDED.net_unit_amount_cents,
|
||||
offer_cents = EXCLUDED.offer_cents,
|
||||
cost_cents = EXCLUDED.cost_cents,
|
||||
vat_rate = EXCLUDED.vat_rate,
|
||||
updated_at = now()
|
||||
RETURNING *`,
|
||||
[
|
||||
input.variantId,
|
||||
input.netUnitAmountCents,
|
||||
input.offerCents ?? null,
|
||||
input.costCents ?? null,
|
||||
input.vatRate,
|
||||
],
|
||||
);
|
||||
const row = updated.rows[0];
|
||||
if (!row) {
|
||||
throw new Error('pricing_variant_prices upsert returned no row');
|
||||
}
|
||||
await client.query(
|
||||
`INSERT INTO pricing_price_history (
|
||||
variant_id,
|
||||
previous_net_unit_amount_cents,
|
||||
previous_vat_rate,
|
||||
previous_currency,
|
||||
new_net_unit_amount_cents,
|
||||
new_vat_rate,
|
||||
new_currency
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
[
|
||||
input.variantId,
|
||||
previousRow?.net_unit_amount_cents ?? null,
|
||||
previousRow?.vat_rate ?? null,
|
||||
previousRow?.currency ?? null,
|
||||
row.net_unit_amount_cents,
|
||||
row.vat_rate,
|
||||
row.currency,
|
||||
],
|
||||
);
|
||||
await client.query('COMMIT');
|
||||
return toVariantPrice(row);
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toVariantPrice(row: PriceRow): VariantPrice {
|
||||
return {
|
||||
variantId: row.variant_id,
|
||||
netUnitAmountCents: row.net_unit_amount_cents,
|
||||
offerCents: row.offer_cents,
|
||||
costCents: row.cost_cents,
|
||||
vatRate: row.vat_rate,
|
||||
currency: row.currency,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
27
project/src/modules/pricing/tests/boundary.test.ts
Normal file
27
project/src/modules/pricing/tests/boundary.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
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);
|
||||
if (statSync(path).isDirectory()) {
|
||||
return sourceFiles(path);
|
||||
}
|
||||
return path.endsWith('.ts') ? [path] : [];
|
||||
});
|
||||
}
|
||||
|
||||
describe('pricing module boundary', () => {
|
||||
it('keeps catalog isolated from pricing internals and tables', () => {
|
||||
const catalogDir = new URL('../../catalog', import.meta.url);
|
||||
|
||||
for (const file of sourceFiles(catalogDir.pathname)) {
|
||||
const source = readFileSync(file, 'utf8');
|
||||
expect(source).not.toContain('pricing_');
|
||||
expect(source).not.toMatch(
|
||||
/modules\/pricing\/(?:api|application|domain|infrastructure|tests)/,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
81
project/src/modules/pricing/tests/pricing-service.test.ts
Normal file
81
project/src/modules/pricing/tests/pricing-service.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { PricingService } from '../application/pricing-service.js';
|
||||
import { InvalidPriceError, PriceNotFoundError } from '../domain/errors.js';
|
||||
import type { PricingRepository } from '../domain/ports.js';
|
||||
import type { SetVariantPriceCommand, VariantPrice } from '../domain/price.js';
|
||||
|
||||
const PRICE: VariantPrice = {
|
||||
variantId: 'variant-1',
|
||||
netUnitAmountCents: 1000,
|
||||
offerCents: null,
|
||||
costCents: null,
|
||||
vatRate: 'general',
|
||||
currency: 'EUR',
|
||||
createdAt: new Date('2026-01-01T00:00:00Z'),
|
||||
updatedAt: new Date('2026-01-01T00:00:00Z'),
|
||||
};
|
||||
|
||||
function repository(overrides: Partial<PricingRepository> = {}): PricingRepository {
|
||||
return {
|
||||
findByVariantId: async () => PRICE,
|
||||
setVariantPrice: async (_input: SetVariantPriceCommand) => PRICE,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('PricingService', () => {
|
||||
it('calculates general VAT totals from server-side price (AC1)', async () => {
|
||||
const service = new PricingService(repository());
|
||||
|
||||
await expect(service.calculate({ variantId: 'variant-1', quantity: 2 })).resolves.toEqual({
|
||||
variantId: 'variant-1',
|
||||
quantity: 2,
|
||||
currency: 'EUR',
|
||||
vatRate: 'general',
|
||||
vatBasisPoints: 2100,
|
||||
netUnitAmountCents: 1000,
|
||||
netSubtotalCents: 2000,
|
||||
vatAmountCents: 420,
|
||||
totalCents: 2420,
|
||||
});
|
||||
});
|
||||
|
||||
it('calculates reduced VAT totals', async () => {
|
||||
const service = new PricingService(
|
||||
repository({ findByVariantId: async () => ({ ...PRICE, vatRate: 'reduced' }) }),
|
||||
);
|
||||
|
||||
await expect(service.calculate({ variantId: 'variant-1', quantity: 3 })).resolves.toMatchObject(
|
||||
{
|
||||
vatRate: 'reduced',
|
||||
vatBasisPoints: 1000,
|
||||
netSubtotalCents: 3000,
|
||||
vatAmountCents: 300,
|
||||
totalCents: 3300,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects invalid quantities and amounts', async () => {
|
||||
const service = new PricingService(repository());
|
||||
|
||||
await expect(service.calculate({ variantId: 'variant-1', quantity: 0 })).rejects.toBeInstanceOf(
|
||||
InvalidPriceError,
|
||||
);
|
||||
await expect(
|
||||
service.setVariantPrice({
|
||||
variantId: 'variant-1',
|
||||
netUnitAmountCents: -1,
|
||||
vatRate: 'general',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(InvalidPriceError);
|
||||
});
|
||||
|
||||
it('fails calculation when the variant has no server-side price', async () => {
|
||||
const service = new PricingService(repository({ findByVariantId: async () => undefined }));
|
||||
|
||||
await expect(service.calculate({ variantId: 'variant-1', quantity: 1 })).rejects.toBeInstanceOf(
|
||||
PriceNotFoundError,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user