From c4c816b0eddd9e9dbe945d1e4a54f84748259a8b Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski Date: Fri, 21 Aug 2026 01:38:55 +0200 Subject: [PATCH] feat(sales): fresh support services and provider --- .../Sales/Application/CreditNoteService.php | 472 ++++++++++++++++++ .../Sales/Application/DocumentItemService.php | 126 +++++ .../Application/InvoiceBalanceService.php | 49 ++ app/Domains/Sales/SalesServiceProvider.php | 66 +++ 4 files changed, 713 insertions(+) create mode 100644 app/Domains/Sales/Application/CreditNoteService.php create mode 100644 app/Domains/Sales/Application/DocumentItemService.php create mode 100644 app/Domains/Sales/Application/InvoiceBalanceService.php create mode 100644 app/Domains/Sales/SalesServiceProvider.php diff --git a/app/Domains/Sales/Application/CreditNoteService.php b/app/Domains/Sales/Application/CreditNoteService.php new file mode 100644 index 00000000..c993b031 --- /dev/null +++ b/app/Domains/Sales/Application/CreditNoteService.php @@ -0,0 +1,472 @@ + invoiceItemId, 'quantity' => float], ...]. + * An empty array credits every remaining quantity (a full reversal). + * @param string|null $reason free-text reason stored on the credit note + * + * @throws ValidationException + */ + public function create(Invoice $invoice, array $items = [], ?string $reason = null): Invoice + { + return DB::transaction(function () use ($invoice, $items, $reason) { + // The invoice is re-read under a row lock because every guard below + // is a read-then-write on it: two concurrent credit notes checking + // the same remaining quantity would each be allowed and together + // overdraw the invoice. + $original = Invoice::query() + ->whereKey($invoice->getKey()) + ->lockForUpdate() + ->firstOrFail(); + + $original->load(['items.taxes', 'taxes', 'fields', 'creditNotes.items']); + + $snapshot = $this->snapshot($original); + $invoiced = $this->invoicedQuantities($original); + $before = $this->creditedQuantities($original); + $after = $this->targetQuantities($invoiced, $before, $items); + + $paid = (int) $original->allocations()->sum('amount'); + $creditedBefore = $this->creditedTotal($original); + + $this->guard($original, $invoiced, $before, $after, $paid, $creditedBefore); + + $amounts = CreditNoteAmounts::forCredit($snapshot, $before, $after); + + if ($creditedBefore + $amounts['total'] > (int) $original->total - $paid) { + throw ValidationException::withMessages([ + 'invoice' => ['credit_amount_exceeds_invoice_balance'], + ]); + } + + $creditNote = $this->persist($original, $amounts, $reason); + + $this->recalculateBalance($original); + + return Invoice::with(self::RESPONSE_RELATIONS)->find($creditNote->id); + }); + } + + /** + * How much of every line of the invoice is still creditable, in hundredths, + * keyed by the original invoice_items.id. + */ + public function remainingQuantities(Invoice $invoice): array + { + $invoice->loadMissing(['items', 'creditNotes.items']); + + $credited = $this->creditedQuantities($invoice); + $remaining = []; + + foreach ($this->invoicedQuantities($invoice) as $itemId => $hundredths) { + $remaining[$itemId] = max(0, $hundredths - ($credited[$itemId] ?? 0)); + } + + return $remaining; + } + + /** + * The amount already credited off this invoice, as a positive number of + * cents (credit notes store negative totals). + */ + public function creditedTotal(Invoice $invoice): int + { + return -(int) $invoice->creditNotes()->sum('total'); + } + + public function recalculateBalance(Invoice $invoice): void + { + $this->invoiceBalanceService->recalculate($invoice); + } + + /** + * Enforce the credit-note invariants, in the order that produces the most + * specific message for each situation. + * + * @throws ValidationException + */ + protected function guard(Invoice $invoice, array $invoiced, array $before, array $after, int $paid, int $creditedBefore): void + { + $remaining = 0; + + foreach ($invoiced as $itemId => $hundredths) { + $remaining += max(0, $hundredths - ($before[$itemId] ?? 0)); + } + + if ($remaining === 0 || (int) $invoice->total - $paid - $creditedBefore <= 0) { + throw ValidationException::withMessages([ + 'invoice' => ['invoice_already_fully_credited'], + ]); + } + + foreach ($after as $itemId => $hundredths) { + if ($hundredths > ($invoiced[$itemId] ?? 0)) { + throw ValidationException::withMessages([ + 'invoice' => ['credit_quantity_exceeds_remaining'], + ]); + } + } + + foreach ($after as $itemId => $hundredths) { + if ($hundredths > ($before[$itemId] ?? 0)) { + return; + } + } + + throw ValidationException::withMessages([ + 'invoice' => ['credit_note_must_credit_something'], + ]); + } + + /** + * Write the credit-note document, its lines and its taxes. + */ + protected function persist(Invoice $invoice, array $amounts, ?string $reason): Invoice + { + // A fresh SerialNumberService per document, as everywhere else in the + // app: it is a stateful builder that keeps the number it computed, so a + // shared instance would hand the same number to the next credit note. + $serial = (new SerialNumberService) + ->setModel(new Invoice) + ->setCompany($invoice->company_id) + ->setCustomer($invoice->customer_id) + ->setSettingKey('credit_note_number_format') + ->setSequenceScope(['type' => Invoice::TYPE_CREDIT_NOTE]) + ->setNextNumbers(); + + // The builder resolved all three figures in the pass above; read them + // off it here so the document below stays plain data. + $number = $serial->getNextNumber(); + $sequence = $serial->nextSequenceNumber; + $customerSequence = $serial->nextCustomerSequenceNumber; + + // Columns the reversal inherits verbatim: it has to sit in the same + // currency, tax and discount regime as the document it undoes, or the + // two would not net out against each other. + $carriedOver = $invoice->only([ + 'discount', + 'discount_type', + 'tax_per_item', + 'discount_per_item', + 'currency_id', + 'sales_tax_type', + 'sales_tax_address_type', + ]); + + // exchange_rate is a float multiplier, not a currency amount. The base_* + // fields are pro-rated from the original's stored base_* integers by the + // calculator, so they are negated as-is rather than recomputed through + // the rate, which would re-round a decision already made. + $creditNote = Invoice::create([ + 'creator_id' => auth()->id(), + 'type' => Invoice::TYPE_CREDIT_NOTE, + 'related_invoice_id' => $invoice->id, + 'credit_reason' => $reason, + 'invoice_date' => Carbon::now()->format('Y-m-d'), + // A reversal is never owed, so it has no due date at all. Leaving it + // null also keeps the credit note out of every due/aging query. + 'due_date' => null, + 'invoice_number' => $number, + 'sequence_number' => $sequence, + 'customer_sequence_number' => $customerSequence, + 'reference_number' => $invoice->invoice_number, + 'customer_id' => $invoice->customer_id, + 'company_id' => $invoice->company_id, + 'template_name' => $invoice->template_name, + // A credit note gets the ordinary create-review-send lifecycle: born + // DRAFT so the Send affordances appear, promoted to SENT by send(). + // Nothing is ever owed on it, so paid_status/due_amount below keep + // it out of the payment flows regardless of status. + 'status' => Invoice::STATUS_DRAFT, + // The credit note is born settled: it exists to pair with the + // original invoice, nothing is ever owed on it, so it must never + // surface as an open (negative) balance in any due/aging view. + 'paid_status' => Invoice::STATUS_PAID, + 'sub_total' => -$amounts['sub_total'], + 'discount_val' => -$amounts['discount_val'], + 'total' => -$amounts['total'], + 'due_amount' => 0, + 'tax' => -$amounts['tax'], + 'tax_included' => $invoice->tax_included, + 'notes' => $invoice->notes, + 'exchange_rate' => $invoice->exchange_rate, + 'base_discount_val' => -$amounts['base_discount_val'], + 'base_sub_total' => -$amounts['base_sub_total'], + 'base_total' => -$amounts['base_total'], + 'base_tax' => -$amounts['base_tax'], + 'base_due_amount' => 0, + ...$carriedOver, + ]); + + $creditNote->unique_hash = Hashids::connection(HashidConnection::Invoice->value)->encode($creditNote->id); + $creditNote->save(); + + // recompute: false throughout. The calculator has already decided every + // cent of this document, and re-deriving the line totals or the base_* + // columns from price * quantity * rate would round a second time and + // break the telescoping invariant by a cent. + $this->documentItemService->createItems( + $creditNote, + $this->creditItems($invoice, $amounts['items']), + recompute: false + ); + + if ($invoice->tax_per_item !== 'YES' && ! empty($amounts['taxes'])) { + $this->documentItemService->createTaxes( + $creditNote, + $this->creditTaxes($invoice->taxes, $amounts['taxes']), + recompute: false + ); + } + + if ($invoice->fields()->exists()) { + $customFields = []; + + foreach ($invoice->fields as $field) { + $customFields[] = [ + 'id' => $field->custom_field_id, + 'value' => $field->defaultAnswer, + ]; + } + + $this->customFieldValueWriter->attach($creditNote, $customFields); + } + + return $creditNote; + } + + /** + * Build the credit-note line payloads: negated amounts from the calculator, + * descriptive fields copied from the line each one credits. + */ + protected function creditItems(Invoice $invoice, array $lines): array + { + $sourceItems = $invoice->items->keyBy('id'); + $items = []; + + foreach ($lines as $sourceId => $line) { + /** @var InvoiceItem $source */ + $source = $sourceItems->get($sourceId); + + if (! $source) { + continue; + } + + $items[] = [ + 'source_invoice_item_id' => $line['source_invoice_item_id'], + 'item_id' => $source->item_id, + 'name' => $source->name, + 'description' => $source->description, + 'unit_name' => $source->unit_name, + 'discount_type' => $source->discount_type, + 'discount' => $source->discount, + // The quantity stays positive: what makes the line a credit is + // the negative price and total, exactly as a full reversal does. + 'quantity' => $line['quantity'], + 'price' => -$line['price'], + 'base_price' => -$line['base_price'], + 'discount_val' => -$line['discount_val'], + 'tax' => -$line['tax'], + 'total' => -$line['total'], + 'base_discount_val' => -$line['base_discount_val'], + 'base_tax' => -$line['base_tax'], + 'base_total' => -$line['base_total'], + 'taxes' => $this->creditTaxes($source->taxes, $line['taxes']), + ]; + } + + return $items; + } + + /** + * Build tax-row payloads: negated amounts from the calculator, descriptive + * fields copied from the tax row each one reverses. + */ + protected function creditTaxes(Collection $sourceTaxes, array $amounts): array + { + $byId = $sourceTaxes->keyBy('id'); + $taxes = []; + + foreach ($amounts as $taxId => $amount) { + $source = $byId->get($taxId); + + if (! $source) { + continue; + } + + $taxes[] = [ + 'tax_type_id' => $source->tax_type_id, + 'item_id' => $source->item_id, + 'name' => $source->name, + 'percent' => $source->percent, + 'compound_tax' => $source->compound_tax, + 'calculation_type' => $source->calculation_type, + 'fixed_amount' => $source->fixed_amount, + 'amount' => -$amount['amount'], + 'base_amount' => -$amount['base_amount'], + ]; + } + + return $taxes; + } + + /** + * The original invoice's stored figures, in the shape the calculator reads. + */ + protected function snapshot(Invoice $invoice): array + { + $items = []; + + foreach ($invoice->items as $item) { + $items[$item->id] = [ + 'price' => (int) $item->price, + 'quantity' => (float) $item->quantity, + 'discount_val' => (int) $item->discount_val, + 'tax' => (int) $item->tax, + 'total' => (int) $item->total, + 'base_price' => (int) $item->base_price, + 'base_discount_val' => (int) $item->base_discount_val, + 'base_tax' => (int) $item->base_tax, + 'base_total' => (int) $item->base_total, + 'taxes' => $this->snapshotTaxes($item->taxes), + ]; + } + + return [ + 'sub_total' => (int) $invoice->sub_total, + 'discount_val' => (int) $invoice->discount_val, + 'tax' => (int) $invoice->tax, + 'total' => (int) $invoice->total, + 'base_sub_total' => (int) $invoice->base_sub_total, + 'base_discount_val' => (int) $invoice->base_discount_val, + 'base_tax' => (int) $invoice->base_tax, + 'base_total' => (int) $invoice->base_total, + 'discount_per_item' => $invoice->discount_per_item, + 'tax_per_item' => $invoice->tax_per_item, + 'tax_included' => (bool) $invoice->tax_included, + 'items' => $items, + 'taxes' => $this->snapshotTaxes($invoice->taxes), + ]; + } + + protected function snapshotTaxes(Collection $taxes): array + { + $snapshot = []; + + foreach ($taxes as $tax) { + $snapshot[$tax->id] = [ + 'amount' => (int) $tax->amount, + 'base_amount' => (int) $tax->base_amount, + ]; + } + + return $snapshot; + } + + /** + * The invoiced quantity of every line, in hundredths. + */ + protected function invoicedQuantities(Invoice $invoice): array + { + $quantities = []; + + foreach ($invoice->items as $item) { + $quantities[$item->id] = CreditNoteAmounts::toHundredths($item->quantity); + } + + return $quantities; + } + + /** + * The already-credited quantity of every line, in hundredths, read off the + * credit notes that still exist. Deleting a credit note therefore gives its + * quantities back without any separate bookkeeping. + */ + protected function creditedQuantities(Invoice $invoice): array + { + $quantities = []; + + foreach ($invoice->creditNotes as $creditNote) { + foreach ($creditNote->items as $item) { + if (! $item->source_invoice_item_id) { + continue; + } + + $quantities[$item->source_invoice_item_id] = + ($quantities[$item->source_invoice_item_id] ?? 0) + + CreditNoteAmounts::toHundredths($item->quantity); + } + } + + return $quantities; + } + + /** + * The cumulative credited quantities this credit note leaves behind: the + * requested quantities on top of what was credited before, or every + * invoiced quantity when nothing specific was requested (full reversal). + */ + protected function targetQuantities(array $invoiced, array $before, array $items): array + { + if (empty($items)) { + return $invoiced; + } + + $after = $before; + + foreach ($items as $line) { + $itemId = (int) $line['id']; + + $after[$itemId] = ($after[$itemId] ?? 0) + CreditNoteAmounts::toHundredths($line['quantity']); + } + + return $after; + } +} diff --git a/app/Domains/Sales/Application/DocumentItemService.php b/app/Domains/Sales/Application/DocumentItemService.php new file mode 100644 index 00000000..415b6d45 --- /dev/null +++ b/app/Domains/Sales/Application/DocumentItemService.php @@ -0,0 +1,126 @@ + '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') { + // A row lifted off a recurring template still carries + // the template's key, which means nothing on the + // generated document. Dropping an absent key is a + // no-op, so it needs no guard. + 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') { + // Same template key as in createItems(), dropped the same way. + unset($tax['recurring_invoice_id']); + + $document->taxes()->create($tax); + } + } + } +} diff --git a/app/Domains/Sales/Application/InvoiceBalanceService.php b/app/Domains/Sales/Application/InvoiceBalanceService.php new file mode 100644 index 00000000..f2baec76 --- /dev/null +++ b/app/Domains/Sales/Application/InvoiceBalanceService.php @@ -0,0 +1,49 @@ +allocations()->sum('amount'); + } + + public function creditedTotal(Invoice $invoice): int + { + return -(int) $invoice->creditNotes()->sum('total'); + } + + /** + * Recalculate an invoice exclusively from durable credits and payment + * allocations. Callers that change allocations must hold the invoice lock. + */ + public function recalculate(Invoice $invoice): void + { + $allocated = $this->allocatedTotal($invoice); + $credited = $this->creditedTotal($invoice); + $due = max(0, (int) $invoice->total - $allocated - $credited); + + $invoice->due_amount = $due; + $invoice->base_due_amount = (int) round($due * $invoice->exchange_rate); + + if ($due === 0) { + // Nothing left outstanding, so the document closes out on both + // axes at once. + $invoice->forceFill([ + 'status' => Invoice::STATUS_COMPLETED, + 'paid_status' => Invoice::STATUS_PAID, + ]); + $invoice->overdue = false; + } else { + $invoice->status = $invoice->getPreviousStatus(); + $invoice->paid_status = $allocated > 0 + ? Invoice::STATUS_PARTIALLY_PAID + : Invoice::STATUS_UNPAID; + } + + $invoice->save(); + } +} diff --git a/app/Domains/Sales/SalesServiceProvider.php b/app/Domains/Sales/SalesServiceProvider.php new file mode 100644 index 00000000..78a22723 --- /dev/null +++ b/app/Domains/Sales/SalesServiceProvider.php @@ -0,0 +1,66 @@ + [policy class, policy method]. + */ + private const POLICY_ABILITIES = [ + 'send invoice' => [InvoicePolicy::class, 'send'], + 'create credit note' => [CreditNotePolicy::class, 'create'], + 'send estimate' => [EstimatePolicy::class, 'send'], + 'delete multiple invoices' => [InvoicePolicy::class, 'deleteMultiple'], + 'delete multiple estimates' => [EstimatePolicy::class, 'deleteMultiple'], + 'delete multiple recurring invoices' => [RecurringInvoicePolicy::class, 'deleteMultiple'], + ]; + + public function register(): void + { + $this->app->bind(EstimatePdfDataProvider::class, EstimateService::class); + $this->app->bind(InvoicePdfDataProvider::class, InvoiceService::class); + $this->app->bind(DocumentExchangeRateRecorder::class, MoneyDocumentExchangeRateRecorder::class); + $this->app->bind(EstimateEmailSender::class, LaravelEstimateEmailSender::class); + $this->app->bind(InvoiceEmailSender::class, LaravelInvoiceEmailSender::class); + } + + public function boot(): void + { + $this->commands([ + CheckEstimateStatus::class, + CheckInvoiceStatus::class, + ]); + + Gate::policy(Estimate::class, EstimatePolicy::class); + Gate::policy(Invoice::class, InvoicePolicy::class); + Gate::policy(RecurringInvoice::class, RecurringInvoicePolicy::class); + + foreach (self::POLICY_ABILITIES as $ability => $handler) { + Gate::define($ability, $handler); + } + } +}