From b0b7d40c73d631d5ae1b94f26ead0319b2cce4d9 Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski Date: Mon, 6 Apr 2026 21:07:50 +0200 Subject: [PATCH] Fix exchange rate parity across all document types - Fix exchange-rate service types to match actual backend response shapes (exchangeRate array, activeProvider success/error, used currencies as strings) - Add ExchangeRateConverter to payments, expenses, and recurring invoices - Set currency_id from customer currency in invoice/estimate selectCustomer() - Load globalStore.currencies in ExchangeRateConverter on mount - Pass driver/key/driver_config params to getSupportedCurrencies in provider modal - Fix OpenExchangeRateDriver validateConnection to use base=USD (free plan compat) - Fix checkActiveCurrencies SQLite whereJsonContains with array values - Remove broken currency/companyCurrency props from ExpenseCreateView, use stores - Show base currency equivalent in document line items and totals when exchange rate is active --- .../ExchangeRate/OpenExchangeRateDriver.php | 2 +- app/Services/ExchangeRateProviderService.php | 39 +++++++++++--- .../api/services/exchange-rate.service.ts | 54 ++++++++++++++----- .../features/company/estimates/store.ts | 3 ++ .../expenses/views/ExpenseCreateView.vue | 38 +++++++------ .../invoices/components/RecurringFields.vue | 13 +++++ .../features/company/invoices/store.ts | 4 +- .../payments/views/PaymentCreateView.vue | 41 ++++++++++++-- .../components/ExchangeRateProviderModal.vue | 13 +++-- .../shared/document-form/DocumentItemRow.vue | 22 ++++++++ .../shared/document-form/DocumentTotals.vue | 20 +++++++ .../document-form/ExchangeRateConverter.vue | 18 +++---- 12 files changed, 213 insertions(+), 54 deletions(-) diff --git a/app/Services/ExchangeRate/OpenExchangeRateDriver.php b/app/Services/ExchangeRate/OpenExchangeRateDriver.php index 82f0e6ce..5239685f 100644 --- a/app/Services/ExchangeRate/OpenExchangeRateDriver.php +++ b/app/Services/ExchangeRate/OpenExchangeRateDriver.php @@ -36,7 +36,7 @@ class OpenExchangeRateDriver extends ExchangeRateDriver public function validateConnection(): array { - $url = "{$this->baseUrl}/latest.json?app_id={$this->apiKey}&base=INR&symbols=USD"; + $url = "{$this->baseUrl}/latest.json?app_id={$this->apiKey}&base=USD&symbols=EUR"; $response = Http::get($url)->json(); if (array_key_exists('error', $response)) { diff --git a/app/Services/ExchangeRateProviderService.php b/app/Services/ExchangeRateProviderService.php index 725386db..b1caf77c 100644 --- a/app/Services/ExchangeRateProviderService.php +++ b/app/Services/ExchangeRateProviderService.php @@ -25,17 +25,42 @@ class ExchangeRateProviderService public function checkActiveCurrencies($request) { - return ExchangeRateProvider::whereJsonContains('currencies', $request->currencies) - ->where('active', true) - ->get(); + $currencies = $request->currencies; + + if (empty($currencies)) { + return collect(); + } + + $query = ExchangeRateProvider::where('active', true); + + foreach ($currencies as $currency) { + $query->orWhere(function ($q) use ($currency) { + $q->where('active', true) + ->whereJsonContains('currencies', $currency); + }); + } + + return $query->get(); } public function checkUpdateActiveCurrencies(ExchangeRateProvider $provider, $request) { - return ExchangeRateProvider::where('active', $request->active) - ->where('id', '<>', $provider->id) - ->whereJsonContains('currencies', $request->currencies) - ->get(); + $currencies = $request->currencies; + + if (empty($currencies)) { + return collect(); + } + + $query = ExchangeRateProvider::where('id', '<>', $provider->id) + ->where('active', true); + + $query->where(function ($q) use ($currencies) { + foreach ($currencies as $currency) { + $q->orWhereJsonContains('currencies', $currency); + } + }); + + return $query->get(); } public function checkProviderStatus($request) diff --git a/resources/scripts-v2/api/services/exchange-rate.service.ts b/resources/scripts-v2/api/services/exchange-rate.service.ts index af00e190..bb8181ea 100644 --- a/resources/scripts-v2/api/services/exchange-rate.service.ts +++ b/resources/scripts-v2/api/services/exchange-rate.service.ts @@ -8,15 +8,16 @@ export interface CreateExchangeRateProviderPayload { key: string active?: boolean currencies?: string[] + driver_config?: Record } +// Normalized response types (what callers receive) export interface ExchangeRateResponse { - exchange_rate: number + exchangeRate: number | null } export interface ActiveProviderResponse { - has_active_provider: boolean - exchange_rate: number | null + hasActiveProvider: boolean } export interface SupportedCurrenciesResponse { @@ -24,7 +25,8 @@ export interface SupportedCurrenciesResponse { } export interface UsedCurrenciesResponse { - activeUsedCurrencies: Currency[] + activeUsedCurrencies: string[] + allUsedCurrencies: string[] } export interface BulkCurrenciesResponse { @@ -38,8 +40,23 @@ export interface BulkUpdatePayload { }> } +export interface ConfigOption { + key: string + value: string +} + export interface ConfigDriversResponse { - exchange_rate_drivers: string[] + exchange_rate_drivers: ConfigOption[] +} + +export interface ConfigServersResponse { + currency_converter_servers: ConfigOption[] +} + +export interface SupportedCurrenciesParams { + driver: string + key: string + driver_config?: Record } export const exchangeRateService = { @@ -73,24 +90,35 @@ export const exchangeRateService = { }, // Exchange Rates + // Backend returns { exchangeRate: [number] } or { error: string } async getRate(currencyId: number): Promise { const { data } = await client.get(`${API.CURRENCIES}/${currencyId}/exchange-rate`) - return data + const raw = data as Record + + if (raw.exchangeRate && Array.isArray(raw.exchangeRate)) { + return { exchangeRate: Number(raw.exchangeRate[0]) ?? null } + } + + return { exchangeRate: null } }, + // Backend returns { success: true, message: "provider_active" } or { error: "no_active_provider" } async getActiveProvider(currencyId: number): Promise { const { data } = await client.get(`${API.CURRENCIES}/${currencyId}/active-provider`) - return data + const raw = data as Record + + return { hasActiveProvider: raw.success === true } }, // Currency lists - async getSupportedCurrencies(): Promise { - const { data } = await client.get(API.SUPPORTED_CURRENCIES) + async getSupportedCurrencies(params: SupportedCurrenciesParams): Promise { + const { data } = await client.get(API.SUPPORTED_CURRENCIES, { params }) return data }, - async getUsedCurrencies(): Promise { - const { data } = await client.get(API.USED_CURRENCIES) + // Backend returns { activeUsedCurrencies: string[], allUsedCurrencies: string[] } + async getUsedCurrencies(params?: { provider_id?: number }): Promise { + const { data } = await client.get(API.USED_CURRENCIES, { params }) return data }, @@ -105,12 +133,14 @@ export const exchangeRateService = { }, // Config + // Backend returns { exchange_rate_drivers: Array<{ key, value }> } async getDrivers(): Promise { const { data } = await client.get(API.CONFIG, { params: { key: 'exchange_rate_drivers' } }) return data }, - async getCurrencyConverterServers(): Promise> { + // Backend returns { currency_converter_servers: Array<{ key, value }> } + async getCurrencyConverterServers(): Promise { const { data } = await client.get(API.CONFIG, { params: { key: 'currency_converter_servers' } }) return data }, diff --git a/resources/scripts-v2/features/company/estimates/store.ts b/resources/scripts-v2/features/company/estimates/store.ts index 8f6c64b6..d090d425 100644 --- a/resources/scripts-v2/features/company/estimates/store.ts +++ b/resources/scripts-v2/features/company/estimates/store.ts @@ -430,6 +430,9 @@ export const useEstimateStore = defineStore('estimate', { const response = await customerService.get(id) this.newEstimate.customer = response.data as unknown as Customer this.newEstimate.customer_id = response.data.id + if (response.data.currency) { + this.newEstimate.currency_id = (response.data.currency as { id: number }).id + } return response }, diff --git a/resources/scripts-v2/features/company/expenses/views/ExpenseCreateView.vue b/resources/scripts-v2/features/company/expenses/views/ExpenseCreateView.vue index 0463e391..4b6d28a4 100644 --- a/resources/scripts-v2/features/company/expenses/views/ExpenseCreateView.vue +++ b/resources/scripts-v2/features/company/expenses/views/ExpenseCreateView.vue @@ -131,7 +131,7 @@ label="name" track-by="name" :content-loading="isFetchingInitialData" - :options="currencies" + :options="globalStore.currencies" searchable :can-deselect="false" :placeholder="$t('customers.select_currency')" @@ -140,6 +140,16 @@ /> + + + (), { - currencies: () => [], - companyCurrency: null, -}) - const route = useRoute() const router = useRouter() const { t } = useI18n() const expenseStore = useExpenseStore() +const globalStore = useGlobalStore() +const companyStore = useCompanyStore() const isSaving = ref(false) const isFetchingInitialData = ref(false) @@ -291,7 +296,7 @@ function onFileInputRemove(): void { } function onCurrencyChange(currencyId: number): void { - const found = props.currencies.find((c) => c.id === currencyId) + const found = globalStore.currencies.find((c: Currency) => c.id === currencyId) expenseStore.currentExpense.selectedCurrency = found ?? null } @@ -314,9 +319,12 @@ async function searchCustomer(search: string): Promise { } async function loadData(): Promise { - if (!isEdit.value && props.companyCurrency) { - expenseStore.currentExpense.currency_id = props.companyCurrency.id - expenseStore.currentExpense.selectedCurrency = props.companyCurrency + await globalStore.fetchCurrencies() + + const companyCurrency = companyStore.selectedCompanyCurrency + if (!isEdit.value && companyCurrency) { + expenseStore.currentExpense.currency_id = companyCurrency.id + expenseStore.currentExpense.selectedCurrency = companyCurrency } isFetchingInitialData.value = true diff --git a/resources/scripts-v2/features/company/invoices/components/RecurringFields.vue b/resources/scripts-v2/features/company/invoices/components/RecurringFields.vue index 01f8a2be..85d885a4 100644 --- a/resources/scripts-v2/features/company/invoices/components/RecurringFields.vue +++ b/resources/scripts-v2/features/company/invoices/components/RecurringFields.vue @@ -125,6 +125,16 @@ label="key" /> + + + @@ -134,6 +144,8 @@ import { computed, onMounted, reactive, ref, watch } from 'vue' import { useI18n } from 'vue-i18n' import { useDebounceFn } from '@vueuse/core' import { useRecurringInvoiceStore } from '@v2/features/company/recurring-invoices/store' +import { useInvoiceStore } from '../store' +import { ExchangeRateConverter } from '../../../shared/document-form' import type { FrequencyOption } from '@v2/features/company/recurring-invoices/store' interface Props { @@ -147,6 +159,7 @@ const props = withDefaults(defineProps(), { }) const recurringInvoiceStore = useRecurringInvoiceStore() +const invoiceStore = useInvoiceStore() const { t } = useI18n() const isLoadingNextDate = ref(false) diff --git a/resources/scripts-v2/features/company/invoices/store.ts b/resources/scripts-v2/features/company/invoices/store.ts index 90169978..2c64daa0 100644 --- a/resources/scripts-v2/features/company/invoices/store.ts +++ b/resources/scripts-v2/features/company/invoices/store.ts @@ -404,13 +404,15 @@ export const useInvoiceStore = defineStore('invoice', { }, async selectCustomer(id: number): Promise { - // This would use customerService in a full implementation const { customerService } = await import( '../../../api/services/customer.service' ) const response = await customerService.get(id) this.newInvoice.customer = response.data as unknown as Customer this.newInvoice.customer_id = response.data.id + if (response.data.currency) { + this.newInvoice.currency_id = (response.data.currency as { id: number }).id + } return response }, diff --git a/resources/scripts-v2/features/company/payments/views/PaymentCreateView.vue b/resources/scripts-v2/features/company/payments/views/PaymentCreateView.vue index 7e225613..1bf328e6 100644 --- a/resources/scripts-v2/features/company/payments/views/PaymentCreateView.vue +++ b/resources/scripts-v2/features/company/payments/views/PaymentCreateView.vue @@ -116,6 +116,16 @@ + + + (false) const isLoadingInvoices = ref(false) @@ -225,9 +239,11 @@ if (route.query.customer) { paymentStore.currentPayment.customer_id = Number(route.query.customer) } -paymentStore.fetchPaymentInitialData(isEdit.value, { - id: isEdit.value ? (route.params.id as string) : undefined, -}) +paymentStore.fetchPaymentInitialData( + isEdit.value, + { id: isEdit.value ? (route.params.id as string) : undefined }, + companyStore.selectedCompanyCurrency ?? undefined, +) // Create-from-invoice: pre-select the invoice and its customer if (route.params.id && !isEdit.value) { @@ -262,8 +278,23 @@ async function onCustomerChange(customerId: number): Promise { isLoadingInvoices.value = true try { - const response = await invoiceService.list(params as never) - invoiceList.value = [...(response.data as unknown as Invoice[])] + const [invoiceResponse, customerResponse] = await Promise.all([ + invoiceService.list(params as never), + customerService.get(customerId), + ]) + + invoiceList.value = [...(invoiceResponse.data as unknown as Invoice[])] + + // Set currency from customer + if (customerResponse.data) { + const customer = customerResponse.data + paymentStore.currentPayment.customer = customer + paymentStore.currentPayment.selectedCustomer = customer + if (customer.currency) { + paymentStore.currentPayment.currency = customer.currency + paymentStore.currentPayment.currency_id = customer.currency.id + } + } if (paymentStore.currentPayment.invoice_id) { selectedInvoice.value = diff --git a/resources/scripts-v2/features/company/settings/components/ExchangeRateProviderModal.vue b/resources/scripts-v2/features/company/settings/components/ExchangeRateProviderModal.vue index c8f67b4c..d3f69f0d 100644 --- a/resources/scripts-v2/features/company/settings/components/ExchangeRateProviderModal.vue +++ b/resources/scripts-v2/features/company/settings/components/ExchangeRateProviderModal.vue @@ -212,11 +212,18 @@ async function fetchCurrencies(): Promise { isFetchingCurrencies.value = true try { - const params: Record = { driver, key } + const driverConfig: Record = {} if (currencyConverter.value.type) { - params.type = currencyConverter.value.type + driverConfig.type = currencyConverter.value.type } - const res = await exchangeRateService.getSupportedCurrencies() + if (currencyConverter.value.url) { + driverConfig.url = currencyConverter.value.url + } + const res = await exchangeRateService.getSupportedCurrencies({ + driver, + key, + driver_config: Object.keys(driverConfig).length ? driverConfig : undefined, + }) supportedCurrencies.value = res.supportedCurrencies ?? [] } finally { isFetchingCurrencies.value = false diff --git a/resources/scripts-v2/features/shared/document-form/DocumentItemRow.vue b/resources/scripts-v2/features/shared/document-form/DocumentItemRow.vue index 4ff9cf5f..23e618a7 100644 --- a/resources/scripts-v2/features/shared/document-form/DocumentItemRow.vue +++ b/resources/scripts-v2/features/shared/document-form/DocumentItemRow.vue @@ -128,6 +128,15 @@ :amount="total" :currency="selectedCurrency" /> + + +
(), { const emit = defineEmits() const { t } = useI18n() +const companyStore = useCompanyStore() const formData = computed(() => { return props.store[props.storeProp] as DocumentFormData @@ -285,6 +296,17 @@ const totalSimpleTax = computed(() => { const totalTax = computed(() => totalSimpleTax.value) +const companyCurrency = computed(() => companyStore.selectedCompanyCurrency) + +const showBaseCurrencyEquivalent = computed(() => { + return !!(formData.value.exchange_rate && (props.store as Record).showExchangeRate) +}) + +const baseCurrencyTotal = computed(() => { + if (!formData.value.exchange_rate) return 0 + return Math.round(total.value * Number(formData.value.exchange_rate)) +}) + const rules = { name: { required: helpers.withMessage(t('validation.required'), required), diff --git a/resources/scripts-v2/features/shared/document-form/DocumentTotals.vue b/resources/scripts-v2/features/shared/document-form/DocumentTotals.vue index 5c56e429..67a942c2 100644 --- a/resources/scripts-v2/features/shared/document-form/DocumentTotals.vue +++ b/resources/scripts-v2/features/shared/document-form/DocumentTotals.vue @@ -217,12 +217,20 @@
+ + +
+ +