fix(release): 0.2.8 harden admin proxy, cart stock caps, selfpay and refund timeline

- admin proxy: 25s timeout, body guards, structured failure logs
- cart: addItem enforces stock cap (409 INSUFFICIENT_STOCK), UI clamps qty
- tpv selfpay: hide sidebar/discounts/save-pending, rename button, receipt-settings 400 fix
- pos admin: quick products slot count aligned to 8
- returns: human-readable history message + metadata jsonb (migrations 064-065) + admin fallback formatter
- storefront: product card white background
- product page: remove duplicate stock label under add-to-cart button
This commit is contained in:
Deploy
2026-08-25 23:41:29 +02:00
parent b6adf681d1
commit b6af852b54
29 changed files with 671 additions and 279 deletions

View File

@@ -57,6 +57,31 @@ async function assertReceiptAccess(
}
}
/**
* Resolves which store the admin POS settings page should target.
*
* Accepts the explicit `storeId` query value when present and well-formed;
* otherwise falls back to the first active store. This keeps the admin
* dashboard recoverable when the user lands on the page before the store
* dropdown is hydrated or the dropdown value is somehow invalid (F-139).
*/
async function resolveStoreIdForReceipt(
pool: pg.Pool,
rawStoreId: string | undefined,
): Promise<string> {
if (rawStoreId && storeIdSchema.safeParse(rawStoreId).success) {
return rawStoreId;
}
const fallback = await pool.query<{ id: string }>(
`SELECT id FROM pos_stores WHERE active = true ORDER BY created_at ASC LIMIT 1`,
);
const id = fallback.rows[0]?.id;
if (!id) {
throw new AppError(404, 'NO_ACTIVE_STORE', 'No hay tiendas activas configuradas');
}
return id;
}
export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps) {
const { pool, authenticate } = deps;
@@ -1208,8 +1233,7 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
summary: 'Get company and receipt numbering settings',
querystring: {
type: 'object',
required: ['storeId'],
properties: { storeId: { type: 'string', format: 'uuid' } },
properties: { storeId: { type: 'string' } },
},
response: { 401: errorSchema, 403: errorSchema, 404: errorSchema },
} as FastifySchema,
@@ -1217,7 +1241,11 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
async (request, reply) => {
const user = await authenticate(request);
requireRole(user, 'admin');
const { storeId } = parseJson(z.object({ storeId: storeIdSchema }), request.query);
const rawStoreId = (request.query as { storeId?: string }).storeId;
// F-139: the admin POS page can land before the store dropdown has a
// selection. Fall back to the first active store so the UI never sees a
// 400 caused by an empty querystring.
const storeId = await resolveStoreIdForReceipt(pool, rawStoreId);
await pool.query(
`INSERT INTO pos_receipt_settings (store_id) VALUES ($1)
ON CONFLICT (store_id) DO NOTHING`,

View File

@@ -15,6 +15,7 @@ interface OrderRow {
interface OrderItemRow {
id: string;
name: string;
quantity: number;
returned_quantity: number;
is_free_item: boolean;
@@ -131,7 +132,7 @@ export class ApplyPosReturnUseCase {
}
const itemRows = await client.query<OrderItemRow>(
`SELECT id, quantity, returned_quantity, is_free_item, variant_id,
`SELECT id, name, quantity, returned_quantity, is_free_item, variant_id,
unit_price_cents, discount_cents, tax_cents
FROM orders_items
WHERE order_id = $1
@@ -315,10 +316,18 @@ export class ApplyPosReturnUseCase {
],
);
await client.query(
`INSERT INTO orders_order_history (order_id, event_type, message, actor_email)
VALUES ($1, 'RETURNED', $2, (SELECT email FROM backoffice_users WHERE id = $3))`,
`INSERT INTO orders_order_history (order_id, event_type, message, actor_email, metadata)
VALUES ($1, 'RETURNED', $2, (SELECT email FROM backoffice_users WHERE id = $3), $4)`,
[
input.orderId,
formatReturnMessage({
refundedCents: totalRefundCents,
status: reportingStatus,
lines: input.lines,
reason: input.reason,
items: itemsById,
}),
input.userId,
JSON.stringify({
returnId,
refundedCents: totalRefundCents,
@@ -326,7 +335,6 @@ export class ApplyPosReturnUseCase {
lines: input.lines,
reason: input.reason,
}),
input.userId,
],
);
@@ -366,3 +374,38 @@ export class ApplyPosReturnUseCase {
}
}
}
interface FormatReturnMessageInput {
refundedCents: number;
status: 'refund' | 'partial_refund';
lines: ReadonlyArray<{ orderItemId: string; returnedQuantity: number }>;
reason: string;
items: ReadonlyMap<string, OrderItemRow>;
}
/**
* Renders a refund event as a short, human-readable line for the order
* timeline. We deliberately do NOT dump the raw payload here: customers and
* back-office staff see `event.message` and the JSON was unreadable.
* Structured fields are still stored on `orders_order_history.metadata` so
* support tooling can read them when needed.
*/
function formatReturnMessage(input: FormatReturnMessageInput): string {
const euros = (input.refundedCents / 100).toFixed(2);
const action = input.status === 'refund' ? 'Devolución total' : 'Devolución parcial';
const lineDescriptions = input.lines
.filter((line) => line.returnedQuantity > 0)
.map((line) => {
const item = input.items.get(line.orderItemId);
const name = item?.name ?? 'Artículo';
return `${line.returnedQuantity} × ${name}`;
});
const linesSummary =
lineDescriptions.length === 0
? ''
: `${lineDescriptions.slice(0, 3).join(', ')}${
lineDescriptions.length > 3 ? ` y ${lineDescriptions.length - 3} más` : ''
}`;
const reason = input.reason?.trim();
return `${action} de ${euros}${linesSummary}${reason ? ` (motivo: ${reason})` : ''}`;
}