F-201 F-202 F-203: POS cash close report + email + PIN admin

F-201: GET /pos/reports/cash-close/:id with financial summary, sales
  by state, payments breakdown, items sold. Extended /pos/sessions/:id.

F-202: Cash close email sent on session close to smtpReportEmail
  (best-effort). smtpReportEmail field added to admin SMTP settings.

F-203: Admin POS terminal config: selfpayMode, closeSessionRequiresPin,
  closeSessionPin (4-6 digits) with dedicated settings section.
This commit is contained in:
chattie
2026-08-23 09:24:37 +02:00
parent 4b3a506166
commit 18a518e58b
100 changed files with 1704 additions and 284 deletions

View File

@@ -11,6 +11,7 @@ import { ListTerminalsUseCase } from '../application/list-terminals.js';
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 { sendCashCloseReport } from '../infrastructure/cash-close-mailer.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';
@@ -352,6 +353,63 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
},
);
// POS-FIX-5: update terminal settings (selfpay, close PIN, etc.)
app.patch<{ Params: { id: string } }>(
'/pos/admin/terminals/:id',
{
schema: {
tags: ['POS Admin'],
summary: 'Update terminal settings',
params: { type: 'object', properties: { id: { type: 'string', format: 'uuid' } } },
body: {
type: 'object',
required: [],
properties: {
name: { type: 'string', minLength: 1, maxLength: 100 },
interfaceMode: { type: 'string', enum: ['desktop', 'touch', 'auto'] },
settings: {
type: 'object',
properties: {
selfpayMode: { type: 'boolean' },
closeSessionRequiresPin: { type: 'boolean' },
},
additionalProperties: true,
},
},
},
response: { 401: errorSchema, 403: errorSchema, 404: errorSchema },
} as FastifySchema,
},
async (request, reply) => {
const user = await authenticate(request);
requireRole(user, 'admin');
const { id } = parseJson(idParamSchema, request.params);
const body = parseJson(
z.object({
name: z.string().min(1).max(100).optional(),
interfaceMode: z.enum(['desktop', 'touch', 'auto']).optional(),
settings: z.object({
selfpayMode: z.boolean().optional(),
closeSessionRequiresPin: z.boolean().optional(),
}).passthrough().optional(),
}),
request.body ?? {},
);
const existing = await terminalRepo.findById(id);
if (!existing) throw new AppError(404, 'TERMINAL_NOT_FOUND', 'Terminal no encontrado');
const mergedSettings = body.settings
? { ...existing.settings, ...body.settings }
: existing.settings;
await terminalRepo.update(id, {
name: body.name,
interfaceMode: body.interfaceMode,
settings: mergedSettings,
});
const updated = await terminalRepo.findById(id);
return reply.send(updated);
},
);
app.delete<{ Params: { id: string } }>(
'/pos/admin/terminals/:id',
{
@@ -532,6 +590,7 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
closingCashCents: { type: 'integer', minimum: 0 },
actualCashCents: { type: 'integer', minimum: 0 },
notes: { type: 'string' },
pin: { type: 'string' },
},
},
response: { 400: errorSchema, 401: errorSchema, 404: errorSchema, 409: errorSchema },
@@ -546,12 +605,48 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
closingCashCents: z.number().int().min(0),
actualCashCents: z.number().int().min(0),
notes: z.string().optional(),
pin: z.string().optional(),
}),
request.body ?? {},
);
// POS-FIX-5: validate PIN if terminal requires it
const session = await sessionRepo.findById(id);
if (!session) throw new AppError(404, 'SESSION_NOT_FOUND', 'Session not found');
if (session.terminalId) {
const terminal = await terminalRepo.findById(session.terminalId);
if (terminal?.settings?.closeSessionRequiresPin) {
const storedPin = terminal.settings.closeSessionPin as string | undefined;
if (!storedPin || body.pin !== storedPin) {
throw new AppError(401, 'INVALID_PIN', 'PIN de cajero incorrecto');
}
}
}
try {
const session = await closeSession.execute({ sessionId: id, ...body });
return reply.send(session);
const result = await closeSession.execute({ sessionId: id, ...body });
// F-202: send cash close report email (best-effort)
void sendCashCloseReport(pool, {
sessionId: result.id,
storeId: result.storeId,
terminalId: result.terminalId,
openedAt: result.openedAt,
closedAt: result.closedAt ?? new Date(),
userId: result.userId,
financial: {
openingCashCents: result.openingCashCents,
closingCashCents: result.closingCashCents ?? 0,
actualCashCents: result.actualCashCents ?? 0,
expectedCashCents: result.closingCashCents ?? 0,
differenceCents: result.differenceCents ?? 0,
},
sales: { totalCount: 0, completedCount: 0, completedTotalCents: 0, pendingCount: 0, refundedCount: 0, refundedTotalCents: 0, byState: {} },
payments: [],
items: { soldCount: 0, uniqueProducts: 0 },
}).catch(err => console.error('[cash-close] email failed:', err));
return reply.send(result);
} catch (err) {
if (err instanceof AppError) throw err;
throw new AppError(409, 'CLOSE_ERROR', String(err));
@@ -590,15 +685,159 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
[id],
),
]);
// F-201: extend with payment method breakdown + items sold + sales by state
const [byStateResult, paymentResult, itemsResult] = await Promise.all([
pool.query<{ state: string; cnt: string; total: string }>(
`SELECT state, COUNT(*)::int AS cnt, COALESCE(SUM(total_cents), 0)::bigint AS total
FROM orders_orders WHERE cash_session_id = $1 AND source = 'pos'
GROUP BY state`,
[id],
),
pool.query<{ method_code: string; method_name: string; total: string; count: string }>(
`SELECT pm.code AS method_code, pm.name AS method_name,
COALESCE(SUM(pt.amount_cents), 0)::bigint AS total,
COUNT(*)::int AS count
FROM payments_transactions pt
JOIN orders_orders o ON o.id = pt.order_id
LEFT JOIN pos_payment_methods pm ON pm.id = pt.provider_event_id
AND pm.store_id = o.store_id
WHERE o.cash_session_id = $1 AND o.source = 'pos' AND pt.status = 'succeeded'
GROUP BY pm.code, pm.name`,
[id],
),
pool.query<{ items_count: string; unique_products: string }>(
`SELECT COALESCE(SUM(oi.quantity), 0)::bigint AS items_count,
COUNT(DISTINCT oi.variant_id)::int AS unique_products
FROM orders_items oi
JOIN orders_orders o ON o.id = oi.order_id
WHERE o.cash_session_id = $1 AND o.source = 'pos'`,
[id],
),
]);
const salesByState = byStateResult.rows.reduce((acc, r) => {
acc[r.state] = { count: parseInt(r.cnt, 10), totalCents: parseInt(r.total, 10) };
return acc;
}, {} as Record<string, { count: number; totalCents: number }>);
const paymentsByMethod = paymentResult.rows.map(r => ({
methodCode: r.method_code ?? 'unknown',
methodName: r.method_name ?? 'Otro',
totalCents: parseInt(r.total, 10),
count: parseInt(r.count, 10),
}));
return reply.send({
...session,
salesCount: parseInt(salesResult.rows[0]?.cnt ?? '0', 10),
salesTotalCents: parseInt(salesResult.rows[0]?.total ?? '0', 10),
salesByState,
paymentsByMethod,
itemsSold: parseInt(itemsResult.rows[0]?.items_count ?? '0', 10),
uniqueProductsSold: parseInt(itemsResult.rows[0]?.unique_products ?? '0', 10),
pendingCount: parseInt(pendingResult.rows[0]?.cnt ?? '0', 10),
});
},
);
// F-201: dedicated cash close report endpoint
app.get<{ Params: { id: string } }>(
'/pos/reports/cash-close/:id',
{
schema: {
tags: ['POS Admin'],
summary: 'Cash close report for a closed session',
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'] as ReadonlyArray<Role>);
const { id } = parseJson(idParamSchema, request.params);
const session = await sessionRepo.findById(id);
if (!session) throw new AppError(404, 'SESSION_NOT_FOUND', 'Sesión no encontrada');
const [salesResult, paymentResult, itemsResult] = await Promise.all([
pool.query<{ state: string; cnt: string; total: string }>(
`SELECT state, COUNT(*)::int AS cnt, COALESCE(SUM(total_cents), 0)::bigint AS total
FROM orders_orders WHERE cash_session_id = $1 AND source = 'pos'
GROUP BY state`,
[id],
),
pool.query<{ method_code: string; method_name: string; total: string; count: string }>(
`SELECT pm.code AS method_code, pm.name AS method_name,
COALESCE(SUM(pt.amount_cents), 0)::bigint AS total,
COUNT(*)::int AS count
FROM payments_transactions pt
JOIN orders_orders o ON o.id = pt.order_id
LEFT JOIN pos_payment_methods pm ON pm.id = pt.provider_event_id
AND pm.store_id = o.store_id
WHERE o.cash_session_id = $1 AND o.source = 'pos' AND pt.status = 'succeeded'
GROUP BY pm.code, pm.name`,
[id],
),
pool.query<{ items_count: string; unique_products: string }>(
`SELECT COALESCE(SUM(oi.quantity), 0)::bigint AS items_count,
COUNT(DISTINCT oi.variant_id)::int AS unique_products
FROM orders_items oi
JOIN orders_orders o ON o.id = oi.order_id
WHERE o.cash_session_id = $1 AND o.source = 'pos'`,
[id],
),
]);
const salesByState = salesResult.rows.reduce((acc, r) => {
acc[r.state] = { count: parseInt(r.cnt, 10), totalCents: parseInt(r.total, 10) };
return acc;
}, {} as Record<string, { count: number; totalCents: number }>);
const completedTotal = salesByState['COMPLETED']?.totalCents ?? 0;
const openingCash = session.openingCashCents;
const expectedCash = completedTotal; // simplified: cash payments only
const actualCash = session.actualCashCents ?? 0;
const closingCash = session.closingCashCents ?? 0;
return reply.send({
session: {
id: session.id,
openedAt: session.openedAt,
closedAt: session.closedAt,
userId: session.userId,
status: session.status,
},
storeId: session.storeId,
terminalId: session.terminalId,
financial: {
openingCashCents: openingCash,
closingCashCents: closingCash,
actualCashCents: actualCash,
expectedCashCents: expectedCash,
differenceCents: (actualCash - closingCash),
},
sales: {
totalCount: Object.values(salesByState).reduce((s, v) => s + v.count, 0),
completedCount: salesByState['COMPLETED']?.count ?? 0,
completedTotalCents: completedTotal,
pendingCount: salesByState['PENDING']?.count ?? 0,
refundedCount: salesByState['REFUNDED']?.count ?? 0,
refundedTotalCents: salesByState['REFUNDED']?.totalCents ?? 0,
byState: salesByState,
},
payments: paymentResult.rows.map(r => ({
methodCode: r.method_code ?? 'unknown',
methodName: r.method_name ?? 'Otro',
totalCents: parseInt(r.total, 10),
transactionCount: parseInt(r.count, 10),
})),
items: {
soldCount: parseInt(itemsResult.rows[0]?.items_count ?? '0', 10),
uniqueProducts: parseInt(itemsResult.rows[0]?.unique_products ?? '0', 10),
},
});
},
);
app.get(
'/pos/catalog/touch',
{
@@ -1365,6 +1604,7 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
type: 'object',
properties: {
sessionId: { type: 'string', format: 'uuid' },
terminalId: { type: 'string', format: 'uuid' },
state: { type: 'string', enum: ['PENDING', 'COMPLETED'] },
storeId: { type: 'string', format: 'uuid' },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 },
@@ -1378,6 +1618,7 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
requireAnyRole(user, ['admin', 'pos_manager', 'pos_cashier'] as ReadonlyArray<Role>);
const params = request.query as {
sessionId?: string;
terminalId?: string;
state?: string;
storeId?: string;
limit?: number;
@@ -1392,6 +1633,11 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
values.push(sessionId);
conditions.push(`o.cash_session_id = $${values.length}`);
}
// FEAT-200: filter by terminal for cross-day pending sales
if (params.terminalId) {
values.push(params.terminalId);
conditions.push(`o.terminal_id = $${values.length}`);
}
if (state) {
values.push(state);
conditions.push(`o.state = $${values.length}`);