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": {