import type pg from 'pg'; import type { PosPaymentKind, PosReceipt, PosReceiptPayment } from '../domain/pos-sale.js'; import { AppError } from '../../../shared/errors.js'; type Queryable = Pick | Pick; interface ReturnedItemRow { id: string; quantity: number; returned_quantity: number; unit_price_cents: number; discount_cents: number; tax_cents: number; vat_rate: 'general' | 'reduced' | 'super-reduced' | null; name: string; sku: string; is_free_item: boolean; } 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; logo_url: string | null; } interface ReceiptItemRow { name: string; sku: string; quantity: number; unit_price_cents: number; discount_cents: number; tax_cents: number; vat_rate: 'general' | 'reduced' | 'super-reduced' | null; is_free_item: boolean; } interface ReceiptPaymentRow { amount_cents: number; provider: string; raw: Record | null; } export async function buildPosReceipt(queryable: Queryable, orderId: string): Promise { const orderResult = await queryable.query( `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, store.logo_url, 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( `SELECT name, sku, quantity, unit_price_cents, discount_cents, tax_cents, vat_rate, is_free_item FROM orders_items WHERE order_id = $1 ORDER BY created_at, id`, [orderId], ), queryable.query( `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, }, logoUrl: order.logo_url, 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 quantity = Number(item.quantity); const unitNetCents = Number(item.unit_price_cents); const unitTaxCents = Number(item.tax_cents); const unitGrossCents = item.is_free_item ? unitNetCents : grossFromNet(unitNetCents, item.vat_rate); const totalCents = (unitNetCents - Number(item.discount_cents) + unitTaxCents) * quantity; const subtotalCents = unitGrossCents * quantity; const discountCents = Math.max(0, subtotalCents - totalCents); const taxCents = unitTaxCents * quantity; return { name: item.name, sku: item.sku, quantity, unitPriceCents: unitGrossCents, subtotalCents, discountCents, taxCents, totalCents, 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 grossFromNet( netUnitAmountCents: number, vatRate: 'general' | 'reduced' | 'super-reduced' | null, ): number { if (!vatRate) return netUnitAmountCents; const basisPoints = vatRate === 'general' ? 2100 : vatRate === 'reduced' ? 1000 : 400; return Math.round(netUnitAmountCents * (1 + basisPoints / 10_000)); } 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; } /** * Build a return/reversal receipt based on the original POS order. * * The returned payload uses negative line totals and prices `R-` 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 { const original = await buildPosReceipt(queryable, orderId); const itemResult = await queryable.query( `SELECT id, quantity, returned_quantity, unit_price_cents, discount_cents, tax_cents, vat_rate, 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 unitNetCents = Number(row.unit_price_cents); const unitTaxCents = Number(row.tax_cents); const unitGrossCents = row.is_free_item ? unitNetCents : grossFromNet(unitNetCents, row.vat_rate); const total = (unitNetCents - Number(row.discount_cents) + unitTaxCents) * returnedQuantity; const subtotal = unitGrossCents * returnedQuantity; const discountCents = Math.max(0, subtotal - total); const taxCents = unitTaxCents * returnedQuantity; return { name: row.name, sku: row.sku, quantity: returnedQuantity, unitPriceCents: unitGrossCents, 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, logoUrl: original.logoUrl, 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, }; }