feat(F-189): completed feature
This commit is contained in:
@@ -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',
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user