import type { FastifyInstance } from 'fastify'; import type { FastifySchema } 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 { errorSchema } from '../../../shared/swagger.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', 'super-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 { const pricing = new PricingService(new PgPricingRepository(deps.pool)); const getPriceSchema: FastifySchema = { tags: ['Pricing'], summary: 'Get variant price (público)', params: { type: 'object', required: ['variantId'], properties: { variantId: { type: 'string', format: 'uuid' } }, }, response: { 404: errorSchema }, }; app.get('/pricing/variants/:variantId', { schema: getPriceSchema }, 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)); }); const setPriceSchema: FastifySchema = { tags: ['Pricing'], summary: 'Set variant price (admin)', params: { type: 'object', required: ['variantId'], properties: { variantId: { type: 'string', format: 'uuid' } }, }, body: { type: 'object' }, response: { 401: errorSchema, 403: errorSchema }, }; app.put('/pricing/variants/:variantId', { schema: setPriceSchema }, 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); } }); const calcSchema: FastifySchema = { tags: ['Pricing'], summary: 'Calculate price (público)', description: 'Calcula el precio con IVA para una variante y cantidad.', body: { type: 'object' }, }; app.post('/pricing/calculate', { schema: calcSchema }, 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 ───────────────────────────────────────────────────────── const listTaxSchema: FastifySchema = { tags: ['Pricing'], summary: 'List tax rates (admin)', response: { 401: errorSchema, 403: errorSchema }, }; app.get('/admin/tax-rates', { schema: listTaxSchema }, 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, })), }); }); const patchTaxSchema: FastifySchema = { tags: ['Pricing'], summary: 'Update tax rate (admin)', params: { type: 'object', required: ['id'], properties: { id: { type: 'string', format: 'uuid' } }, }, body: { type: 'object' }, response: { 401: errorSchema, 403: errorSchema }, }; app.patch('/admin/tax-rates/:id', { schema: patchTaxSchema }, 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(), appliesTo: z.enum(['general', 'reduced', 'super-reduced']).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 (patch.appliesTo !== undefined) { sets.push(`applies_to = $${i++}`); values.push(patch.appliesTo); } 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, }; }