Files
InvoiceShelf/app/Support/DocumentTotals.php
Darko Gjorgjijoski 107a951126 fix(security): recompute document totals server-side (GHSA-8c69) (#665)
Invoice/estimate/recurring-invoice creation and update accepted total,
sub_total, tax and due_amount straight from the request with no server-side
recalculation, so a client could persist financial totals that don't match the
line items (and, on invoice update, corrupt the due-amount/paid-amount logic
which keyed off the client total).

- Adds App\Support\DocumentTotals which recomputes item totals (round(price *
  quantity) minus per-item discount) and document totals (sub_total, tax with
  per-item vs document and tax_included handling, total, due_amount), mirroring
  the front-end calculation. Only price/quantity/discounts/tax-line amounts are
  trusted.
- getInvoicePayload/getEstimatePayload/getRecurringInvoicePayload override the
  client totals with the recomputed values; createItems recomputes each stored
  item total; Invoice::updateInvoice keys its due-amount/paid-amount logic off
  the recomputed total instead of the request total.

Adds DocumentTotals unit tests + a feature test proving a tampered invoice
total is ignored. Existing create/update tests no longer assert the (now
server-authoritative) derived totals.
2026-06-12 09:30:44 +02:00

71 lines
2.4 KiB
PHP

<?php
namespace App\Support;
/**
* Recomputes authoritative invoice/estimate/recurring-invoice totals from the
* submitted line items + document-level discount/taxes, mirroring the
* front-end calculation (resources/scripts/admin/stores/invoice.js and
* components/estimate-invoice-common/CreateItemRow.vue).
*
* The client-supplied total / sub_total / tax / due_amount are NOT trusted
* (GHSA-8c69) — only price, quantity, discounts and tax-line amounts are.
* All amounts are integer minor units (cents).
*/
class DocumentTotals
{
/**
* @param array $items each item: price, quantity, discount_val?, taxes?[{amount}]
* @param array $taxes document-level taxes: [{amount}, ...]
* @return array{sub_total:int, tax:int, total:int}
*/
public static function compute(array $items, array $taxes, $discountVal, $taxPerItem, bool $taxIncluded, $discountPerItem = 'NO'): array
{
$perItemDiscount = is_string($discountPerItem) && strtoupper(trim($discountPerItem)) === 'YES';
$perItemTax = is_string($taxPerItem) && strtoupper(trim($taxPerItem)) === 'YES';
$subTotal = 0;
$itemTaxTotal = 0;
foreach ($items as $item) {
$subTotal += self::itemTotal($item, $perItemDiscount);
$itemTaxTotal += self::sumTaxAmounts($item['taxes'] ?? []);
}
$subtotalWithDiscount = $subTotal - (int) round((float) $discountVal);
$totalTax = $perItemTax ? $itemTaxTotal : self::sumTaxAmounts($taxes);
$total = $taxIncluded ? $subtotalWithDiscount : $subtotalWithDiscount + $totalTax;
return [
'sub_total' => $subTotal,
'tax' => $totalTax,
'total' => $total,
];
}
/**
* Authoritative per-item total: round(price * quantity) minus the item
* discount (only applied when discount is configured per item).
*/
public static function itemTotal(array $item, bool $perItemDiscount): int
{
$price = (float) ($item['price'] ?? 0);
$quantity = (float) ($item['quantity'] ?? 0);
$discount = $perItemDiscount ? (int) round((float) ($item['discount_val'] ?? 0)) : 0;
return (int) round($price * $quantity) - $discount;
}
protected static function sumTaxAmounts(array $taxes): int
{
$sum = 0;
foreach ($taxes as $tax) {
$sum += (int) round((float) ($tax['amount'] ?? 0));
}
return $sum;
}
}