Files
InvoiceShelf/app/Domains/Sales/Application/DocumentItemService.php
T
Darko Gjorgjijoski 13aa087faf Add compound tax support (#753)
* test(taxes): cover compound_tax API semantics and validate it as boolean

* feat(taxes): restore the compound tax toggle in tax type settings

* feat(taxes): compute document-level compound tax amounts

Two-pass recalculation: simple taxes on the discounted subtotal, compound
taxes on the subtotal plus all simple taxes (v2 parity; branch order
fixed -> compound -> inclusive back-out -> simple). Amounts now also
recalculate on tax add/remove and on the tax-inclusive toggle, which
previously never triggered recalculation.

* feat(taxes): support compound taxes in per-item mode

Per-item tax rows now carry the compound flag and charge compound taxes
on the item's discounted base plus its simple taxes, through the same
calcTaxAmount branch order as document-level taxes. The item row splits
its tax sums into simple and compound so a compound row can never widen
its own base.

Also fixes two pre-existing bugs: the per-item tax dropdown read
window.__taxTypes, which nothing ever assigned, so it always rendered
empty (tax types are now fetched by the items table and passed down);
and removing a tax row hard-zeroed the item's totals instead of
re-syncing them.

* fix(invoices): skip placeholder tax rows when persisting item taxes

The document form keeps one empty placeholder tax row per item in
per-item tax mode. Its empty name is nullified by the framework's
empty-string middleware, so inserting it violated the NOT NULL
constraint and turned every per-item save into a 500. The equivalent
v2 guard keyed on a null amount, which the v3 stub (amount: 0) evades;
keying on the missing tax_type_id catches it.
2026-08-14 00:17:52 +02:00

126 lines
4.8 KiB
PHP

<?php
namespace App\Domains\Sales\Application;
use App\Domains\Metadata\Contracts\CustomFieldValueWriter;
use App\Support\DocumentTotals;
use Illuminate\Database\Eloquent\Model;
class DocumentItemService
{
public function __construct(
private readonly CustomFieldValueWriter $customFieldValueWriter,
) {}
/**
* Company-currency columns and the column each one is derived from.
*/
private const BASE_FIELDS = [
'base_price' => 'price',
'base_discount_val' => 'discount_val',
'base_tax' => 'tax',
'base_total' => 'total',
];
/**
* Persist the line items of a document.
*
* $recompute = false is for callers that have already decided every cent of
* the document and must not have it decided again: partial credit notes
* carry pro-rated integers from CreditNoteAmounts, and re-deriving the line
* total from price * quantity or the base_* columns from the exchange rate
* rounds a second time, drifts a cent, and breaks the telescoping invariant
* that makes a chain of partial credits add back up to the invoice. Such a
* caller still gets a base_* value derived here for any it did not supply.
*/
public function createItems(Model $document, array $items, bool $recompute = true): void
{
$exchangeRate = $document->exchange_rate;
foreach ($items as $item) {
$item['company_id'] = $document->company_id;
$item['exchange_rate'] = $exchangeRate;
if ($recompute) {
// Recompute the item total from price/quantity so a tampered item
// total can't desync from the recomputed document totals (GHSA-8c69).
$item['total'] = DocumentTotals::itemTotal($item, $document->discount_per_item === 'YES');
$item['base_price'] = $item['price'] * $exchangeRate;
$item['base_discount_val'] = $item['discount_val'] * $exchangeRate;
$item['base_tax'] = $item['tax'] * $exchangeRate;
$item['base_total'] = $item['total'] * $exchangeRate;
} else {
foreach (self::BASE_FIELDS as $baseField => $field) {
if (! array_key_exists($baseField, $item)) {
$item[$baseField] = ($item[$field] ?? 0) * $exchangeRate;
}
}
}
if (array_key_exists('recurring_invoice_id', $item)) {
unset($item['recurring_invoice_id']);
}
$createdItem = $document->items()->create($item);
if (array_key_exists('taxes', $item) && $item['taxes']) {
foreach ($item['taxes'] as $tax) {
if (empty($tax['tax_type_id'])) {
// UI placeholder row for per-item tax mode; never persist.
continue;
}
$tax['company_id'] = $document->company_id;
$tax['exchange_rate'] = $document->exchange_rate;
$tax['currency_id'] = $document->currency_id;
if ($recompute || ! array_key_exists('base_amount', $tax)) {
$tax['base_amount'] = $tax['amount'] * $exchangeRate;
}
if (gettype($tax['amount']) !== 'NULL') {
if (array_key_exists('recurring_invoice_id', $tax)) {
unset($tax['recurring_invoice_id']);
}
$createdItem->taxes()->create($tax);
}
}
}
if (array_key_exists('custom_fields', $item) && $item['custom_fields']) {
$this->customFieldValueWriter->attach($createdItem, $item['custom_fields']);
}
}
}
/**
* Persist the document-level tax rows.
*
* $recompute = false has the same meaning as in {@see createItems()}: the
* supplied base_amount is the caller's pro-rated integer and is kept as-is.
*/
public function createTaxes(Model $document, array $taxes, bool $recompute = true): void
{
$exchangeRate = $document->exchange_rate;
foreach ($taxes as $tax) {
$tax['company_id'] = $document->company_id;
$tax['exchange_rate'] = $document->exchange_rate;
$tax['currency_id'] = $document->currency_id;
if ($recompute || ! array_key_exists('base_amount', $tax)) {
$tax['base_amount'] = $tax['amount'] * $exchangeRate;
}
if (gettype($tax['amount']) !== 'NULL') {
if (array_key_exists('recurring_invoice_id', $tax)) {
unset($tax['recurring_invoice_id']);
}
$document->taxes()->create($tax);
}
}
}
}