feat(F-188): completed feature

This commit is contained in:
chattie
2026-08-22 22:44:37 +02:00
parent c5e5b4c48c
commit 0e3c488c85
21 changed files with 1081 additions and 71 deletions

View File

@@ -12,6 +12,7 @@ import { GetPosConfigUseCase } from '../application/get-pos-config.js';
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 { buildPosReceipt } from '../application/build-pos-receipt.js';
import { sendTransactionalEmail } from '../../notifications/index.js';
import { Argon2PasswordHasher } from '../../identity/index.js';
@@ -62,6 +63,7 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
const paymentMethodRepo = new PgPaymentMethodRepository(pool);
const sessionRepo = new PgCashSessionRepository(pool);
const createPosSale = new CreatePosSaleUseCase(pool);
const receiveRestPayment = new ReceiveRestPaymentUseCase(pool);
const listStores = new ListStoresUseCase(storeRepo);
const listTerminals = new ListTerminalsUseCase(terminalRepo);
@@ -1321,6 +1323,8 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
type: 'object',
properties: {
sessionId: { type: 'string', format: 'uuid' },
state: { type: 'string', enum: ['PENDING', 'COMPLETED'] },
storeId: { type: 'string', format: 'uuid' },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 },
},
},
@@ -1330,25 +1334,129 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
async (request, reply) => {
const user = await authenticate(request);
requireAnyRole(user, ['admin', 'pos_manager', 'pos_cashier'] as ReadonlyArray<Role>);
const { sessionId, limit = 20 } = request.query as { sessionId?: string; limit?: number };
let query = `SELECT o.id, o.total_cents AS "totalCents", o.subtotal_cents AS "subtotalCents",
o.discount_cents AS "discountCents", o.created_at AS "createdAt",
u.email AS "userEmail"
FROM orders_orders o
LEFT JOIN identity_users u ON u.id = o.user_id
WHERE o.idempotency_key IS NOT NULL`;
const params: unknown[] = [];
const params = request.query as {
sessionId?: string;
state?: string;
storeId?: string;
limit?: number;
};
const sessionId = params.sessionId;
const state = params.state;
const storeId = params.storeId;
const limit = Math.min(Math.max(params.limit ?? 20, 1), 100);
const conditions: string[] = [`o.idempotency_key IS NOT NULL`];
const values: unknown[] = [];
if (sessionId) {
params.push(sessionId);
query += ` AND o.id IN (SELECT order_id FROM pos_cash_sessions WHERE id = $${params.length})`;
values.push(sessionId);
conditions.push(`o.cash_session_id = $${values.length}`);
}
params.push(limit);
query += ` ORDER BY o.created_at DESC LIMIT $${params.length}`;
const result = await pool.query(query, params);
if (state) {
values.push(state);
conditions.push(`o.state = $${values.length}`);
}
if (storeId) {
values.push(storeId);
conditions.push(`o.store_id = $${values.length}`);
}
values.push(limit);
const query = `
SELECT o.id, o.state,
o.total_cents AS "totalCents", o.subtotal_cents AS "subtotalCents",
o.discount_cents AS "discountCents", o.created_at AS "createdAt",
o.store_id AS "storeId", o.terminal_id AS "terminalId",
o.cash_session_id AS "cashSessionId",
COALESCE(payments.sum_paid, 0)::int AS "paidCents",
(o.total_cents - COALESCE(payments.sum_paid, 0))::int AS "outstandingCents",
u.email AS "userEmail"
FROM orders_orders o
LEFT JOIN identity_users u ON u.id = o.user_id
LEFT JOIN (
SELECT order_id, SUM(amount_cents) AS sum_paid
FROM payments_transactions
WHERE status = 'succeeded'
GROUP BY order_id
) payments ON payments.order_id = o.id
WHERE ${conditions.join(' AND ')}
ORDER BY o.created_at DESC
LIMIT $${values.length}
`;
const result = await pool.query(query, values);
return reply.send({ items: result.rows });
},
);
app.post<{ Params: { id: string } }>(
'/pos/sales/:id/payments',
{
schema: {
tags: ['POS Terminal'],
summary: 'Apply additional payments to a pending POS sale',
params: { type: 'object', properties: { id: { type: 'string', format: 'uuid' } } },
body: {
type: 'object',
required: ['idempotencyKey', 'cashSessionId', 'terminalId', 'payments'],
properties: {
idempotencyKey: { type: 'string', minLength: 1, maxLength: 128 },
cashSessionId: { type: 'string', format: 'uuid' },
terminalId: { type: 'string', format: 'uuid' },
payments: { type: 'array', minItems: 1, items: { type: 'object' } },
},
},
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 payment = z
.object({
methodCode: z
.string()
.regex(/^[a-z0-9_-]{1,32}$/)
.optional(),
kind: z.enum(['cash', 'card', 'other']).optional(),
amountCents: z.number().int().min(1),
tenderedCents: z.number().int().min(0).optional(),
last4: z
.string()
.regex(/^\d{1,4}$/)
.optional(),
})
.refine((value) => Boolean(value.methodCode || value.kind), {
message: 'methodCode is required',
});
const body = parseJson(
z.object({
idempotencyKey: z.string().min(1).max(128),
cashSessionId: z.string().uuid(),
terminalId: z.string().uuid(),
payments: z.array(payment).min(1),
}),
request.body ?? {},
);
const boundTerminalId = request.headers['x-terminal-id'];
if (typeof boundTerminalId !== 'string' || boundTerminalId !== body.terminalId) {
throw new AppError(
403,
'POS_TERMINAL_MISMATCH',
'La venta no pertenece al terminal vinculado',
);
}
const result = await receiveRestPayment.execute({
orderId: (request.params as { id: string }).id,
userId: user.id,
...body,
});
return reply.code(201).send(result);
},
);
app.post<{ Params: { id: string } }>(
'/pos/sales/:id/void',
{