Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions app/Domains/Accounting/Services/CurrencyConversionService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<?php

namespace App\Domains\Accounting\Services;

use App\Domains\Accounting\Models\ExchangeRate;
use App\Support\Money;
use DomainException;

/**
* Converts a document amount into the organization's ledger currency.
*
* The ledger itself is single-currency. A foreign invoice must not be
* posted as if its amount were already in that currency.
*/
class CurrencyConversionService
{
/**
* @return array{amount: numeric-string, rate: numeric-string}
*/
public function convert(string $organizationId, string $from, string $to, string $amount, string $date): array
{
$rate = $this->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);
}
}
17 changes: 15 additions & 2 deletions app/Domains/Expenses/Services/ExpenseService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -31,6 +32,7 @@ class ExpenseService
public function __construct(
private LedgerService $ledgerService,
private LedgerQueryService $ledgerQuery,
private CurrencyConversionService $currencies,
) {}

/**
Expand Down Expand Up @@ -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;

Expand Down
71 changes: 52 additions & 19 deletions app/Domains/Invoicing/Services/InvoiceAccountingService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -33,6 +34,7 @@ public function __construct(
private LedgerService $ledgerService,
private LedgerQueryService $ledgerQuery,
private VatPeriodLockService $vatPeriodLocks,
private CurrencyConversionService $currencies,
) {}

/**
Expand All @@ -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)) {
Expand Down Expand Up @@ -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,
));

Expand All @@ -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([
Expand Down Expand Up @@ -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'),
],
));

Expand Down
2 changes: 1 addition & 1 deletion resources/js/Components/Sidebar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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 ? [
Expand Down
2 changes: 1 addition & 1 deletion resources/js/Pages/Invoices/Index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
])
Expand Down
Loading