feat(F-186): completed feature

This commit is contained in:
chattie
2026-08-22 22:08:09 +02:00
parent 63a305bdd4
commit a3f6edd325
30 changed files with 3603 additions and 624 deletions

View File

@@ -0,0 +1,167 @@
import type pg from 'pg';
import type { PosPaymentKind, PosReceipt, PosReceiptPayment } from '../domain/pos-sale.js';
import { AppError } from '../../../shared/errors.js';
type Queryable = Pick<pg.Pool, 'query'> | Pick<pg.PoolClient, 'query'>;
interface ReceiptOrderRow {
id: string;
receipt_number: string | null;
subtotal_cents: number;
discount_cents: number;
tax_cents: number;
total_cents: number;
created_at: Date;
cash_session_id: string;
terminal_id: string;
customer_email: string | null;
store_name: string;
address: string | null;
tax_id: string | null;
contact_email: string | null;
contact_phone: string | null;
receipt_header: string | null;
receipt_footer: string | null;
terminal_name: string;
cashier_email: string;
return_policy: string | null;
}
interface ReceiptItemRow {
name: string;
sku: string;
quantity: number;
unit_price_cents: number;
discount_cents: number;
tax_cents: number;
is_free_item: boolean;
}
interface ReceiptPaymentRow {
amount_cents: number;
provider: string;
raw: Record<string, unknown> | null;
}
export async function buildPosReceipt(queryable: Queryable, orderId: string): Promise<PosReceipt> {
const orderResult = await queryable.query<ReceiptOrderRow>(
`SELECT o.id, o.receipt_number, o.subtotal_cents, o.discount_cents, o.tax_cents,
o.total_cents, o.created_at, o.cash_session_id, o.terminal_id,
customer.email AS customer_email,
store.name AS store_name, store.address, store.tax_id, store.contact_email,
store.contact_phone, store.receipt_header, store.receipt_footer,
terminal.name AS terminal_name, cashier.email AS cashier_email,
receipt_settings.return_policy
FROM orders_orders o
JOIN pos_stores store ON store.id = o.store_id
JOIN pos_terminals terminal ON terminal.id = o.terminal_id
JOIN pos_cash_sessions session ON session.id = o.cash_session_id
JOIN backoffice_users cashier ON cashier.id = session.user_id
LEFT JOIN identity_users customer ON customer.id = o.user_id
LEFT JOIN pos_receipt_settings receipt_settings ON receipt_settings.store_id = o.store_id
WHERE o.id = $1 AND o.source = 'pos'`,
[orderId],
);
const order = orderResult.rows[0];
if (!order) throw new AppError(404, 'POS_RECEIPT_NOT_FOUND', 'No se encontró el ticket');
const [itemResult, paymentResult] = await Promise.all([
queryable.query<ReceiptItemRow>(
`SELECT name, sku, quantity, unit_price_cents, discount_cents, tax_cents, is_free_item
FROM orders_items WHERE order_id = $1 ORDER BY created_at, id`,
[orderId],
),
queryable.query<ReceiptPaymentRow>(
`SELECT amount_cents, provider, raw
FROM payments_transactions WHERE order_id = $1 AND status = 'succeeded'
ORDER BY created_at, id`,
[orderId],
),
]);
const payments: PosReceiptPayment[] = paymentResult.rows.map((row) => {
const raw = row.raw ?? {};
const kind = isPaymentKind(raw.kind) ? raw.kind : providerKind(row.provider);
const tendered = integerOrNull(raw.tenderedCents);
const change = integerOrZero(raw.changeCents);
return {
methodCode: stringOr(raw.methodCode, row.provider.replace(/^pos_/, '')),
methodLabel: stringOr(raw.methodLabel, paymentKindLabel(kind)),
kind,
amountCents: Number(row.amount_cents),
tenderedCents: tendered,
changeCents: change,
};
});
return {
receiptNumber: order.receipt_number ?? `POS-${order.id.slice(0, 8).toUpperCase()}`,
orderId: order.id,
issuedAt: order.created_at,
company: {
name: order.store_name,
address: order.address,
taxId: order.tax_id,
email: order.contact_email,
phone: order.contact_phone,
},
terminal: { id: order.terminal_id, name: order.terminal_name },
cashier: order.cashier_email,
sessionId: order.cash_session_id,
customerEmail: order.customer_email,
items: itemResult.rows.map((item) => {
const subtotalCents = Number(item.unit_price_cents) * Number(item.quantity);
const discountCents = Number(item.discount_cents) * Number(item.quantity);
const taxCents = Number(item.tax_cents) * Number(item.quantity);
return {
name: item.name,
sku: item.sku,
quantity: Number(item.quantity),
unitPriceCents: Number(item.unit_price_cents),
subtotalCents,
discountCents,
taxCents,
totalCents: subtotalCents - discountCents + taxCents,
freeItem: item.is_free_item,
};
}),
subtotalCents: Number(order.subtotal_cents),
discountCents: Number(order.discount_cents),
taxCents: Number(order.tax_cents),
totalCents: Number(order.total_cents),
payments,
changeCents: payments.reduce((sum, payment) => sum + payment.changeCents, 0),
header: order.receipt_header,
returnPolicy:
order.return_policy ?? 'Se admiten devoluciones durante 15 días con el ticket de compra.',
footer: order.receipt_footer,
};
}
function isPaymentKind(value: unknown): value is PosPaymentKind {
return value === 'cash' || value === 'card' || value === 'other';
}
function providerKind(provider: string): PosPaymentKind {
if (provider === 'pos_cash') return 'cash';
if (provider === 'pos_card') return 'card';
return 'other';
}
function paymentKindLabel(kind: PosPaymentKind): string {
if (kind === 'cash') return 'Efectivo';
if (kind === 'card') return 'Tarjeta';
return 'Otro';
}
function stringOr(value: unknown, fallback: string): string {
return typeof value === 'string' && value.trim() ? value : fallback;
}
function integerOrNull(value: unknown): number | null {
return typeof value === 'number' && Number.isInteger(value) ? value : null;
}
function integerOrZero(value: unknown): number {
return typeof value === 'number' && Number.isInteger(value) ? value : 0;
}