feat(pos): receipt VAT breakdown — per-line rate + subtotals by type

This commit is contained in:
Deploy
2026-08-26 22:58:33 +02:00
parent ff0d470845
commit 3b11662229
6 changed files with 161 additions and 5 deletions

View File

@@ -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">

View File

@@ -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;

View File

@@ -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,

View File

@@ -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;