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:
@@ -105,28 +105,52 @@ describe.skipIf(!hasDb)('cart flows (real PostgreSQL)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('flags cart item unavailable when variant is out of stock (AC2)', async () => {
|
||||
it('rejects add when stock is zero (AC2 — F-138)', async () => {
|
||||
const productId = randomUUID();
|
||||
const variantId = randomUUID();
|
||||
await setPrice(variantId, 500);
|
||||
await setStock(variantId, 0);
|
||||
await app.inject({
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/cart/items',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
cookies: { [SESSION_COOKIE_NAME]: cookie },
|
||||
payload: { productId, variantId, quantity: 1 },
|
||||
});
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(response.json()).toMatchObject({ error: { code: 'INSUFFICIENT_STOCK' } });
|
||||
|
||||
const cart = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/cart',
|
||||
cookies: { [SESSION_COOKIE_NAME]: cookie },
|
||||
});
|
||||
const item = (cart.json().items as Array<{ variantId: string; available: boolean }>).find(
|
||||
(entry) => entry.variantId === variantId,
|
||||
);
|
||||
expect(item).toMatchObject({ available: false });
|
||||
const items = cart.json().items as Array<{ variantId: string }>;
|
||||
expect(items.find((entry) => entry.variantId === variantId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects add when total quantity exceeds stock (AC2b — F-138)', async () => {
|
||||
const productId = randomUUID();
|
||||
const variantId = randomUUID();
|
||||
await setPrice(variantId, 500);
|
||||
await setStock(variantId, 3);
|
||||
const first = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/cart/items',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
cookies: { [SESSION_COOKIE_NAME]: cookie },
|
||||
payload: { productId, variantId, quantity: 2 },
|
||||
});
|
||||
expect(first.statusCode).toBe(201);
|
||||
const oversell = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/cart/items',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
cookies: { [SESSION_COOKIE_NAME]: cookie },
|
||||
payload: { productId, variantId, quantity: 2 },
|
||||
});
|
||||
expect(oversell.statusCode).toBe(409);
|
||||
expect(oversell.json()).toMatchObject({ error: { code: 'INSUFFICIENT_STOCK' } });
|
||||
});
|
||||
|
||||
it('ignores client-supplied price fields in cart payloads (AC3)', async () => {
|
||||
|
||||
@@ -19,8 +19,16 @@ export class CartService {
|
||||
|
||||
async addItem(userId: string, input: CartItemInput): Promise<CartView> {
|
||||
ensurePositiveQuantity(input.quantity);
|
||||
// Keep unavailable lines visible in the cart so the customer can remove or
|
||||
// replace them; checkout remains the authoritative stock gate.
|
||||
// F-138: stock cap is enforced on every cart mutation. The previous
|
||||
// behaviour ("keep unavailable lines visible so the buyer can remove
|
||||
// them") allowed overselling in the online storefront: customers could
|
||||
// load 50 units of a 3-unit SKU into the cart and only failed at checkout.
|
||||
// Cap the resulting quantity to whatever is actually in stock and surface
|
||||
// the available amount to the caller via the standard error.
|
||||
const cart = await this.carts.getOrCreate(userId);
|
||||
const existing = cart.items.find((item) => item.variantId === input.variantId);
|
||||
const requested = (existing?.quantity ?? 0) + input.quantity;
|
||||
await this.assertStockAvailable(input.variantId, requested);
|
||||
return this.toView(await this.carts.addItem(userId, input));
|
||||
}
|
||||
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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})` : ''}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user