feat(F-189): completed feature
This commit is contained in:
338
project/src/app/tests/pos-returns.itest.ts
Normal file
338
project/src/app/tests/pos-returns.itest.ts
Normal file
@@ -0,0 +1,338 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import argon2 from 'argon2';
|
||||
import type pg from 'pg';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { buildApp } from '../build-app.js';
|
||||
import { CreatePosSaleUseCase } from '../../modules/pos/index.js';
|
||||
import { createPool } from '../../infrastructure/db/pool.js';
|
||||
import {
|
||||
getTestDbUrl,
|
||||
recreateDatabase,
|
||||
runMigrations,
|
||||
} from '../../infrastructure/db/tests/db-test-support.js';
|
||||
|
||||
const hasDb = Boolean(process.env.TEST_DATABASE_URL);
|
||||
const STORE_ID = '00000000-0000-0000-0000-000000000001';
|
||||
const TERMINAL_ID = '20000000-0000-4000-8000-000000000189';
|
||||
const SESSION_ID = '30000000-0000-4000-8000-000000000189';
|
||||
const USER_ID = '10000000-0000-4000-8000-000000000189';
|
||||
const SESSION_TOKEN = 'f189-session';
|
||||
const PRODUCT_ID = '40000000-0000-4000-8000-000000000189';
|
||||
const VARIANT_ID = '50000000-0000-4000-8000-000000000189';
|
||||
|
||||
function tokenHash(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
function cookie(token: string): string {
|
||||
return `backoffice_session=${token}`;
|
||||
}
|
||||
|
||||
describe.skipIf(!hasDb)('F-189 POS returns (real PostgreSQL)', () => {
|
||||
const url = hasDb ? getTestDbUrl() : '';
|
||||
let pool: pg.Pool;
|
||||
let app: Awaited<ReturnType<typeof buildApp>>;
|
||||
|
||||
beforeAll(async () => {
|
||||
await recreateDatabase(url);
|
||||
await runMigrations(url, 'up');
|
||||
pool = createPool(url);
|
||||
const passwordHash = await argon2.hash('cashier-189');
|
||||
await pool.query(
|
||||
`INSERT INTO backoffice_users (id, email, password_hash, role)
|
||||
VALUES ($1, 'cashier-f189@example.test', $2, 'pos_cashier')`,
|
||||
[USER_ID, passwordHash],
|
||||
);
|
||||
await pool.query(
|
||||
`INSERT INTO backoffice_sessions (user_id, token_hash, expires_at)
|
||||
VALUES ($1, $2, now() + interval '1 hour')`,
|
||||
[USER_ID, tokenHash(SESSION_TOKEN)],
|
||||
);
|
||||
await pool.query(
|
||||
`INSERT INTO pos_terminals (id, store_id, name, binding_code, bound_at)
|
||||
VALUES ($1, $2, 'Caja F-189', 'F189CODE', now())`,
|
||||
[TERMINAL_ID, STORE_ID],
|
||||
);
|
||||
await pool.query(
|
||||
`INSERT INTO pos_cash_sessions (
|
||||
id, terminal_id, store_id, user_id, opening_cash_cents, expected_cash_cents
|
||||
) VALUES ($1, $2, $3, $4, 1000, 1000)`,
|
||||
[SESSION_ID, TERMINAL_ID, STORE_ID, USER_ID],
|
||||
);
|
||||
await pool.query(
|
||||
`INSERT INTO catalog_products (id, name, slug, state)
|
||||
VALUES ($1, 'Producto F-189', 'producto-f189', 'active')`,
|
||||
[PRODUCT_ID],
|
||||
);
|
||||
await pool.query(
|
||||
`INSERT INTO catalog_product_variants (id, product_id, sku, ean)
|
||||
VALUES ($1, $2, 'SKU-F189', '8412345678903')`,
|
||||
[VARIANT_ID, PRODUCT_ID],
|
||||
);
|
||||
await pool.query(
|
||||
`INSERT INTO pricing_variant_prices (variant_id, net_unit_amount_cents, vat_rate)
|
||||
VALUES ($1, 1000, 'reduced')`,
|
||||
[VARIANT_ID],
|
||||
);
|
||||
await pool.query(
|
||||
`INSERT INTO inventory_stock (variant_id, store_id, available)
|
||||
VALUES ($1, $2, 10)`,
|
||||
[VARIANT_ID, STORE_ID],
|
||||
);
|
||||
app = await buildApp({ pool, cookieSecure: false });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
it('partially returns a sale, restores partial stock and stays PARTIALLY_REFUNDED', async () => {
|
||||
const useCase = new CreatePosSaleUseCase(pool);
|
||||
const sale = await useCase.execute({
|
||||
idempotencyKey: 'f189-sale-partial',
|
||||
cashSessionId: SESSION_ID,
|
||||
terminalId: TERMINAL_ID,
|
||||
userId: USER_ID,
|
||||
items: [{ kind: 'stock', variantId: VARIANT_ID, quantity: 3, discountCents: 0 }],
|
||||
payments: [{ methodCode: 'cash', amountCents: 3000, tenderedCents: 3000 }],
|
||||
});
|
||||
expect(sale.state).toBe('COMPLETED');
|
||||
|
||||
const items = await pool.query<{ id: string }>(
|
||||
`SELECT id FROM orders_items WHERE order_id = $1`,
|
||||
[sale.orderId],
|
||||
);
|
||||
const firstItemId = items.rows[0]?.id;
|
||||
expect(firstItemId).toBeTruthy();
|
||||
|
||||
const stockBefore = await pool.query<{ available: number }>(
|
||||
`SELECT available FROM inventory_stock WHERE variant_id = $1 AND store_id = $2`,
|
||||
[VARIANT_ID, STORE_ID],
|
||||
);
|
||||
expect(Number(stockBefore.rows[0]?.available)).toBe(7);
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/pos/sales/${sale.orderId}/returns`,
|
||||
headers: { cookie: cookie(SESSION_TOKEN), 'x-terminal-id': TERMINAL_ID },
|
||||
payload: {
|
||||
idempotencyKey: 'f189-rest-partial',
|
||||
cashSessionId: SESSION_ID,
|
||||
terminalId: TERMINAL_ID,
|
||||
reason: 'Cliente cambió de opinión',
|
||||
items: [{ orderItemId: firstItemId!, returnedQuantity: 1 }],
|
||||
},
|
||||
});
|
||||
expect(response.statusCode).toBe(201);
|
||||
const body = response.json();
|
||||
expect(body.state).toBe('PARTIALLY_REFUNDED');
|
||||
expect(body.refundedCents).toBe(1000);
|
||||
expect(body.receipt.receiptNumber.startsWith('R-')).toBe(true);
|
||||
expect(body.receipt.totalCents).toBe(-1000);
|
||||
expect(body.receipt.items[0].quantity).toBe(1);
|
||||
expect(body.receipt.items[0].totalCents).toBe(-1000);
|
||||
|
||||
const order = await pool.query<{ state: string }>(
|
||||
`SELECT state FROM orders_orders WHERE id = $1`,
|
||||
[sale.orderId],
|
||||
);
|
||||
expect(order.rows[0]?.state).toBe('PARTIALLY_REFUNDED');
|
||||
|
||||
const itemRow = await pool.query<{ returned_quantity: number }>(
|
||||
`SELECT returned_quantity FROM orders_items WHERE id = $1`,
|
||||
[firstItemId!],
|
||||
);
|
||||
expect(Number(itemRow.rows[0]?.returned_quantity)).toBe(1);
|
||||
|
||||
const stockAfter = await pool.query<{ available: number }>(
|
||||
`SELECT available FROM inventory_stock WHERE variant_id = $1 AND store_id = $2`,
|
||||
[VARIANT_ID, STORE_ID],
|
||||
);
|
||||
expect(Number(stockAfter.rows[0]?.available)).toBe(8);
|
||||
|
||||
const event = await pool.query<{ event_type: string }>(
|
||||
`SELECT event_type FROM orders_order_history WHERE order_id = $1 AND event_type = 'RETURNED'`,
|
||||
[sale.orderId],
|
||||
);
|
||||
expect(event.rows.length).toBe(1);
|
||||
|
||||
const reporting = await pool.query<{ status: string; amount_cents: number }>(
|
||||
`SELECT status, amount_cents FROM reporting_payment_lines
|
||||
WHERE order_id = $1 AND provider = 'pos_return'`,
|
||||
[sale.orderId],
|
||||
);
|
||||
expect(reporting.rows[0]?.status).toBe('partial_refund');
|
||||
expect(Number(reporting.rows[0]?.amount_cents)).toBe(1000);
|
||||
|
||||
const session = await pool.query<{ expected_cash_cents: number }>(
|
||||
`SELECT expected_cash_cents FROM pos_cash_sessions WHERE id = $1`,
|
||||
[SESSION_ID],
|
||||
);
|
||||
expect(Number(session.rows[0]?.expected_cash_cents)).toBeLessThanOrEqual(4000);
|
||||
});
|
||||
|
||||
it('fully returns a sale and transitions to REFUNDED', async () => {
|
||||
const useCase = new CreatePosSaleUseCase(pool);
|
||||
const sale = await useCase.execute({
|
||||
idempotencyKey: 'f189-sale-full',
|
||||
cashSessionId: SESSION_ID,
|
||||
terminalId: TERMINAL_ID,
|
||||
userId: USER_ID,
|
||||
items: [
|
||||
{ kind: 'stock', variantId: VARIANT_ID, quantity: 1, discountCents: 0 },
|
||||
{ kind: 'free', name: 'Mano de obra', unitPriceCents: 500, quantity: 1 },
|
||||
],
|
||||
payments: [{ methodCode: 'cash', amountCents: 1500, tenderedCents: 1500 }],
|
||||
});
|
||||
expect(sale.state).toBe('COMPLETED');
|
||||
|
||||
const items = await pool.query<{ id: string; is_free_item: boolean }>(
|
||||
`SELECT id, is_free_item FROM orders_items WHERE order_id = $1 ORDER BY is_free_item DESC, id`,
|
||||
[sale.orderId],
|
||||
);
|
||||
const stockItemId = items.rows.find((r) => !r.is_free_item)?.id;
|
||||
const freeItemId = items.rows.find((r) => r.is_free_item)?.id;
|
||||
expect(stockItemId).toBeTruthy();
|
||||
expect(freeItemId).toBeTruthy();
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/pos/sales/${sale.orderId}/returns`,
|
||||
headers: { cookie: cookie(SESSION_TOKEN), 'x-terminal-id': TERMINAL_ID },
|
||||
payload: {
|
||||
idempotencyKey: 'f189-rest-full',
|
||||
cashSessionId: SESSION_ID,
|
||||
terminalId: TERMINAL_ID,
|
||||
reason: 'Devolución completa',
|
||||
items: [
|
||||
{ orderItemId: stockItemId!, returnedQuantity: 1 },
|
||||
{ orderItemId: freeItemId!, returnedQuantity: 1 },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(response.statusCode).toBe(201);
|
||||
const body = response.json();
|
||||
expect(body.state).toBe('REFUNDED');
|
||||
expect(body.refundedCents).toBe(1500);
|
||||
|
||||
const stock = await pool.query<{ available: number }>(
|
||||
`SELECT available FROM inventory_stock WHERE variant_id = $1 AND store_id = $2`,
|
||||
[VARIANT_ID, STORE_ID],
|
||||
);
|
||||
expect(Number(stock.rows[0]?.available)).toBe(8);
|
||||
|
||||
const reporting = await pool.query<{ status: string }>(
|
||||
`SELECT status FROM reporting_payment_lines
|
||||
WHERE order_id = $1 AND provider = 'pos_return'`,
|
||||
[sale.orderId],
|
||||
);
|
||||
expect(reporting.rows[0]?.status).toBe('refund');
|
||||
});
|
||||
|
||||
it('rejects over-return with POS_RETURN_EXCEEDS_AVAILABLE', async () => {
|
||||
const useCase = new CreatePosSaleUseCase(pool);
|
||||
const sale = await useCase.execute({
|
||||
idempotencyKey: 'f189-sale-overreturn',
|
||||
cashSessionId: SESSION_ID,
|
||||
terminalId: TERMINAL_ID,
|
||||
userId: USER_ID,
|
||||
items: [{ kind: 'stock', variantId: VARIANT_ID, quantity: 1, discountCents: 0 }],
|
||||
payments: [{ methodCode: 'cash', amountCents: 1000, tenderedCents: 1000 }],
|
||||
});
|
||||
|
||||
const items = await pool.query<{ id: string }>(
|
||||
`SELECT id FROM orders_items WHERE order_id = $1`,
|
||||
[sale.orderId],
|
||||
);
|
||||
const itemId = items.rows[0]!.id;
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/pos/sales/${sale.orderId}/returns`,
|
||||
headers: { cookie: cookie(SESSION_TOKEN), 'x-terminal-id': TERMINAL_ID },
|
||||
payload: {
|
||||
idempotencyKey: 'f189-rest-over',
|
||||
cashSessionId: SESSION_ID,
|
||||
terminalId: TERMINAL_ID,
|
||||
reason: 'Exceso',
|
||||
items: [{ orderItemId: itemId, returnedQuantity: 5 }],
|
||||
},
|
||||
});
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(response.json().error.code).toBe('POS_RETURN_EXCEEDS_AVAILABLE');
|
||||
});
|
||||
|
||||
it('replays the same idempotency key without duplicating rows', async () => {
|
||||
const useCase = new CreatePosSaleUseCase(pool);
|
||||
const sale = await useCase.execute({
|
||||
idempotencyKey: 'f189-sale-replay',
|
||||
cashSessionId: SESSION_ID,
|
||||
terminalId: TERMINAL_ID,
|
||||
userId: USER_ID,
|
||||
items: [{ kind: 'stock', variantId: VARIANT_ID, quantity: 2, discountCents: 0 }],
|
||||
payments: [{ methodCode: 'cash', amountCents: 2000, tenderedCents: 2000 }],
|
||||
});
|
||||
|
||||
const items = await pool.query<{ id: string }>(
|
||||
`SELECT id FROM orders_items WHERE order_id = $1`,
|
||||
[sale.orderId],
|
||||
);
|
||||
const itemId = items.rows[0]!.id;
|
||||
|
||||
const first = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/pos/sales/${sale.orderId}/returns`,
|
||||
headers: { cookie: cookie(SESSION_TOKEN), 'x-terminal-id': TERMINAL_ID },
|
||||
payload: {
|
||||
idempotencyKey: 'f189-rest-replay',
|
||||
cashSessionId: SESSION_ID,
|
||||
terminalId: TERMINAL_ID,
|
||||
reason: 'Idem',
|
||||
items: [{ orderItemId: itemId, returnedQuantity: 1 }],
|
||||
},
|
||||
});
|
||||
expect(first.statusCode).toBe(201);
|
||||
expect(first.json().returnId).toBeTruthy();
|
||||
|
||||
const second = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/pos/sales/${sale.orderId}/returns`,
|
||||
headers: { cookie: cookie(SESSION_TOKEN), 'x-terminal-id': TERMINAL_ID },
|
||||
payload: {
|
||||
idempotencyKey: 'f189-rest-replay',
|
||||
cashSessionId: SESSION_ID,
|
||||
terminalId: TERMINAL_ID,
|
||||
reason: 'Idem',
|
||||
items: [{ orderItemId: itemId, returnedQuantity: 1 }],
|
||||
},
|
||||
});
|
||||
expect(second.statusCode).toBe(201);
|
||||
expect(second.json().returnId).toBeTruthy();
|
||||
expect(second.json().returnId).toBe(first.json().returnId);
|
||||
console.log('FIRST', first.json());
|
||||
console.log('SECOND', second.json());
|
||||
|
||||
const returns = await pool.query<{ count: number }>(
|
||||
`SELECT COUNT(*)::int AS count FROM pos_returns WHERE order_id = $1`,
|
||||
[sale.orderId],
|
||||
);
|
||||
expect(returns.rows[0]?.count).toBe(1);
|
||||
|
||||
const stock = await pool.query<{ available: number }>(
|
||||
`SELECT available FROM inventory_stock WHERE variant_id = $1 AND store_id = $2`,
|
||||
[VARIANT_ID, STORE_ID],
|
||||
);
|
||||
expect(Number(stock.rows[0]?.available)).toBe(6);
|
||||
});
|
||||
|
||||
it('returns 404 on the legacy /refund route', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/pos/sales/00000000-0000-4000-8000-000000000000/refund',
|
||||
headers: { cookie: cookie(SESSION_TOKEN), 'x-terminal-id': TERMINAL_ID },
|
||||
payload: { refundAmountCents: 100, reason: 'legacy' },
|
||||
});
|
||||
expect(response.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import { OpenCashSessionUseCase } from '../application/open-cash-session.js';
|
||||
import { CloseCashSessionUseCase } from '../application/close-cash-session.js';
|
||||
import { CreatePosSaleUseCase } from '../application/create-pos-sale.js';
|
||||
import { ReceiveRestPaymentUseCase } from '../application/receive-rest-payment.js';
|
||||
import { ApplyPosReturnUseCase } from '../application/apply-pos-return.js';
|
||||
import { buildPosReceipt } from '../application/build-pos-receipt.js';
|
||||
import { sendTransactionalEmail } from '../../notifications/index.js';
|
||||
import { Argon2PasswordHasher } from '../../identity/index.js';
|
||||
@@ -64,6 +65,7 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
|
||||
const sessionRepo = new PgCashSessionRepository(pool);
|
||||
const createPosSale = new CreatePosSaleUseCase(pool);
|
||||
const receiveRestPayment = new ReceiveRestPaymentUseCase(pool);
|
||||
const applyPosReturn = new ApplyPosReturnUseCase(pool);
|
||||
|
||||
const listStores = new ListStoresUseCase(storeRepo);
|
||||
const listTerminals = new ListTerminalsUseCase(terminalRepo);
|
||||
@@ -1558,56 +1560,82 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
|
||||
},
|
||||
);
|
||||
|
||||
// ── POS-012: Refund + receipt print + analytics ────────────────────────────
|
||||
// ── POS-012: POS returns + receipt print + analytics ──────────────────────
|
||||
|
||||
app.post<{ Params: { id: string } }>(
|
||||
'/pos/sales/:id/refund',
|
||||
'/pos/sales/:id/returns',
|
||||
{
|
||||
schema: {
|
||||
tags: ['POS Terminal'],
|
||||
summary: 'Refund a POS sale',
|
||||
summary: 'Apply a partial or full return to a POS sale',
|
||||
params: { type: 'object', properties: { id: { type: 'string', format: 'uuid' } } },
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['refundAmountCents', 'reason'],
|
||||
required: ['idempotencyKey', 'cashSessionId', 'terminalId', 'reason', 'items'],
|
||||
properties: {
|
||||
refundAmountCents: { type: 'integer', minimum: 1 },
|
||||
reason: { type: 'string', minLength: 1 },
|
||||
idempotencyKey: { type: 'string', minLength: 1, maxLength: 128 },
|
||||
cashSessionId: { type: 'string', format: 'uuid' },
|
||||
terminalId: { type: 'string', format: 'uuid' },
|
||||
reason: { type: 'string', minLength: 1, maxLength: 500 },
|
||||
items: {
|
||||
type: 'array',
|
||||
minItems: 1,
|
||||
items: {
|
||||
type: 'object',
|
||||
required: ['orderItemId', 'returnedQuantity'],
|
||||
properties: {
|
||||
orderItemId: { type: 'string', format: 'uuid' },
|
||||
returnedQuantity: { type: 'integer', minimum: 1 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
response: { 400: errorSchema, 401: errorSchema, 403: errorSchema, 404: errorSchema },
|
||||
response: {
|
||||
400: errorSchema,
|
||||
401: errorSchema,
|
||||
403: errorSchema,
|
||||
404: errorSchema,
|
||||
409: errorSchema,
|
||||
},
|
||||
} as FastifySchema,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const user = await authenticate(request);
|
||||
requireAnyRole(user, ['admin', 'pos_manager', 'pos_cashier'] as ReadonlyArray<Role>);
|
||||
const { id } = request.params;
|
||||
const body = (request.body ?? {}) as { refundAmountCents?: number; reason?: string };
|
||||
const order = await pool.query<{ id: string; total_cents: number }>(
|
||||
'SELECT id, total_cents FROM orders_orders WHERE id = $1',
|
||||
[id],
|
||||
const body = parseJson(
|
||||
z.object({
|
||||
idempotencyKey: z.string().min(1).max(128),
|
||||
cashSessionId: z.string().uuid(),
|
||||
terminalId: z.string().uuid(),
|
||||
reason: z.string().min(1).max(500),
|
||||
items: z
|
||||
.array(
|
||||
z.object({
|
||||
orderItemId: z.string().uuid(),
|
||||
returnedQuantity: z.number().int().min(1),
|
||||
}),
|
||||
)
|
||||
.min(1),
|
||||
}),
|
||||
request.body ?? {},
|
||||
);
|
||||
if (!order.rows[0]) throw new AppError(404, 'NOT_FOUND', 'Order not found');
|
||||
if ((body.refundAmountCents ?? 0) > (order.rows[0].total_cents ?? 0))
|
||||
throw new AppError(400, 'REFUND_EXCEEDS', 'Refund exceeds amount');
|
||||
await pool.query(
|
||||
`INSERT INTO payments_transactions (provider, provider_event_id, provider_payment_id, order_id, amount_cents, currency, status, raw) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||
[
|
||||
'pos_refund',
|
||||
`ref-${id}`,
|
||||
`ref-${Date.now()}`,
|
||||
id,
|
||||
body.refundAmountCents,
|
||||
'EUR',
|
||||
'COMPLETED',
|
||||
JSON.stringify({ reason: body.reason, by: user.id }),
|
||||
],
|
||||
);
|
||||
await pool.query(
|
||||
`INSERT INTO orders_order_events (order_id, event, actor_id, metadata) VALUES ($1, 'REFUNDED', $2, $3)`,
|
||||
[id, user.id, JSON.stringify({ amt: body.refundAmountCents, reason: body.reason })],
|
||||
);
|
||||
return reply.send({ ok: true, refundedCents: body.refundAmountCents });
|
||||
const boundTerminalId = request.headers['x-terminal-id'];
|
||||
if (typeof boundTerminalId !== 'string' || boundTerminalId !== body.terminalId) {
|
||||
throw new AppError(
|
||||
403,
|
||||
'POS_TERMINAL_MISMATCH',
|
||||
'La devolución no pertenece al terminal vinculado',
|
||||
);
|
||||
}
|
||||
const result = await applyPosReturn.execute({
|
||||
orderId: id,
|
||||
userId: user.id,
|
||||
...body,
|
||||
lines: body.items,
|
||||
});
|
||||
return reply.code(201).send(result);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1630,6 +1658,57 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
|
||||
},
|
||||
);
|
||||
|
||||
app.get<{ Params: { id: string } }>(
|
||||
'/pos/sales/:id/items',
|
||||
{
|
||||
schema: {
|
||||
tags: ['POS Terminal'],
|
||||
summary: 'List order items of a POS sale (for returns)',
|
||||
params: { type: 'object', properties: { id: { type: 'string', format: 'uuid' } } },
|
||||
response: { 401: errorSchema, 404: errorSchema },
|
||||
} as FastifySchema,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const user = await authenticate(request);
|
||||
requireAnyRole(user, ['admin', 'pos_manager', 'pos_cashier'] as ReadonlyArray<Role>);
|
||||
const { id } = parseJson(idParamSchema, request.params);
|
||||
const order = await pool.query<{ id: string; source: string }>(
|
||||
`SELECT id, source FROM orders_orders WHERE id = $1`,
|
||||
[id],
|
||||
);
|
||||
if (!order.rows[0] || order.rows[0].source !== 'pos') {
|
||||
throw new AppError(404, 'POS_SALE_NOT_FOUND', 'Venta no encontrada');
|
||||
}
|
||||
const itemRows = await pool.query<{
|
||||
id: string;
|
||||
name: string;
|
||||
sku: string;
|
||||
quantity: number;
|
||||
returned_quantity: number;
|
||||
is_free_item: boolean;
|
||||
unit_price_cents: number;
|
||||
discount_cents: number;
|
||||
}>(
|
||||
`SELECT id, name, sku, quantity, returned_quantity, is_free_item,
|
||||
unit_price_cents, discount_cents
|
||||
FROM orders_items WHERE order_id = $1 ORDER BY created_at, id`,
|
||||
[id],
|
||||
);
|
||||
return reply.send({
|
||||
items: itemRows.rows.map((row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
sku: row.sku,
|
||||
quantity: Number(row.quantity),
|
||||
returnedQuantity: Number(row.returned_quantity),
|
||||
freeItem: row.is_free_item,
|
||||
unitPriceCents: Number(row.unit_price_cents),
|
||||
discountCents: Number(row.discount_cents),
|
||||
})),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
app.post<{ Params: { id: string } }>(
|
||||
'/pos/sales/:id/receipt/email',
|
||||
{
|
||||
|
||||
368
project/src/modules/pos/application/apply-pos-return.ts
Normal file
368
project/src/modules/pos/application/apply-pos-return.ts
Normal file
@@ -0,0 +1,368 @@
|
||||
import type pg from 'pg';
|
||||
import { AppError } from '../../../shared/errors.js';
|
||||
import type { PosReturnInput, PosReturnResult } from '../domain/pos-sale.js';
|
||||
import { buildPosReturnReceipt } from './build-pos-receipt.js';
|
||||
|
||||
interface OrderRow {
|
||||
id: string;
|
||||
source: string;
|
||||
state: string;
|
||||
terminal_id: string;
|
||||
cash_session_id: string;
|
||||
store_id: string;
|
||||
total_cents: string;
|
||||
}
|
||||
|
||||
interface OrderItemRow {
|
||||
id: string;
|
||||
quantity: number;
|
||||
returned_quantity: number;
|
||||
is_free_item: boolean;
|
||||
variant_id: string | null;
|
||||
unit_price_cents: number;
|
||||
discount_cents: number;
|
||||
tax_cents: number;
|
||||
}
|
||||
|
||||
interface SessionRow {
|
||||
id: string;
|
||||
terminal_id: string;
|
||||
store_id: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface ReplayReturnRow {
|
||||
id: string;
|
||||
total_cents: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a partial or full return to a POS sale.
|
||||
*
|
||||
* - Locks the order and stock rows.
|
||||
* - Restores inventory for returned stock lines.
|
||||
* - Updates per-line `returned_quantity` counters.
|
||||
* - Emits a single `reporting_payment_lines` row (`status='refund'` or `'partial_refund'`).
|
||||
* - Adjusts `pos_cash_sessions.expected_cash_cents` only when cash is refunded.
|
||||
* - Transitions the order to `PARTIALLY_REFUNDED` or `REFUNDED`.
|
||||
* - Records audit + history events. Returns the return receipt payload.
|
||||
*/
|
||||
export class ApplyPosReturnUseCase {
|
||||
constructor(private readonly pool: pg.Pool) {}
|
||||
|
||||
async execute(input: PosReturnInput): Promise<PosReturnResult> {
|
||||
if (input.lines.length === 0) {
|
||||
throw new AppError(400, 'POS_RETURN_EMPTY', 'Selecciona al menos una línea a devolver');
|
||||
}
|
||||
for (const line of input.lines) {
|
||||
if (!Number.isInteger(line.returnedQuantity) || line.returnedQuantity <= 0) {
|
||||
throw new AppError(
|
||||
400,
|
||||
'POS_RETURN_INVALID_QUANTITY',
|
||||
'La cantidad devuelta debe ser un entero positivo',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const client = await this.pool.connect();
|
||||
try {
|
||||
// Idempotent replay check uses a small dedicated transaction so the
|
||||
// read does not stay open while we do the (possibly heavyweight)
|
||||
// mutation work.
|
||||
const replay = await client.query<ReplayReturnRow>(
|
||||
`SELECT id, total_cents, status
|
||||
FROM pos_returns
|
||||
WHERE order_id = $1 AND idempotency_key = $2`,
|
||||
[input.orderId, input.idempotencyKey],
|
||||
);
|
||||
if (replay.rows[0]) {
|
||||
const previous = replay.rows[0];
|
||||
const receipt = await buildPosReturnReceipt(
|
||||
client,
|
||||
input.orderId,
|
||||
Number(previous.total_cents),
|
||||
);
|
||||
return {
|
||||
orderId: input.orderId,
|
||||
returnId: previous.id,
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
state: previous.status === 'refund' ? 'REFUNDED' : 'PARTIALLY_REFUNDED',
|
||||
refundedCents: Number(previous.total_cents),
|
||||
receipt,
|
||||
};
|
||||
}
|
||||
|
||||
await client.query('BEGIN');
|
||||
|
||||
const orderRow = await client.query<OrderRow>(
|
||||
`SELECT id, source, state, terminal_id, cash_session_id, store_id, total_cents
|
||||
FROM orders_orders WHERE id = $1 FOR UPDATE`,
|
||||
[input.orderId],
|
||||
);
|
||||
const order = orderRow.rows[0];
|
||||
if (!order || order.source !== 'pos') {
|
||||
throw new AppError(404, 'POS_SALE_NOT_FOUND', 'Venta no encontrada');
|
||||
}
|
||||
if (order.state !== 'COMPLETED' && order.state !== 'PARTIALLY_REFUNDED') {
|
||||
throw new AppError(
|
||||
409,
|
||||
'POS_RETURN_NOT_ALLOWED',
|
||||
'La venta no admite devoluciones en su estado actual',
|
||||
);
|
||||
}
|
||||
if (order.terminal_id !== input.terminalId || order.cash_session_id !== input.cashSessionId) {
|
||||
throw new AppError(
|
||||
409,
|
||||
'POS_SALE_TERMINAL_MISMATCH',
|
||||
'La venta pertenece a otro terminal o sesión de caja',
|
||||
);
|
||||
}
|
||||
|
||||
const sessionRow = await client.query<SessionRow>(
|
||||
`SELECT id, terminal_id, store_id, status
|
||||
FROM pos_cash_sessions WHERE id = $1 FOR UPDATE`,
|
||||
[input.cashSessionId],
|
||||
);
|
||||
const session = sessionRow.rows[0];
|
||||
if (!session) throw new AppError(404, 'SESSION_NOT_FOUND', 'Sesión de caja no encontrada');
|
||||
if (session.status !== 'OPEN') {
|
||||
throw new AppError(409, 'SESSION_CLOSED', 'La sesión de caja está cerrada');
|
||||
}
|
||||
|
||||
const itemRows = await client.query<OrderItemRow>(
|
||||
`SELECT id, quantity, returned_quantity, is_free_item, variant_id,
|
||||
unit_price_cents, discount_cents, tax_cents
|
||||
FROM orders_items
|
||||
WHERE order_id = $1
|
||||
FOR UPDATE`,
|
||||
[input.orderId],
|
||||
);
|
||||
const itemsById = new Map(itemRows.rows.map((row) => [row.id, row]));
|
||||
let totalRefundCents = 0;
|
||||
const stockUpdates: Array<{
|
||||
variantId: string;
|
||||
storeId: string;
|
||||
quantity: number;
|
||||
}> = [];
|
||||
for (const line of input.lines) {
|
||||
const item = itemsById.get(line.orderItemId);
|
||||
if (!item) {
|
||||
throw new AppError(404, 'POS_RETURN_ITEM_NOT_FOUND', 'Línea de venta no encontrada');
|
||||
}
|
||||
const remaining = item.quantity - item.returned_quantity;
|
||||
if (remaining <= 0) {
|
||||
throw new AppError(
|
||||
409,
|
||||
'POS_RETURN_LINE_FULLY_RETURNED',
|
||||
'La línea ya está devuelta por completo',
|
||||
);
|
||||
}
|
||||
if (item.is_free_item) {
|
||||
if (line.returnedQuantity !== item.quantity - item.returned_quantity) {
|
||||
throw new AppError(
|
||||
400,
|
||||
'POS_RETURN_FREE_ITEM_PARTIAL',
|
||||
'Los artículos libres solo pueden devolverse en su totalidad',
|
||||
);
|
||||
}
|
||||
} else if (line.returnedQuantity > remaining) {
|
||||
throw new AppError(
|
||||
409,
|
||||
'POS_RETURN_EXCEEDS_AVAILABLE',
|
||||
`Solo quedan ${remaining} unidades de la línea ${item.id}`,
|
||||
);
|
||||
}
|
||||
totalRefundCents +=
|
||||
(item.unit_price_cents - item.discount_cents) * line.returnedQuantity;
|
||||
if (!item.is_free_item && item.variant_id) {
|
||||
stockUpdates.push({
|
||||
variantId: item.variant_id,
|
||||
storeId: order.store_id,
|
||||
quantity: line.returnedQuantity,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Lock every affected stock row.
|
||||
for (const stock of stockUpdates) {
|
||||
await client.query(
|
||||
`SELECT available FROM inventory_stock
|
||||
WHERE variant_id = $1 AND store_id = $2 FOR UPDATE`,
|
||||
[stock.variantId, stock.storeId],
|
||||
);
|
||||
}
|
||||
|
||||
for (const stock of stockUpdates) {
|
||||
await client.query(
|
||||
`UPDATE inventory_stock
|
||||
SET available = available + $3,
|
||||
sold = GREATEST(0, sold - $3),
|
||||
updated_at = now()
|
||||
WHERE variant_id = $1 AND store_id = $2`,
|
||||
[stock.variantId, stock.storeId, stock.quantity],
|
||||
);
|
||||
await client.query(
|
||||
`INSERT INTO inventory_movements (variant_id, store_id, operation, quantity)
|
||||
VALUES ($1, $2, 'return', $3)`,
|
||||
[stock.variantId, stock.storeId, stock.quantity],
|
||||
);
|
||||
}
|
||||
|
||||
for (const line of input.lines) {
|
||||
await client.query(
|
||||
`UPDATE orders_items
|
||||
SET returned_quantity = returned_quantity + $2
|
||||
WHERE id = $1`,
|
||||
[line.orderItemId, line.returnedQuantity],
|
||||
);
|
||||
}
|
||||
|
||||
// Decide new state. A line is considered fully returned when
|
||||
// `returned_quantity == quantity`; free items are treated the same way.
|
||||
const updatedItemsResult = await client.query<{
|
||||
quantity: number;
|
||||
returned_quantity: number;
|
||||
is_free_item: boolean;
|
||||
}>(
|
||||
`SELECT quantity, returned_quantity, is_free_item
|
||||
FROM orders_items WHERE order_id = $1`,
|
||||
[input.orderId],
|
||||
);
|
||||
let allFullyReturned = true;
|
||||
let anyReturn = false;
|
||||
for (const row of updatedItemsResult.rows) {
|
||||
if (row.returned_quantity === 0) {
|
||||
allFullyReturned = false;
|
||||
} else {
|
||||
anyReturn = true;
|
||||
if (row.returned_quantity < row.quantity) {
|
||||
allFullyReturned = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!anyReturn) {
|
||||
// Nothing changed (defensive).
|
||||
allFullyReturned = false;
|
||||
}
|
||||
const reportingStatus: 'refund' | 'partial_refund' = allFullyReturned
|
||||
? 'refund'
|
||||
: 'partial_refund';
|
||||
const newOrderState: 'REFUNDED' | 'PARTIALLY_REFUNDED' = allFullyReturned
|
||||
? 'REFUNDED'
|
||||
: 'PARTIALLY_REFUNDED';
|
||||
|
||||
// Cash portion of the refund reduces expected_cash_cents proportional to
|
||||
// the share of the original cash payment.
|
||||
const cashResult = await client.query<{ sum: string | null }>(
|
||||
`SELECT COALESCE(SUM(amount_cents), 0)::text AS sum FROM payments_transactions
|
||||
WHERE order_id = $1 AND status = 'succeeded' AND provider = 'pos_cash'`,
|
||||
[input.orderId],
|
||||
);
|
||||
const cashPaidCents = Number(cashResult.rows[0]?.sum ?? '0');
|
||||
const totalCents = Number(order.total_cents);
|
||||
const cashRefundPortion =
|
||||
totalCents > 0
|
||||
? Math.min(
|
||||
Math.round((cashPaidCents / totalCents) * totalRefundCents),
|
||||
cashPaidCents,
|
||||
)
|
||||
: 0;
|
||||
if (cashRefundPortion > 0) {
|
||||
await client.query(
|
||||
`UPDATE pos_cash_sessions
|
||||
SET expected_cash_cents = GREATEST(0, COALESCE(expected_cash_cents, opening_cash_cents) - $1),
|
||||
updated_at = now()
|
||||
WHERE id = $2`,
|
||||
[cashRefundPortion, input.cashSessionId],
|
||||
);
|
||||
}
|
||||
|
||||
const insertedReturn = await client.query<{ id: string }>(
|
||||
`INSERT INTO pos_returns (
|
||||
order_id, idempotency_key, terminal_id, cash_session_id, store_id, user_id,
|
||||
total_cents, status, reason
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING id`,
|
||||
[
|
||||
input.orderId,
|
||||
input.idempotencyKey,
|
||||
input.terminalId,
|
||||
input.cashSessionId,
|
||||
order.store_id,
|
||||
input.userId,
|
||||
totalRefundCents,
|
||||
reportingStatus,
|
||||
input.reason,
|
||||
],
|
||||
);
|
||||
const returnId = insertedReturn.rows[0]?.id;
|
||||
if (!returnId) throw new AppError(500, 'POS_RETURN_INSERT_FAILED', 'No se pudo registrar la devolución');
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO reporting_payment_lines (
|
||||
order_id, store_id, terminal_id, cash_session_id, payment_method_id,
|
||||
provider, amount_cents, currency, status, provider_ref
|
||||
) VALUES ($1, $2, $3, $4, NULL, 'pos_return', $5, 'EUR', $6, $7)`,
|
||||
[
|
||||
input.orderId,
|
||||
order.store_id,
|
||||
input.terminalId,
|
||||
input.cashSessionId,
|
||||
totalRefundCents,
|
||||
reportingStatus,
|
||||
returnId,
|
||||
],
|
||||
);
|
||||
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))`,
|
||||
[
|
||||
input.orderId,
|
||||
JSON.stringify({
|
||||
returnId,
|
||||
refundedCents: totalRefundCents,
|
||||
status: reportingStatus,
|
||||
lines: input.lines,
|
||||
reason: input.reason,
|
||||
}),
|
||||
input.userId,
|
||||
],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`UPDATE orders_orders SET state = $2, state_changed_at = now(), updated_at = now()
|
||||
WHERE id = $1`,
|
||||
[input.orderId, newOrderState],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO security_audit_log (actor_id, action, target, metadata)
|
||||
VALUES ($1, $2, $3, jsonb_build_object('refundedCents', $4::int, 'returnId', $5::text))`,
|
||||
[
|
||||
input.userId,
|
||||
newOrderState === 'REFUNDED' ? 'pos.sale.returned' : 'pos.sale.partial_returned',
|
||||
input.orderId,
|
||||
totalRefundCents,
|
||||
returnId,
|
||||
],
|
||||
);
|
||||
|
||||
const receipt = await buildPosReturnReceipt(client, input.orderId, totalRefundCents);
|
||||
await client.query('COMMIT');
|
||||
return {
|
||||
orderId: input.orderId,
|
||||
returnId,
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
state: newOrderState,
|
||||
refundedCents: totalRefundCents,
|
||||
receipt,
|
||||
};
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,18 @@ import { AppError } from '../../../shared/errors.js';
|
||||
|
||||
type Queryable = Pick<pg.Pool, 'query'> | Pick<pg.PoolClient, 'query'>;
|
||||
|
||||
interface ReturnedItemRow {
|
||||
id: string;
|
||||
quantity: number;
|
||||
returned_quantity: number;
|
||||
unit_price_cents: number;
|
||||
discount_cents: number;
|
||||
tax_cents: number;
|
||||
name: string;
|
||||
sku: string;
|
||||
is_free_item: boolean;
|
||||
}
|
||||
|
||||
interface ReceiptOrderRow {
|
||||
id: string;
|
||||
receipt_number: string | null;
|
||||
@@ -165,3 +177,75 @@ function integerOrNull(value: unknown): number | null {
|
||||
function integerOrZero(value: unknown): number {
|
||||
return typeof value === 'number' && Number.isInteger(value) ? value : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a return/reversal receipt based on the original POS order.
|
||||
*
|
||||
* The returned payload uses negative line totals and prices `R-<original>` as
|
||||
* its receipt number without persisting a separate sequence. The `paidCents`
|
||||
* block is replaced with a `refundedCents` flag and the receipt is flagged
|
||||
* `isReturn=true` so renderers can style it accordingly.
|
||||
*/
|
||||
export async function buildPosReturnReceipt(
|
||||
queryable: Queryable,
|
||||
orderId: string,
|
||||
refundedCents: number,
|
||||
): Promise<PosReceipt> {
|
||||
const original = await buildPosReceipt(queryable, orderId);
|
||||
const itemResult = await queryable.query<ReturnedItemRow>(
|
||||
`SELECT id, quantity, returned_quantity, unit_price_cents, discount_cents, tax_cents,
|
||||
name, sku, is_free_item
|
||||
FROM orders_items WHERE order_id = $1 ORDER BY created_at, id`,
|
||||
[orderId],
|
||||
);
|
||||
const items = itemResult.rows
|
||||
.filter((row) => row.returned_quantity > 0)
|
||||
.map((row) => {
|
||||
const returnedQuantity = Number(row.returned_quantity);
|
||||
const unitPrice = Number(row.unit_price_cents);
|
||||
const discount = Number(row.discount_cents);
|
||||
const tax = Number(row.tax_cents);
|
||||
const subtotal = unitPrice * returnedQuantity;
|
||||
const discountCents = discount * returnedQuantity;
|
||||
const taxCents = tax * returnedQuantity;
|
||||
const total = subtotal - discountCents + taxCents;
|
||||
return {
|
||||
name: row.name,
|
||||
sku: row.sku,
|
||||
quantity: returnedQuantity,
|
||||
unitPriceCents: unitPrice,
|
||||
subtotalCents: subtotal,
|
||||
discountCents,
|
||||
taxCents,
|
||||
totalCents: -total,
|
||||
freeItem: row.is_free_item,
|
||||
};
|
||||
});
|
||||
const subtotal = items.reduce((sum, item) => sum + item.subtotalCents, 0);
|
||||
const discount = items.reduce((sum, item) => sum + item.discountCents, 0);
|
||||
const tax = items.reduce((sum, item) => sum + item.taxCents, 0);
|
||||
return {
|
||||
receiptNumber: original.receiptNumber.startsWith('R-')
|
||||
? original.receiptNumber
|
||||
: `R-${original.receiptNumber}`,
|
||||
orderId: original.orderId,
|
||||
issuedAt: original.issuedAt,
|
||||
company: original.company,
|
||||
terminal: original.terminal,
|
||||
cashier: original.cashier,
|
||||
sessionId: original.sessionId,
|
||||
customerEmail: original.customerEmail,
|
||||
items,
|
||||
subtotalCents: -subtotal,
|
||||
discountCents: -discount,
|
||||
taxCents: -tax,
|
||||
totalCents: -refundedCents,
|
||||
payments: [],
|
||||
changeCents: 0,
|
||||
header: original.header,
|
||||
returnPolicy: original.returnPolicy,
|
||||
footer: original.footer,
|
||||
originalReceiptNumber: original.receiptNumber,
|
||||
isReturn: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -105,6 +105,10 @@ export interface PosReceipt {
|
||||
header: string | null;
|
||||
returnPolicy: string;
|
||||
footer: string | null;
|
||||
/** Present on return receipts. References the original ticket number. */
|
||||
originalReceiptNumber?: string;
|
||||
/** True for return/reversal receipts that should print negative totals. */
|
||||
isReturn?: boolean;
|
||||
}
|
||||
|
||||
export interface PosSaleResult {
|
||||
@@ -125,3 +129,27 @@ export interface PosSaleResult {
|
||||
export interface PosPaymentResult extends PosReceiptPayment {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface PosReturnLineInput {
|
||||
orderItemId: string;
|
||||
returnedQuantity: number;
|
||||
}
|
||||
|
||||
export interface PosReturnInput {
|
||||
orderId: string;
|
||||
idempotencyKey: string;
|
||||
cashSessionId: string;
|
||||
terminalId: string;
|
||||
userId: string;
|
||||
reason: string;
|
||||
lines: PosReturnLineInput[];
|
||||
}
|
||||
|
||||
export interface PosReturnResult {
|
||||
orderId: string;
|
||||
returnId: string;
|
||||
idempotencyKey: string;
|
||||
state: 'PARTIALLY_REFUNDED' | 'REFUNDED';
|
||||
refundedCents: number;
|
||||
receipt: PosReceipt;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@ export {
|
||||
type ValidatedPayment,
|
||||
} from './application/create-pos-sale.js';
|
||||
export { ReceiveRestPaymentUseCase } from './application/receive-rest-payment.js';
|
||||
export { buildPosReceipt } from './application/build-pos-receipt.js';
|
||||
export { ApplyPosReturnUseCase } from './application/apply-pos-return.js';
|
||||
export { buildPosReceipt, buildPosReturnReceipt } from './application/build-pos-receipt.js';
|
||||
export type {
|
||||
PosSaleInput,
|
||||
PosSaleResult,
|
||||
@@ -15,5 +16,8 @@ export type {
|
||||
PosSaleLineItem,
|
||||
PosPaymentInput,
|
||||
PosPaymentResult,
|
||||
PosReturnInput,
|
||||
PosReturnResult,
|
||||
PosReturnLineInput,
|
||||
PosReceipt,
|
||||
} from './domain/pos-sale.js';
|
||||
|
||||
Reference in New Issue
Block a user