From 3b11662229a111e4fe1e87ecdce3acdf777ce0e2 Mon Sep 17 00:00:00 2001
From: Deploy
Date: Wed, 26 Aug 2026 22:58:33 +0200
Subject: [PATCH] =?UTF-8?q?feat(pos):=20receipt=20VAT=20breakdown=20?=
=?UTF-8?q?=E2=80=94=20per-line=20rate=20+=20subtotals=20by=20type?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backlog/features.json | 16 ++++++
.../apps/pos/src/components/ReceiptModal.tsx | 50 ++++++++++++++++-
project/apps/pos/src/types/checkout.ts | 12 +++++
.../pos/application/build-pos-receipt.ts | 54 ++++++++++++++++++-
project/src/modules/pos/domain/pos-sale.ts | 16 ++++++
work/runtime-status.json | 18 ++++++-
6 files changed, 161 insertions(+), 5 deletions(-)
diff --git a/backlog/features.json b/backlog/features.json
index ef9e537..870ea65 100644
--- a/backlog/features.json
+++ b/backlog/features.json
@@ -8174,6 +8174,22 @@
},
"phase": "admin",
"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"
}
]
}
diff --git a/project/apps/pos/src/components/ReceiptModal.tsx b/project/apps/pos/src/components/ReceiptModal.tsx
index f6871a9..6960aeb 100644
--- a/project/apps/pos/src/components/ReceiptModal.tsx
+++ b/project/apps/pos/src/components/ReceiptModal.tsx
@@ -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({
{formatPrice(item.unitPriceCents)}/ud{item.freeItem ? ' · libre' : ''}
+ {!item.freeItem && item.vatRate && (
+
+ IVA {vatRateLabel(item.vatRate)}
+
+ )}
{item.discountCents > 0 && (
Dto. −{formatPrice(item.discountCents)}
@@ -146,8 +170,30 @@ export default function ReceiptModal({
−{formatPrice(receipt.discountCents)}
)}
-
- IVA
+
+ Base imponible
+ {formatPrice(taxBaseCents(receipt))}
+
+ {receipt.taxBreakdown.generalTaxCents > 0 && (
+
+ · IVA 21% (general)
+ {formatPrice(receipt.taxBreakdown.generalTaxCents)}
+
+ )}
+ {receipt.taxBreakdown.reducedTaxCents > 0 && (
+
+ · IVA 10% (reducido)
+ {formatPrice(receipt.taxBreakdown.reducedTaxCents)}
+
+ )}
+ {receipt.taxBreakdown.superReducedTaxCents > 0 && (
+
+ · IVA 4% (super-reducido)
+ {formatPrice(receipt.taxBreakdown.superReducedTaxCents)}
+
+ )}
+
+ Total IVA
{formatPrice(receipt.taxCents)}
diff --git a/project/apps/pos/src/types/checkout.ts b/project/apps/pos/src/types/checkout.ts
index 677b4f8..57d9d22 100644
--- a/project/apps/pos/src/types/checkout.ts
+++ b/project/apps/pos/src/types/checkout.ts
@@ -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;
diff --git a/project/src/modules/pos/application/build-pos-receipt.ts b/project/src/modules/pos/application/build-pos-receipt.ts
index 1023a5e..bdf1e92 100644
--- a/project/src/modules/pos/application/build-pos-receipt.ts
+++ b/project/src/modules/pos/application/build-pos-receipt.ts
@@ -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 | Pick;
@@ -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,
diff --git a/project/src/modules/pos/domain/pos-sale.ts b/project/src/modules/pos/domain/pos-sale.ts
index 08b5e61..30132d0 100644
--- a/project/src/modules/pos/domain/pos-sale.ts
+++ b/project/src/modules/pos/domain/pos-sale.ts
@@ -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;
diff --git a/work/runtime-status.json b/work/runtime-status.json
index 6a54812..421ed8b 100644
--- a/work/runtime-status.json
+++ b/work/runtime-status.json
@@ -2,11 +2,11 @@
"feature_id": "CHECKOUT-STOCK-RECHECK",
"stage": "build",
"agent": "implementer",
- "action": "Add Pagination component to all admin list pages",
+ "action": "Implementar desglose IVA en backend + ReceiptModal",
"state": "running",
"next_agent": "reviewer",
"waiting_for": "build",
- "updated_at": "2026-08-26T20:49:22.330241Z",
+ "updated_at": "2026-08-26T20:57:05.300295Z",
"timeline": [
{
"ts": "2026-08-26T17:21:37Z",
@@ -259,6 +259,20 @@
"stage": "build",
"state": "running",
"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": {