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,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,
};
}