275 lines
9.5 KiB
TypeScript
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,
|
|
};
|
|
}
|