457 lines
16 KiB
TypeScript
457 lines
16 KiB
TypeScript
import type { FastifyInstance } from 'fastify';
|
|
import type { FastifySchema } from 'fastify';
|
|
import type pg from 'pg';
|
|
import { z } from 'zod';
|
|
import type { Authenticate } from '../../../shared/auth.js';
|
|
import { requireRole } from '../../../shared/auth.js';
|
|
import { AppError } from '../../../shared/errors.js';
|
|
import { parseJson } from '../../../shared/http-input.js';
|
|
import { errorSchema } from '../../../shared/swagger.js';
|
|
import { OrderService } from '../application/order-service.js';
|
|
import { OrderNotFoundError, OrderStateTransitionError } from '../domain/errors.js';
|
|
import type { OrderState } from '../domain/order.js';
|
|
import { PgOrderRepository } from '../infrastructure/pg-order-repository.js';
|
|
import { NoOpOrderEventPublisher } from '../infrastructure/no-op-event-publisher.js';
|
|
import { sendOrderStatusEmail } from '../infrastructure/order-status-mailer.js';
|
|
|
|
export interface OrdersRoutesDeps {
|
|
pool: pg.Pool;
|
|
authenticate: Authenticate;
|
|
}
|
|
|
|
const orderItemSchema = z.object({
|
|
productId: z.uuid(),
|
|
variantId: z.uuid(),
|
|
sku: z.string().min(1).max(120),
|
|
ean: z.string().min(1).max(40).nullable().optional(),
|
|
name: z.string().min(1).max(200),
|
|
unitPriceCents: z.number().int().min(0),
|
|
discountCents: z.number().int().min(0),
|
|
taxCents: z.number().int().min(0),
|
|
quantity: z.number().int().positive(),
|
|
});
|
|
|
|
const createOrderSchema = z
|
|
.object({
|
|
idempotencyKey: z.string().min(1).max(120).optional().nullable(),
|
|
items: z.array(orderItemSchema).min(1),
|
|
totals: z.object({
|
|
subtotalCents: z.number().int().min(0),
|
|
discountCents: z.number().int().min(0),
|
|
taxCents: z.number().int().min(0),
|
|
totalCents: z.number().int().min(0),
|
|
}),
|
|
})
|
|
.strip();
|
|
|
|
const transitionSchema = z.object({
|
|
state: z.enum([
|
|
'PENDING',
|
|
'AWAITING_PAYMENT',
|
|
'PAID',
|
|
'PROCESSING',
|
|
'SHIPPED',
|
|
'DELIVERED',
|
|
'CANCELLED',
|
|
'REFUNDED',
|
|
'PARTIALLY_REFUNDED',
|
|
]),
|
|
});
|
|
|
|
const orderIdParamSchema = z.object({ id: z.uuid() });
|
|
|
|
export async function registerOrdersRoutes(
|
|
app: FastifyInstance,
|
|
deps: OrdersRoutesDeps,
|
|
): Promise<void> {
|
|
const service = new OrderService(new PgOrderRepository(deps.pool), new NoOpOrderEventPublisher());
|
|
|
|
const createOrderSwaggerSchema: FastifySchema = {
|
|
tags: ['Orders'],
|
|
summary: 'Create order',
|
|
description: 'Crea un pedido. Requiere autenticación.',
|
|
body: { type: 'object' },
|
|
response: { 201: { type: 'object' }, 401: errorSchema },
|
|
};
|
|
app.post('/orders', { schema: createOrderSwaggerSchema }, async (request, reply) => {
|
|
const user = await deps.authenticate(request);
|
|
const input = parseJson(createOrderSchema, request.body);
|
|
try {
|
|
const order = await service.create({
|
|
userId: user.id,
|
|
idempotencyKey: input.idempotencyKey ?? null,
|
|
items: input.items.map((item) => ({ ...item, ean: item.ean ?? null })),
|
|
totals: input.totals,
|
|
});
|
|
return reply.code(201).send(serializeOrder(order));
|
|
} catch (error) {
|
|
throw mapOrderError(error);
|
|
}
|
|
});
|
|
|
|
const transitionOrderSchema: FastifySchema = {
|
|
tags: ['Orders'],
|
|
summary: 'Transition order state (customer)',
|
|
params: {
|
|
type: 'object',
|
|
required: ['id'],
|
|
properties: { id: { type: 'string', format: 'uuid' } },
|
|
},
|
|
body: {
|
|
type: 'object',
|
|
required: ['state'],
|
|
properties: {
|
|
state: {
|
|
type: 'string',
|
|
enum: [
|
|
'PENDING',
|
|
'AWAITING_PAYMENT',
|
|
'PAID',
|
|
'PROCESSING',
|
|
'SHIPPED',
|
|
'DELIVERED',
|
|
'CANCELLED',
|
|
'REFUNDED',
|
|
'PARTIALLY_REFUNDED',
|
|
],
|
|
},
|
|
},
|
|
},
|
|
response: { 401: errorSchema, 404: errorSchema },
|
|
};
|
|
app.post('/orders/:id/transitions', { schema: transitionOrderSchema }, async (request, reply) => {
|
|
const user = await deps.authenticate(request);
|
|
const { id } = parseJson(orderIdParamSchema, request.params);
|
|
const { state } = parseJson(transitionSchema, request.body);
|
|
try {
|
|
const order = await service.transition(id, state as OrderState, user.id);
|
|
return reply.send(serializeOrder(order));
|
|
} catch (error) {
|
|
throw mapOrderError(error);
|
|
}
|
|
});
|
|
|
|
const getOrderSchema: FastifySchema = {
|
|
tags: ['Orders'],
|
|
summary: 'Get order',
|
|
description: 'Obtiene un pedido. El cliente solo ve los suyos; admin ve cualquiera.',
|
|
params: {
|
|
type: 'object',
|
|
required: ['id'],
|
|
properties: { id: { type: 'string', format: 'uuid' } },
|
|
},
|
|
response: { 401: errorSchema, 404: errorSchema },
|
|
};
|
|
app.get('/orders/:id', { schema: getOrderSchema }, async (request, reply) => {
|
|
const user = await deps.authenticate(request);
|
|
const { id } = parseJson(orderIdParamSchema, request.params);
|
|
const order = await service.getOrder(id, user.id);
|
|
if (!order) throw new AppError(404, 'ORDER_NOT_FOUND', 'Order not found');
|
|
return reply.send(serializeOrder(order));
|
|
});
|
|
|
|
// Admin-only routes
|
|
const listOrdersSchema: FastifySchema = {
|
|
tags: ['Orders'],
|
|
summary: 'List orders (admin)',
|
|
querystring: {
|
|
type: 'object',
|
|
properties: {
|
|
status: { type: 'string', description: 'Filtro por estado del pedido' },
|
|
q: { type: 'string', maxLength: 120, description: 'Búsqueda por ID o email del cliente' },
|
|
limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 },
|
|
offset: { type: 'integer', minimum: 0, default: 0 },
|
|
},
|
|
},
|
|
response: { 401: errorSchema, 403: errorSchema },
|
|
};
|
|
app.get('/orders', { schema: listOrdersSchema }, async (request, reply) => {
|
|
const user = await deps.authenticate(request);
|
|
requireRole(user, 'admin');
|
|
const query = request.query as { status?: string; q?: string; limit?: string; offset?: string };
|
|
const limit = Math.min(Math.max(Number(query.limit ?? 20) || 20, 1), 100);
|
|
const offset = Math.max(Number(query.offset ?? 0) || 0, 0);
|
|
const { items, total } = await service.searchOrders({
|
|
state: query.status?.trim() || undefined,
|
|
q: query.q?.trim() || undefined,
|
|
limit,
|
|
offset,
|
|
});
|
|
return reply.send({ items: items.map(serializeOrder), total });
|
|
});
|
|
|
|
const getOrderAdminSchema: FastifySchema = {
|
|
tags: ['Orders'],
|
|
summary: 'Get order (admin)',
|
|
params: {
|
|
type: 'object',
|
|
required: ['id'],
|
|
properties: { id: { type: 'string', format: 'uuid' } },
|
|
},
|
|
response: { 401: errorSchema, 403: errorSchema, 404: errorSchema },
|
|
};
|
|
app.get('/orders/:id/admin', { schema: getOrderAdminSchema }, async (request, reply) => {
|
|
const user = await deps.authenticate(request);
|
|
requireRole(user, 'admin');
|
|
const { id } = parseJson(orderIdParamSchema, request.params);
|
|
const order = await service.getOrderAdmin(id);
|
|
if (!order) throw new AppError(404, 'ORDER_NOT_FOUND', 'Order not found');
|
|
return reply.send(serializeOrder(order));
|
|
});
|
|
|
|
const adminTransitionSchema: FastifySchema = {
|
|
tags: ['Orders'],
|
|
summary: 'Force order transition (admin)',
|
|
description: 'Fuerza una transición de estado de pedido.',
|
|
params: {
|
|
type: 'object',
|
|
required: ['id'],
|
|
properties: { id: { type: 'string', format: 'uuid' } },
|
|
},
|
|
body: {
|
|
type: 'object',
|
|
required: ['state'],
|
|
properties: {
|
|
state: {
|
|
type: 'string',
|
|
enum: [
|
|
'PENDING',
|
|
'AWAITING_PAYMENT',
|
|
'PAID',
|
|
'PROCESSING',
|
|
'SHIPPED',
|
|
'DELIVERED',
|
|
'CANCELLED',
|
|
'REFUNDED',
|
|
'PARTIALLY_REFUNDED',
|
|
],
|
|
},
|
|
trackingNumber: { type: 'string', maxLength: 120, description: 'Número de seguimiento del envío (obligatorio al marcar como Enviado)' },
|
|
},
|
|
},
|
|
response: { 401: errorSchema, 403: errorSchema, 404: errorSchema },
|
|
};
|
|
app.post(
|
|
'/orders/:id/transitions/admin',
|
|
{ schema: adminTransitionSchema },
|
|
async (request, reply) => {
|
|
const user = await deps.authenticate(request);
|
|
requireRole(user, 'admin');
|
|
const { id } = parseJson(orderIdParamSchema, request.params);
|
|
const { state, trackingNumber } = parseJson(
|
|
transitionSchema.extend({ trackingNumber: z.string().min(1).max(120).optional() }),
|
|
request.body,
|
|
);
|
|
if (state === 'SHIPPED' && !trackingNumber?.trim()) {
|
|
throw new AppError(422, 'TRACKING_NUMBER_REQUIRED', 'Indica el número de seguimiento para marcar el pedido como enviado');
|
|
}
|
|
let order;
|
|
try {
|
|
order = await service.transitionAdmin(id, state as OrderState, trackingNumber?.trim() || undefined);
|
|
} catch (error) {
|
|
throw mapOrderError(error);
|
|
}
|
|
// Notificar al cliente el cambio de estado (sin bloquear la transición).
|
|
let notified = false;
|
|
let notificationError = '';
|
|
try {
|
|
const emailRow = await deps.pool.query<{ email: string }>(
|
|
'SELECT email FROM identity_users WHERE id = $1',
|
|
[order.userId],
|
|
);
|
|
const to = emailRow.rows[0]?.email;
|
|
if (to) {
|
|
await sendOrderStatusEmail(deps.pool, {
|
|
to,
|
|
orderId: order.id,
|
|
state: order.state,
|
|
trackingNumber: order.trackingNumber,
|
|
});
|
|
notified = true;
|
|
} else {
|
|
notificationError = 'El cliente no tiene email asociado';
|
|
}
|
|
} catch (error) {
|
|
notificationError = error instanceof Error ? error.message : 'No se pudo enviar el email';
|
|
request.log.warn({ err: error, orderId: order.id }, 'order_status_email_failed');
|
|
}
|
|
return reply.send({ ...serializeOrder(order), notified, notificationError: notificationError || undefined });
|
|
},
|
|
);
|
|
|
|
const editItemsSchema: FastifySchema = {
|
|
tags: ['Orders'],
|
|
summary: 'Edit order items (admin)',
|
|
description:
|
|
'Reemplaza los artículos del pedido (agregar/quitar productos y ajustar cantidades). Los totales se recalculan con los precios e IVA actuales.',
|
|
params: {
|
|
type: 'object',
|
|
required: ['id'],
|
|
properties: { id: { type: 'string', format: 'uuid' } },
|
|
},
|
|
body: { type: 'object' },
|
|
response: { 401: errorSchema, 403: errorSchema, 404: errorSchema, 422: errorSchema },
|
|
};
|
|
app.put('/orders/:id/items', { schema: editItemsSchema }, async (request, reply) => {
|
|
const user = await deps.authenticate(request);
|
|
requireRole(user, 'admin');
|
|
const { id } = parseJson(orderIdParamSchema, request.params);
|
|
const body = parseJson(
|
|
z.object({
|
|
items: z
|
|
.array(
|
|
z.object({
|
|
variantId: z.uuid(),
|
|
quantity: z.number().int().positive().max(999),
|
|
}),
|
|
)
|
|
.min(1),
|
|
}),
|
|
request.body,
|
|
);
|
|
|
|
const existing = await service.getOrderAdmin(id);
|
|
if (!existing) throw new AppError(404, 'ORDER_NOT_FOUND', 'Order not found');
|
|
|
|
// Consolidar cantidades por variante.
|
|
const byVariant = new Map<string, number>();
|
|
for (const item of body.items) {
|
|
byVariant.set(item.variantId, (byVariant.get(item.variantId) ?? 0) + item.quantity);
|
|
}
|
|
const variantIds = [...byVariant.keys()];
|
|
|
|
// Resolver datos de catálogo y precio vigente de cada variante.
|
|
const variantResult = await deps.pool.query<{
|
|
variant_id: string;
|
|
sku: string;
|
|
ean: string | null;
|
|
product_id: string;
|
|
product_name: string;
|
|
net_unit_amount_cents: number | null;
|
|
vat_rate: string | null;
|
|
}>(
|
|
`SELECT v.id AS variant_id, v.sku, v.ean, v.product_id, p.name AS product_name,
|
|
pr.net_unit_amount_cents, pr.vat_rate
|
|
FROM catalog_product_variants v
|
|
JOIN catalog_products p ON p.id = v.product_id
|
|
LEFT JOIN pricing_variant_prices pr ON pr.variant_id = v.id
|
|
WHERE v.id = ANY($1::uuid[])`,
|
|
[variantIds],
|
|
);
|
|
const found = new Map(variantResult.rows.map((row) => [row.variant_id, row]));
|
|
for (const variantId of variantIds) {
|
|
if (!found.has(variantId)) {
|
|
throw new AppError(422, 'VARIANT_NOT_FOUND', `La variante ${variantId.slice(0, 8)} no existe en el catálogo`);
|
|
}
|
|
const row = found.get(variantId);
|
|
if (row?.net_unit_amount_cents == null) {
|
|
throw new AppError(422, 'PRICE_MISSING', `Falta el precio de la variante ${row?.sku ?? variantId.slice(0, 8)}`);
|
|
}
|
|
}
|
|
|
|
const VAT_POINTS: Record<string, number> = { general: 2100, reduced: 1000, 'super-reduced': 400 };
|
|
const itemInputs = variantIds.map((variantId) => {
|
|
const row = found.get(variantId)!;
|
|
const quantity = byVariant.get(variantId)!;
|
|
const unitPriceCents = row.net_unit_amount_cents!;
|
|
const points = VAT_POINTS[row.vat_rate ?? 'general'] ?? 2100;
|
|
const taxCents = Math.round((unitPriceCents * quantity * points) / 10_000);
|
|
return {
|
|
productId: row.product_id,
|
|
variantId,
|
|
sku: row.sku,
|
|
ean: row.ean,
|
|
name: row.product_name,
|
|
unitPriceCents,
|
|
discountCents: 0,
|
|
taxCents,
|
|
quantity,
|
|
};
|
|
});
|
|
|
|
const netSubtotalCents = itemInputs.reduce((acc, item) => acc + item.unitPriceCents * item.quantity, 0);
|
|
const taxCents = itemInputs.reduce((acc, item) => acc + item.taxCents, 0);
|
|
const subtotalCents = netSubtotalCents + taxCents;
|
|
const discountCents = Math.min(existing.discountCents, subtotalCents);
|
|
// El coste de envío original se conserva: total - (subtotal - descuento).
|
|
const shippingCents = Math.max(0, existing.totalCents - Math.max(0, existing.subtotalCents - existing.discountCents));
|
|
const totalCents = Math.max(0, subtotalCents - discountCents) + shippingCents;
|
|
|
|
try {
|
|
const updated = await service.editItems(id, itemInputs, {
|
|
subtotalCents,
|
|
discountCents,
|
|
taxCents,
|
|
totalCents,
|
|
});
|
|
return reply.send(serializeOrder(updated));
|
|
} catch (error) {
|
|
throw mapOrderError(error);
|
|
}
|
|
});
|
|
}
|
|
|
|
function mapOrderError(error: unknown): Error {
|
|
if (error instanceof OrderStateTransitionError)
|
|
return new AppError(409, 'ORDER_STATE_TRANSITION_INVALID', error.message);
|
|
if (error instanceof OrderNotFoundError)
|
|
return new AppError(404, 'ORDER_NOT_FOUND', error.message);
|
|
return error instanceof Error ? error : new Error('Unknown order error');
|
|
}
|
|
|
|
function serializeOrder(order: {
|
|
id: string;
|
|
state: string;
|
|
currency: string;
|
|
subtotalCents: number;
|
|
discountCents: number;
|
|
taxCents: number;
|
|
totalCents: number;
|
|
trackingNumber?: string | null;
|
|
createdAt: Date;
|
|
updatedAt: Date;
|
|
idempotencyKey: string | null;
|
|
userId?: string;
|
|
items: Array<{
|
|
id: string;
|
|
productId: string;
|
|
variantId: string;
|
|
sku: string;
|
|
ean: string | null;
|
|
name: string;
|
|
unitPriceCents: number;
|
|
discountCents: number;
|
|
taxCents: number;
|
|
quantity: number;
|
|
createdAt: Date;
|
|
}>;
|
|
}) {
|
|
return {
|
|
id: order.id,
|
|
userId: order.userId,
|
|
state: order.state,
|
|
currency: order.currency,
|
|
subtotalCents: order.subtotalCents,
|
|
discountCents: order.discountCents,
|
|
taxCents: order.taxCents,
|
|
totalCents: order.totalCents,
|
|
trackingNumber: order.trackingNumber ?? null,
|
|
idempotencyKey: order.idempotencyKey,
|
|
items: order.items.map((item) => ({
|
|
id: item.id,
|
|
productId: item.productId,
|
|
variantId: item.variantId,
|
|
sku: item.sku,
|
|
ean: item.ean,
|
|
name: item.name,
|
|
unitPriceCents: item.unitPriceCents,
|
|
discountCents: item.discountCents,
|
|
taxCents: item.taxCents,
|
|
quantity: item.quantity,
|
|
createdAt: item.createdAt.toISOString(),
|
|
})),
|
|
createdAt: order.createdAt.toISOString(),
|
|
updatedAt: order.updatedAt.toISOString(),
|
|
};
|
|
}
|