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

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