feat(pos): receipt VAT breakdown — per-line rate + subtotals by type
This commit is contained in:
@@ -13,6 +13,25 @@ interface ReceiptModalProps {
|
||||
showActions?: boolean; // false when used just for printing
|
||||
}
|
||||
|
||||
function vatRateLabel(rate: 'general' | 'reduced' | 'super-reduced'): string {
|
||||
switch (rate) {
|
||||
case 'general':
|
||||
return '21% (general)';
|
||||
case 'reduced':
|
||||
return '10% (reducido)';
|
||||
case 'super-reduced':
|
||||
return '4% (super-reducido)';
|
||||
}
|
||||
}
|
||||
|
||||
function taxBaseCents(receipt: PosReceipt): number {
|
||||
return (
|
||||
receipt.taxBreakdown.generalBaseCents +
|
||||
receipt.taxBreakdown.reducedBaseCents +
|
||||
receipt.taxBreakdown.superReducedBaseCents
|
||||
);
|
||||
}
|
||||
|
||||
export default function ReceiptModal({
|
||||
receipt,
|
||||
initialEmail = '',
|
||||
@@ -122,6 +141,11 @@ export default function ReceiptModal({
|
||||
<span className="block text-xs text-gray-500">
|
||||
{formatPrice(item.unitPriceCents)}/ud{item.freeItem ? ' · libre' : ''}
|
||||
</span>
|
||||
{!item.freeItem && item.vatRate && (
|
||||
<span className="block text-xs text-gray-400">
|
||||
IVA {vatRateLabel(item.vatRate)}
|
||||
</span>
|
||||
)}
|
||||
{item.discountCents > 0 && (
|
||||
<span className="block text-xs text-red-600">
|
||||
Dto. −{formatPrice(item.discountCents)}
|
||||
@@ -146,8 +170,30 @@ export default function ReceiptModal({
|
||||
<span>−{formatPrice(receipt.discountCents)}</span>
|
||||
</p>
|
||||
)}
|
||||
<p className="flex justify-between">
|
||||
<span>IVA</span>
|
||||
<p className="flex justify-between border-t border-gray-300 pt-1 font-semibold">
|
||||
<span>Base imponible</span>
|
||||
<span>{formatPrice(taxBaseCents(receipt))}</span>
|
||||
</p>
|
||||
{receipt.taxBreakdown.generalTaxCents > 0 && (
|
||||
<p className="flex justify-between text-xs text-gray-600">
|
||||
<span>· IVA 21% (general)</span>
|
||||
<span>{formatPrice(receipt.taxBreakdown.generalTaxCents)}</span>
|
||||
</p>
|
||||
)}
|
||||
{receipt.taxBreakdown.reducedTaxCents > 0 && (
|
||||
<p className="flex justify-between text-xs text-gray-600">
|
||||
<span>· IVA 10% (reducido)</span>
|
||||
<span>{formatPrice(receipt.taxBreakdown.reducedTaxCents)}</span>
|
||||
</p>
|
||||
)}
|
||||
{receipt.taxBreakdown.superReducedTaxCents > 0 && (
|
||||
<p className="flex justify-between text-xs text-gray-600">
|
||||
<span>· IVA 4% (super-reducido)</span>
|
||||
<span>{formatPrice(receipt.taxBreakdown.superReducedTaxCents)}</span>
|
||||
</p>
|
||||
)}
|
||||
<p className="flex justify-between font-semibold">
|
||||
<span>Total IVA</span>
|
||||
<span>{formatPrice(receipt.taxCents)}</span>
|
||||
</p>
|
||||
<p className="flex justify-between border-t border-gray-900 pt-2 text-xl font-bold">
|
||||
|
||||
@@ -25,6 +25,17 @@ export interface ReceiptItem {
|
||||
taxCents: number;
|
||||
totalCents: number;
|
||||
freeItem: boolean;
|
||||
/** Type of VAT applied to this line. null when unknown (free items, legacy). */
|
||||
vatRate: 'general' | 'reduced' | 'super-reduced' | null;
|
||||
}
|
||||
|
||||
export interface ReceiptTaxBreakdown {
|
||||
generalBaseCents: number;
|
||||
generalTaxCents: number;
|
||||
reducedBaseCents: number;
|
||||
reducedTaxCents: number;
|
||||
superReducedBaseCents: number;
|
||||
superReducedTaxCents: number;
|
||||
}
|
||||
|
||||
export interface ReceiptPayment {
|
||||
@@ -57,6 +68,7 @@ export interface PosReceipt {
|
||||
discountCents: number;
|
||||
taxCents: number;
|
||||
totalCents: number;
|
||||
taxBreakdown: ReceiptTaxBreakdown;
|
||||
payments: ReceiptPayment[];
|
||||
changeCents: number;
|
||||
header: string | null;
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import type pg from 'pg';
|
||||
import type { PosPaymentKind, PosReceipt, PosReceiptPayment } from '../domain/pos-sale.js';
|
||||
import type {
|
||||
PosPaymentKind,
|
||||
PosReceipt,
|
||||
PosReceiptPayment,
|
||||
PosReceiptTaxBreakdown,
|
||||
} from '../domain/pos-sale.js';
|
||||
import { AppError } from '../../../shared/errors.js';
|
||||
|
||||
type Queryable = Pick<pg.Pool, 'query'> | Pick<pg.PoolClient, 'query'>;
|
||||
@@ -146,12 +151,14 @@ export async function buildPosReceipt(queryable: Queryable, orderId: string): Pr
|
||||
taxCents,
|
||||
totalCents,
|
||||
freeItem: item.is_free_item,
|
||||
vatRate: item.vat_rate,
|
||||
};
|
||||
}),
|
||||
subtotalCents: Number(order.subtotal_cents),
|
||||
discountCents: Number(order.discount_cents),
|
||||
taxCents: Number(order.tax_cents),
|
||||
totalCents: Number(order.total_cents),
|
||||
taxBreakdown: computeTaxBreakdown(itemResult.rows),
|
||||
payments,
|
||||
changeCents: payments.reduce((sum, payment) => sum + payment.changeCents, 0),
|
||||
header: order.receipt_header,
|
||||
@@ -170,6 +177,42 @@ function grossFromNet(
|
||||
return Math.round(netUnitAmountCents * (1 + basisPoints / 10_000));
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the per-VAT-rate breakdown for a set of receipt item rows.
|
||||
* Items with unknown VAT (null) or marked as free items are ignored.
|
||||
*/
|
||||
function computeTaxBreakdown(
|
||||
rows: ReceiptItemRow[],
|
||||
): PosReceiptTaxBreakdown {
|
||||
const result: PosReceiptTaxBreakdown = {
|
||||
generalBaseCents: 0,
|
||||
generalTaxCents: 0,
|
||||
reducedBaseCents: 0,
|
||||
reducedTaxCents: 0,
|
||||
superReducedBaseCents: 0,
|
||||
superReducedTaxCents: 0,
|
||||
};
|
||||
for (const row of rows) {
|
||||
if (row.is_free_item || !row.vat_rate) continue;
|
||||
const quantity = Number(row.quantity);
|
||||
const unitNet = Number(row.unit_price_cents);
|
||||
const unitTax = Number(row.tax_cents);
|
||||
const base = unitNet * quantity;
|
||||
const tax = unitTax * quantity;
|
||||
if (row.vat_rate === 'general') {
|
||||
result.generalBaseCents += base;
|
||||
result.generalTaxCents += tax;
|
||||
} else if (row.vat_rate === 'reduced') {
|
||||
result.reducedBaseCents += base;
|
||||
result.reducedTaxCents += tax;
|
||||
} else if (row.vat_rate === 'super-reduced') {
|
||||
result.superReducedBaseCents += base;
|
||||
result.superReducedTaxCents += tax;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function isPaymentKind(value: unknown): value is PosPaymentKind {
|
||||
return value === 'cash' || value === 'card' || value === 'other';
|
||||
}
|
||||
@@ -241,6 +284,7 @@ export async function buildPosReturnReceipt(
|
||||
taxCents,
|
||||
totalCents: -total,
|
||||
freeItem: row.is_free_item,
|
||||
vatRate: row.vat_rate,
|
||||
};
|
||||
});
|
||||
const subtotal = items.reduce((sum, item) => sum + item.subtotalCents, 0);
|
||||
@@ -263,6 +307,14 @@ export async function buildPosReturnReceipt(
|
||||
discountCents: -discount,
|
||||
taxCents: -tax,
|
||||
totalCents: -refundedCents,
|
||||
taxBreakdown: {
|
||||
generalBaseCents: -original.taxBreakdown.generalBaseCents,
|
||||
generalTaxCents: -original.taxBreakdown.generalTaxCents,
|
||||
reducedBaseCents: -original.taxBreakdown.reducedBaseCents,
|
||||
reducedTaxCents: -original.taxBreakdown.reducedTaxCents,
|
||||
superReducedBaseCents: -original.taxBreakdown.superReducedBaseCents,
|
||||
superReducedTaxCents: -original.taxBreakdown.superReducedTaxCents,
|
||||
},
|
||||
payments: [],
|
||||
changeCents: 0,
|
||||
header: original.header,
|
||||
|
||||
@@ -73,6 +73,20 @@ export interface PosReceiptItem {
|
||||
taxCents: number;
|
||||
totalCents: number;
|
||||
freeItem: boolean;
|
||||
/** Type of VAT applied to this line. null when unknown (free items, legacy). */
|
||||
vatRate: 'general' | 'reduced' | 'super-reduced' | null;
|
||||
}
|
||||
|
||||
export interface PosReceiptTaxBreakdown {
|
||||
/** Sum of net (pre-VAT) base for items subject to the general rate (21%). */
|
||||
generalBaseCents: number;
|
||||
generalTaxCents: number;
|
||||
/** Sum of net base for items subject to the reduced rate (10%). */
|
||||
reducedBaseCents: number;
|
||||
reducedTaxCents: number;
|
||||
/** Sum of net base for items subject to the super-reduced rate (4%). */
|
||||
superReducedBaseCents: number;
|
||||
superReducedTaxCents: number;
|
||||
}
|
||||
|
||||
export interface PosReceiptPayment {
|
||||
@@ -106,6 +120,8 @@ export interface PosReceipt {
|
||||
discountCents: number;
|
||||
taxCents: number;
|
||||
totalCents: number;
|
||||
/** Breakdown of the tax total by VAT rate. */
|
||||
taxBreakdown: PosReceiptTaxBreakdown;
|
||||
payments: PosReceiptPayment[];
|
||||
changeCents: number;
|
||||
header: string | null;
|
||||
|
||||
Reference in New Issue
Block a user