Files
mercadodevida/project/src/modules/pos/application/build-pos-receipt.ts

275 lines
9.5 KiB
TypeScript

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 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<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, 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<ReceiptItemRow>(
`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<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,
},
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-<original>` 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<PosReceipt> {
const original = await buildPosReceipt(queryable, orderId);
const itemResult = await queryable.query<ReturnedItemRow>(
`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,
};
}