mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-09-02 13:21:02 +00:00
* 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.
126 lines
4.8 KiB
PHP
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);
|
|
}
|
|
}
|
|
}
|
|
}
|