From 4c80cd905d16e462c5184d1fce3f880dd43350bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bernhard=20Rosenkr=C3=A4nzer?= Date: Sun, 27 Sep 2026 20:04:27 +0200 Subject: [PATCH] fix: book foreign-currency invoices in the organization currency A EUR invoice kept its currency on the document, but posting copied the same number into the single-currency ledger and the UI labeled it CHF. Convert at the exchange rate on or before the document date, and refuse to post when no rate exists. --- .../Services/CurrencyConversionService.php | 80 +++++++++++++++++++ .../Expenses/Services/ExpenseService.php | 17 +++- .../Services/InvoiceAccountingService.php | 71 +++++++++++----- resources/js/Components/Sidebar.vue | 2 +- resources/js/Pages/Invoices/Index.vue | 2 +- resources/js/Pages/Invoices/Show.vue | 45 ++++++----- routes/web/accounting.php | 13 ++- tests/Feature/Invoicing/InvoiceFlowTest.php | 36 +++++++++ 8 files changed, 218 insertions(+), 48 deletions(-) create mode 100644 app/Domains/Accounting/Services/CurrencyConversionService.php diff --git a/app/Domains/Accounting/Services/CurrencyConversionService.php b/app/Domains/Accounting/Services/CurrencyConversionService.php new file mode 100644 index 00000000..102eb24a --- /dev/null +++ b/app/Domains/Accounting/Services/CurrencyConversionService.php @@ -0,0 +1,80 @@ +rate($organizationId, $from, $to, $date); + + return [ + 'amount' => Money::round(bcmul($amount, $rate, 8)), + 'rate' => $rate, + ]; + } + + /** + * Units of $to per 1 unit of $from, using the newest rate on or before $date. + * + * @return numeric-string + */ + public function rate(string $organizationId, string $from, string $to, string $date): string + { + $from = strtoupper($from); + $to = strtoupper($to); + + if ($from === $to) { + return '1.00000000'; + } + + $direct = $this->lookup($organizationId, $from, $to, $date); + if ($direct !== null) { + return $this->normalizeRate((string) $direct); + } + + $inverse = $this->lookup($organizationId, $to, $from, $date); + if ($inverse !== null && bccomp((string) $inverse, '0', 8) !== 0) { + return bcdiv('1', (string) $inverse, 8); + } + + throw new DomainException( + "Cannot book {$from} into the {$to} ledger without an exchange rate on or before {$date}. Add one under Accounting → Exchange rates (fetch ECB rates, or enter EUR → {$to})." + ); + } + + private function lookup(string $organizationId, string $from, string $to, string $date): ?string + { + $rate = ExchangeRate::query() + ->where('organization_id', $organizationId) + ->where('currency_from', $from) + ->where('currency_to', $to) + ->whereDate('date', '<=', $date) + ->orderByDesc('date') + ->orderByDesc('id') + ->value('rate'); + + return $rate === null ? null : (string) $rate; + } + + /** + * @return numeric-string + */ + private function normalizeRate(string $rate): string + { + return bcadd($rate, '0', 8); + } +} diff --git a/app/Domains/Expenses/Services/ExpenseService.php b/app/Domains/Expenses/Services/ExpenseService.php index 414e21b9..ed3caf75 100644 --- a/app/Domains/Expenses/Services/ExpenseService.php +++ b/app/Domains/Expenses/Services/ExpenseService.php @@ -8,6 +8,7 @@ use App\Domains\Accounting\Enums\VatEntryType; use App\Domains\Accounting\Models\JournalEntry; use App\Domains\Accounting\Models\VatEntry; +use App\Domains\Accounting\Services\CurrencyConversionService; use App\Domains\Accounting\Services\LedgerQueryService; use App\Domains\Accounting\Services\LedgerService; use App\Domains\Expenses\DTOs\RecordExpensePaymentData; @@ -31,6 +32,7 @@ class ExpenseService public function __construct( private LedgerService $ledgerService, private LedgerQueryService $ledgerQuery, + private CurrencyConversionService $currencies, ) {} /** @@ -59,8 +61,19 @@ public function postToLedger(Expense $expense, RecordExpensePaymentData $data, b $expenseAccount = $this->ledgerQuery->resolveAccount($orgId, $data->expenseAccountCode); $bankAccount = $this->ledgerQuery->resolveAccount($orgId, $bankAccountCode); - $netAmount = $data->amount; - $vatAmount = (string) ($expense->vat_amount ?? '0'); + $expense->loadMissing('organization'); + $ledgerCurrency = strtoupper((string) ($expense->organization->currency ?: 'CHF')); + $documentCurrency = strtoupper((string) ($expense->currency ?: $ledgerCurrency)); + $rate = $this->currencies->rate( + $orgId, + $documentCurrency, + $ledgerCurrency, + $expense->date->toDateString(), + ); + $toLedger = fn (string $amount): string => Money::round(bcmul($amount, $rate, 8)); + + $netAmount = $toLedger((string) $data->amount); + $vatAmount = $toLedger((string) ($expense->vat_amount ?? '0')); $hasVat = $expense->vat_rate_id && Money::isPositive($vatAmount); $grossAmount = $hasVat ? Money::add($netAmount, $vatAmount) : $netAmount; diff --git a/app/Domains/Invoicing/Services/InvoiceAccountingService.php b/app/Domains/Invoicing/Services/InvoiceAccountingService.php index c7a90d73..30847c7d 100644 --- a/app/Domains/Invoicing/Services/InvoiceAccountingService.php +++ b/app/Domains/Invoicing/Services/InvoiceAccountingService.php @@ -7,6 +7,7 @@ use App\Domains\Accounting\DTOs\JournalLineData; use App\Domains\Accounting\Enums\VatEntryType; use App\Domains\Accounting\Models\VatEntry; +use App\Domains\Accounting\Services\CurrencyConversionService; use App\Domains\Accounting\Services\LedgerQueryService; use App\Domains\Accounting\Services\LedgerService; use App\Domains\Accounting\Services\VatPeriodLockService; @@ -33,6 +34,7 @@ public function __construct( private LedgerService $ledgerService, private LedgerQueryService $ledgerQuery, private VatPeriodLockService $vatPeriodLocks, + private CurrencyConversionService $currencies, ) {} /** @@ -54,33 +56,33 @@ public function postToLedger(Invoice $invoice): Invoice { return DB::transaction(function () use ($invoice) { $orgId = $invoice->organization_id; - $invoice->load('lines.vatRate'); + $invoice->load('lines.vatRate', 'organization'); $isCreditNote = $invoice->type === InvoiceType::CreditNote; + $ledgerCurrency = strtoupper((string) ($invoice->organization->currency ?: 'CHF')); + $documentCurrency = strtoupper((string) ($invoice->currency ?: $ledgerCurrency)); + $rate = $this->currencies->rate( + $orgId, + $documentCurrency, + $ledgerCurrency, + $invoice->issue_date->toDateString(), + ); + $toLedger = fn (string $amount): string => Money::round(bcmul($amount, $rate, 8)); $ar = $this->ledgerQuery->resolveAccount($orgId, AccountCode::ACCOUNTS_RECEIVABLE); $revenue = $this->ledgerQuery->resolveAccount($orgId, AccountCode::REVENUE); $lines = []; - // For credit notes, amounts are negative — use absolute values and swap debit/credit - $invoiceTotal = $isCreditNote - ? Money::absoluteAmount((string) $invoice->total) - : (string) $invoice->total; - - // AR line: Debit for invoice, Credit for credit note - $lines[] = new JournalLineData( - accountId: (string) $ar->id, - debit: $isCreditNote ? '0' : $invoiceTotal, - credit: $isCreditNote ? $invoiceTotal : '0', - description: 'Accounts Receivable', - ); - - // Group invoice lines by VAT rate to create separate revenue + VAT entries + // Group invoice lines by VAT rate to create separate revenue + VAT entries. + // The receivable is the sum of those converted lines so rounding cannot + // leave the entry unbalanced. $groupedByVat = $invoice->lines->groupBy(fn ($line) => $line->vat_rate_id ?? 'none'); foreach ($groupedByVat as $vatRateId => $invoiceLines) { ['netAmount' => $netAmount, 'vatAmount' => $vatAmount] = $this->calculateGroupTotals($invoiceLines); + $netAmount = $toLedger($netAmount); + $vatAmount = $toLedger($vatAmount); // Revenue line: Credit for invoice, Debit for credit note if (Money::isPositive($netAmount)) { @@ -108,11 +110,25 @@ public function postToLedger(Invoice $invoice): Invoice } } + $invoiceTotal = '0'; + foreach (array_slice($lines, 0) as $line) { + $invoiceTotal = Money::add($invoiceTotal, $isCreditNote ? $line->debit : $line->credit); + } + array_unshift($lines, new JournalLineData( + accountId: (string) $ar->id, + debit: $isCreditNote ? '0' : $invoiceTotal, + credit: $isCreditNote ? $invoiceTotal : '0', + description: 'Accounts Receivable', + )); + $docType = $isCreditNote ? 'Credit Note' : 'Invoice'; + $fxNote = $documentCurrency === $ledgerCurrency + ? '' + : sprintf(' [%s %s @ %s %s]', $invoice->total, $documentCurrency, $rate, $ledgerCurrency); $journalEntry = $this->ledgerService->postEntry($orgId, new JournalEntryData( date: $invoice->issue_date->toDateString(), reference: $invoice->number, - description: "{$docType} {$invoice->number} — ".($invoice->customer->name ?? 'N/A'), + description: "{$docType} {$invoice->number} — ".($invoice->customer->name ?? 'N/A').$fxNote, lines: $lines, )); @@ -123,6 +139,8 @@ public function postToLedger(Invoice $invoice): Invoice } ['netAmount' => $netAmount, 'vatAmount' => $vatAmount] = $this->calculateGroupTotals($invoiceLines); + $netAmount = $toLedger($netAmount); + $vatAmount = $toLedger($vatAmount); if (Money::isPositive($vatAmount)) { VatEntry::create([ @@ -198,15 +216,30 @@ public function recordPayment(Invoice $invoice, RecordPaymentData $data): Invoic $bankAccount = $this->ledgerQuery->resolveAccount($orgId, $bankAccountCode); $accountsReceivable = $this->ledgerQuery->resolveAccount($orgId, AccountCode::ACCOUNTS_RECEIVABLE); + $invoice->loadMissing('organization'); + $ledgerCurrency = strtoupper((string) ($invoice->organization->currency ?: 'CHF')); + $documentCurrency = strtoupper((string) ($invoice->currency ?: $ledgerCurrency)); + $converted = $this->currencies->convert( + $orgId, + $documentCurrency, + $ledgerCurrency, + (string) $data->amount, + $invoice->issue_date->toDateString(), + ); + $ledgerAmount = $converted['amount']; + $fxNote = $documentCurrency === $ledgerCurrency + ? '' + : sprintf(' [%s %s @ %s]', $data->amount, $documentCurrency, $converted['rate']); + $paymentRef = $data->reference ?? 'PAY-'.$invoice->number.'-'.($invoice->payments()->count() + 1); $journalEntry = $this->ledgerService->postEntry($orgId, new JournalEntryData( date: $data->paymentDate, reference: $paymentRef, - description: "Payment received for {$invoice->number}", + description: "Payment received for {$invoice->number}{$fxNote}", lines: [ - new JournalLineData(accountId: (string) $bankAccount->id, debit: $data->amount, credit: '0', description: 'Bank deposit'), - new JournalLineData(accountId: (string) $accountsReceivable->id, debit: '0', credit: $data->amount, description: 'Clear receivable'), + new JournalLineData(accountId: (string) $bankAccount->id, debit: $ledgerAmount, credit: '0', description: 'Bank deposit'), + new JournalLineData(accountId: (string) $accountsReceivable->id, debit: '0', credit: $ledgerAmount, description: 'Clear receivable'), ], )); diff --git a/resources/js/Components/Sidebar.vue b/resources/js/Components/Sidebar.vue index 29048d1b..50c0dc78 100644 --- a/resources/js/Components/Sidebar.vue +++ b/resources/js/Components/Sidebar.vue @@ -177,7 +177,7 @@ const navigation = computed(() => { ...(features.value.consolidation && accountingRoutes.value.consolidation ? [ { key: 'consolidation', href: '/accounting/consolidation' }, ] : []), - ...(features.value.multi_currency && accountingRoutes.value.exchangeRates ? [ + ...(accountingRoutes.value.exchangeRates ? [ { key: 'exchange_rates', href: '/accounting/exchange-rates' }, ] : []), ...(features.value.assets ? [ diff --git a/resources/js/Pages/Invoices/Index.vue b/resources/js/Pages/Invoices/Index.vue index d1b160ba..1c4ec119 100644 --- a/resources/js/Pages/Invoices/Index.vue +++ b/resources/js/Pages/Invoices/Index.vue @@ -80,7 +80,7 @@ const columns = computed(() => [ { key: 'customer', label: t('client'), format: (v) => v?.name ?? '—' }, { key: 'issue_date', label: t('date'), format: (v) => formatDate(v), sortable: true }, { key: 'due_date', label: t('due'), format: (v) => formatDate(v), sortable: true }, - { key: 'total', label: t('total'), class: 'text-right', format: (v) => formatCurrency(v), sortable: true }, + { key: 'total', label: t('total'), class: 'text-right', format: (v, row) => formatCurrency(v, row.currency || 'CHF'), sortable: true }, { key: 'status', label: t('status'), sortable: true }, { key: 'actions', label: '', class: 'text-right w-auto' }, ]) diff --git a/resources/js/Pages/Invoices/Show.vue b/resources/js/Pages/Invoices/Show.vue index 22bded1a..de7c4fc3 100644 --- a/resources/js/Pages/Invoices/Show.vue +++ b/resources/js/Pages/Invoices/Show.vue @@ -1,5 +1,5 @@