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

- Adds App\Support\DocumentTotals (mirrors the front-end calc) trusting only
  price/quantity/discounts/tax-line amounts.
- getInvoicePayload/getEstimatePayload/getRecurringInvoicePayload override the
  client totals; the shared DocumentItemService::createItems recomputes each
  item total; InvoiceService::update keys its due-amount logic off the
  recomputed 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 11:02:23 +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 invoice store +
* CreateItemRow).
*
* 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;
}
}