214 lines
7.3 KiB
TypeScript
214 lines
7.3 KiB
TypeScript
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<void> {
|
|
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,
|
|
};
|
|
}
|