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

@@ -8174,6 +8174,22 @@
}, },
"phase": "admin", "phase": "admin",
"completed_at": "2026-08-26T20:56:49Z" "completed_at": "2026-08-26T20:56:49Z"
},
{
"id": "RECEIPT-VAT-BREAKDOWN",
"type": "fix",
"title": "Receipt VAT breakdown: discriminate types per line and subtotal",
"description": "POS receipt should show VAT type (general/reduced/super-reduced) per line item AND break down subtotals by VAT type. Currently only shows total IVA without discrimination.",
"priority": "med",
"risk": "med",
"status": "in_progress",
"created_at": "2026-08-26",
"gates": {
"reviewer": false,
"security": false,
"qa": false
},
"phase": "pos"
} }
] ]
} }

View File

@@ -13,6 +13,25 @@ interface ReceiptModalProps {
showActions?: boolean; // false when used just for printing 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({ export default function ReceiptModal({
receipt, receipt,
initialEmail = '', initialEmail = '',
@@ -122,6 +141,11 @@ export default function ReceiptModal({
<span className="block text-xs text-gray-500"> <span className="block text-xs text-gray-500">
{formatPrice(item.unitPriceCents)}/ud{item.freeItem ? ' · libre' : ''} {formatPrice(item.unitPriceCents)}/ud{item.freeItem ? ' · libre' : ''}
</span> </span>
{!item.freeItem && item.vatRate && (
<span className="block text-xs text-gray-400">
IVA {vatRateLabel(item.vatRate)}
</span>
)}
{item.discountCents > 0 && ( {item.discountCents > 0 && (
<span className="block text-xs text-red-600"> <span className="block text-xs text-red-600">
Dto. {formatPrice(item.discountCents)} Dto. {formatPrice(item.discountCents)}
@@ -146,8 +170,30 @@ export default function ReceiptModal({
<span>{formatPrice(receipt.discountCents)}</span> <span>{formatPrice(receipt.discountCents)}</span>
</p> </p>
)} )}
<p className="flex justify-between"> <p className="flex justify-between border-t border-gray-300 pt-1 font-semibold">
<span>IVA</span> <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> <span>{formatPrice(receipt.taxCents)}</span>
</p> </p>
<p className="flex justify-between border-t border-gray-900 pt-2 text-xl font-bold"> <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; taxCents: number;
totalCents: number; totalCents: number;
freeItem: boolean; 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 { export interface ReceiptPayment {
@@ -57,6 +68,7 @@ export interface PosReceipt {
discountCents: number; discountCents: number;
taxCents: number; taxCents: number;
totalCents: number; totalCents: number;
taxBreakdown: ReceiptTaxBreakdown;
payments: ReceiptPayment[]; payments: ReceiptPayment[];
changeCents: number; changeCents: number;
header: string | null; header: string | null;

View File

@@ -1,5 +1,10 @@
import type pg from 'pg'; 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'; import { AppError } from '../../../shared/errors.js';
type Queryable = Pick<pg.Pool, 'query'> | Pick<pg.PoolClient, 'query'>; type Queryable = Pick<pg.Pool, 'query'> | Pick<pg.PoolClient, 'query'>;
@@ -146,12 +151,14 @@ export async function buildPosReceipt(queryable: Queryable, orderId: string): Pr
taxCents, taxCents,
totalCents, totalCents,
freeItem: item.is_free_item, freeItem: item.is_free_item,
vatRate: item.vat_rate,
}; };
}), }),
subtotalCents: Number(order.subtotal_cents), subtotalCents: Number(order.subtotal_cents),
discountCents: Number(order.discount_cents), discountCents: Number(order.discount_cents),
taxCents: Number(order.tax_cents), taxCents: Number(order.tax_cents),
totalCents: Number(order.total_cents), totalCents: Number(order.total_cents),
taxBreakdown: computeTaxBreakdown(itemResult.rows),
payments, payments,
changeCents: payments.reduce((sum, payment) => sum + payment.changeCents, 0), changeCents: payments.reduce((sum, payment) => sum + payment.changeCents, 0),
header: order.receipt_header, header: order.receipt_header,
@@ -170,6 +177,42 @@ function grossFromNet(
return Math.round(netUnitAmountCents * (1 + basisPoints / 10_000)); 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 { function isPaymentKind(value: unknown): value is PosPaymentKind {
return value === 'cash' || value === 'card' || value === 'other'; return value === 'cash' || value === 'card' || value === 'other';
} }
@@ -241,6 +284,7 @@ export async function buildPosReturnReceipt(
taxCents, taxCents,
totalCents: -total, totalCents: -total,
freeItem: row.is_free_item, freeItem: row.is_free_item,
vatRate: row.vat_rate,
}; };
}); });
const subtotal = items.reduce((sum, item) => sum + item.subtotalCents, 0); const subtotal = items.reduce((sum, item) => sum + item.subtotalCents, 0);
@@ -263,6 +307,14 @@ export async function buildPosReturnReceipt(
discountCents: -discount, discountCents: -discount,
taxCents: -tax, taxCents: -tax,
totalCents: -refundedCents, 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: [], payments: [],
changeCents: 0, changeCents: 0,
header: original.header, header: original.header,

View File

@@ -73,6 +73,20 @@ export interface PosReceiptItem {
taxCents: number; taxCents: number;
totalCents: number; totalCents: number;
freeItem: boolean; 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 { export interface PosReceiptPayment {
@@ -106,6 +120,8 @@ export interface PosReceipt {
discountCents: number; discountCents: number;
taxCents: number; taxCents: number;
totalCents: number; totalCents: number;
/** Breakdown of the tax total by VAT rate. */
taxBreakdown: PosReceiptTaxBreakdown;
payments: PosReceiptPayment[]; payments: PosReceiptPayment[];
changeCents: number; changeCents: number;
header: string | null; header: string | null;

View File

@@ -2,11 +2,11 @@
"feature_id": "CHECKOUT-STOCK-RECHECK", "feature_id": "CHECKOUT-STOCK-RECHECK",
"stage": "build", "stage": "build",
"agent": "implementer", "agent": "implementer",
"action": "Add Pagination component to all admin list pages", "action": "Implementar desglose IVA en backend + ReceiptModal",
"state": "running", "state": "running",
"next_agent": "reviewer", "next_agent": "reviewer",
"waiting_for": "build", "waiting_for": "build",
"updated_at": "2026-08-26T20:49:22.330241Z", "updated_at": "2026-08-26T20:57:05.300295Z",
"timeline": [ "timeline": [
{ {
"ts": "2026-08-26T17:21:37Z", "ts": "2026-08-26T17:21:37Z",
@@ -259,6 +259,20 @@
"stage": "build", "stage": "build",
"state": "running", "state": "running",
"message": "Add pagination to admin orders page" "message": "Add pagination to admin orders page"
},
{
"ts": "2026-08-26T20:56:56.601386Z",
"agent": "architect",
"stage": "design",
"state": "done",
"message": "Diseño: vatRate por línea + taxBreakdown por tipo en receipt"
},
{
"ts": "2026-08-26T20:56:56.601386Z",
"agent": "implementer",
"stage": "build",
"state": "running",
"message": "Implementar desglose IVA en backend + ReceiptModal"
} }
], ],
"gates": { "gates": {