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
This commit is contained in:
Darko Gjorgjijoski
2026-04-06 21:07:50 +02:00
parent e64529468c
commit b0b7d40c73
12 changed files with 213 additions and 54 deletions

View File

@@ -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)) {

View File

@@ -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)

View File

@@ -8,15 +8,16 @@ export interface CreateExchangeRateProviderPayload {
key: string
active?: boolean
currencies?: string[]
driver_config?: Record<string, string>
}
// 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<string, string>
}
export const exchangeRateService = {
@@ -73,24 +90,35 @@ export const exchangeRateService = {
},
// Exchange Rates
// Backend returns { exchangeRate: [number] } or { error: string }
async getRate(currencyId: number): Promise<ExchangeRateResponse> {
const { data } = await client.get(`${API.CURRENCIES}/${currencyId}/exchange-rate`)
return data
const raw = data as Record<string, unknown>
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<ActiveProviderResponse> {
const { data } = await client.get(`${API.CURRENCIES}/${currencyId}/active-provider`)
return data
const raw = data as Record<string, unknown>
return { hasActiveProvider: raw.success === true }
},
// Currency lists
async getSupportedCurrencies(): Promise<SupportedCurrenciesResponse> {
const { data } = await client.get(API.SUPPORTED_CURRENCIES)
async getSupportedCurrencies(params: SupportedCurrenciesParams): Promise<SupportedCurrenciesResponse> {
const { data } = await client.get(API.SUPPORTED_CURRENCIES, { params })
return data
},
async getUsedCurrencies(): Promise<UsedCurrenciesResponse> {
const { data } = await client.get(API.USED_CURRENCIES)
// Backend returns { activeUsedCurrencies: string[], allUsedCurrencies: string[] }
async getUsedCurrencies(params?: { provider_id?: number }): Promise<UsedCurrenciesResponse> {
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<ConfigDriversResponse> {
const { data } = await client.get(API.CONFIG, { params: { key: 'exchange_rate_drivers' } })
return data
},
async getCurrencyConverterServers(): Promise<Record<string, unknown>> {
// Backend returns { currency_converter_servers: Array<{ key, value }> }
async getCurrencyConverterServers(): Promise<ConfigServersResponse> {
const { data } = await client.get(API.CONFIG, { params: { key: 'currency_converter_servers' } })
return data
},

View File

@@ -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
},

View File

@@ -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 @@
/>
</BaseInputGroup>
<!-- Exchange Rate -->
<ExchangeRateConverter
:store="expenseStore"
store-prop="currentExpense"
:v="{ exchange_rate: { $error: false, $errors: [], $touch: () => {} } }"
:is-loading="isFetchingInitialData"
:is-edit="isEdit"
:customer-currency="expenseStore.currentExpense.currency_id"
/>
<!-- Customer -->
<BaseInputGroup
:content-loading="isFetchingInitialData"
@@ -237,24 +247,19 @@ import { ref, computed, onBeforeUnmount } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useExpenseStore } from '../store'
import { useGlobalStore } from '../../../../stores/global.store'
import { useCompanyStore } from '../../../../stores/company.store'
import { ExchangeRateConverter } from '../../../shared/document-form'
import type { ExpenseCategory } from '../../../../types/domain/expense'
import type { Customer } from '../../../../types/domain/customer'
import type { Currency } from '../../../../types/domain/currency'
interface Props {
currencies?: Currency[]
companyCurrency?: Currency | null
}
const props = withDefaults(defineProps<Props>(), {
currencies: () => [],
companyCurrency: null,
})
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const expenseStore = useExpenseStore()
const globalStore = useGlobalStore()
const companyStore = useCompanyStore()
const isSaving = ref<boolean>(false)
const isFetchingInitialData = ref<boolean>(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<Customer[]> {
}
async function loadData(): Promise<void> {
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

View File

@@ -125,6 +125,16 @@
label="key"
/>
</BaseInputGroup>
<!-- Exchange Rate -->
<ExchangeRateConverter
:store="invoiceStore"
store-prop="newInvoice"
:v="{ exchange_rate: { $error: false, $errors: [], $touch: () => {} } }"
:is-loading="isLoading"
:is-edit="isEdit"
:customer-currency="invoiceStore.newInvoice.currency_id"
/>
</BaseInputGrid>
</div>
</template>
@@ -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<Props>(), {
})
const recurringInvoiceStore = useRecurringInvoiceStore()
const invoiceStore = useInvoiceStore()
const { t } = useI18n()
const isLoadingNextDate = ref<boolean>(false)

View File

@@ -404,13 +404,15 @@ export const useInvoiceStore = defineStore('invoice', {
},
async selectCustomer(id: number): Promise<unknown> {
// 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
},

View File

@@ -116,6 +116,16 @@
</div>
</BaseInputGroup>
<!-- Exchange Rate -->
<ExchangeRateConverter
:store="paymentStore"
store-prop="currentPayment"
:v="{ exchange_rate: { $error: false, $errors: [], $touch: () => {} } }"
:is-loading="isLoadingContent"
:is-edit="isEdit"
:customer-currency="paymentStore.currentPayment.currency_id"
/>
<!-- Payment Mode -->
<BaseInputGroup
:content-loading="isLoadingContent"
@@ -180,13 +190,17 @@ import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import cloneDeep from 'lodash/cloneDeep'
import { usePaymentStore } from '../store'
import { useCompanyStore } from '../../../../stores/company.store'
import { invoiceService } from '../../../../api/services/invoice.service'
import { customerService } from '../../../../api/services/customer.service'
import { ExchangeRateConverter } from '../../../shared/document-form'
import type { Invoice } from '../../../../types/domain/invoice'
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const paymentStore = usePaymentStore()
const companyStore = useCompanyStore()
const isSaving = ref<boolean>(false)
const isLoadingInvoices = ref<boolean>(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<void> {
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 =

View File

@@ -212,11 +212,18 @@ async function fetchCurrencies(): Promise<void> {
isFetchingCurrencies.value = true
try {
const params: Record<string, string> = { driver, key }
const driverConfig: Record<string, string> = {}
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

View File

@@ -128,6 +128,15 @@
:amount="total"
:currency="selectedCurrency"
/>
<span
v-if="showBaseCurrencyEquivalent"
class="block text-xs text-muted mt-1"
>
<BaseFormatMoney
:amount="baseCurrencyTotal"
:currency="companyCurrency"
/>
</span>
</span>
<div class="flex items-center justify-center w-6 h-10 mx-2">
<BaseIcon
@@ -184,6 +193,7 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { required, between, maxLength, helpers, minValue } from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
import { useCompanyStore } from '../../../stores/company.store'
import DocumentItemRowTax from './DocumentItemRowTax.vue'
import DragIcon from '@v2/components/icons/DragIcon.vue'
import { generateClientId } from '../../../utils'
@@ -221,6 +231,7 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<Emits>()
const { t } = useI18n()
const companyStore = useCompanyStore()
const formData = computed<DocumentFormData>(() => {
return props.store[props.storeProp] as DocumentFormData
@@ -285,6 +296,17 @@ const totalSimpleTax = computed<number>(() => {
const totalTax = computed<number>(() => totalSimpleTax.value)
const companyCurrency = computed(() => companyStore.selectedCompanyCurrency)
const showBaseCurrencyEquivalent = computed<boolean>(() => {
return !!(formData.value.exchange_rate && (props.store as Record<string, unknown>).showExchangeRate)
})
const baseCurrencyTotal = computed<number>(() => {
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),

View File

@@ -217,12 +217,20 @@
<BaseFormatMoney :amount="store.getTotal" :currency="defaultCurrency" />
</label>
</div>
<!-- Base currency equivalent -->
<div v-if="showBaseCurrencyEquivalent" class="flex items-center justify-end w-full mt-1">
<label class="text-xs text-muted">
<BaseFormatMoney :amount="baseCurrencyGrandTotal" :currency="companyCurrency" />
</label>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import TaxSelectPopup from './TaxSelectPopup.vue'
import { useCompanyStore } from '../../../stores/company.store'
import { generateClientId } from '../../../utils'
import type { Currency } from '../../../types/domain/currency'
import type { TaxType } from '../../../types/domain/tax'
@@ -245,6 +253,7 @@ const props = withDefaults(defineProps<Props>(), {
isLoading: false,
})
const companyStore = useCompanyStore()
const taxModal = ref<HTMLElement | null>(null)
const formData = computed<DocumentFormData>(() => {
@@ -263,6 +272,17 @@ const defaultCurrencySymbol = computed<string>(() => {
return (curr?.symbol as string) ?? '$'
})
const companyCurrency = computed(() => companyStore.selectedCompanyCurrency)
const showBaseCurrencyEquivalent = computed<boolean>(() => {
return !!(formData.value.exchange_rate && (props.store as Record<string, unknown>).showExchangeRate)
})
const baseCurrencyGrandTotal = computed<number>(() => {
if (!formData.value.exchange_rate) return 0
return Math.round(props.store.getTotal * Number(formData.value.exchange_rate))
})
watch(
() => formData.value.items,
() => setDiscount(),

View File

@@ -49,7 +49,7 @@
</template>
<script setup lang="ts">
import { watch, computed, ref, onBeforeUnmount } from 'vue'
import { watch, computed, ref, onMounted, onBeforeUnmount } from 'vue'
import { exchangeRateService } from '../../../api/services/exchange-rate.service'
import { useCompanyStore } from '../../../stores/company.store'
import { useGlobalStore } from '../../../stores/global.store'
@@ -79,6 +79,10 @@ const globalStore = useGlobalStore()
const hasActiveProvider = ref<boolean>(false)
const isFetching = ref<boolean>(false)
onMounted(() => {
globalStore.fetchCurrencies()
})
const formData = computed<DocumentFormData>(() => {
return props.store[props.storeProp] as DocumentFormData
})
@@ -139,12 +143,10 @@ function checkForActiveProvider(): void {
exchangeRateService
.getActiveProvider(Number(props.customerCurrency))
.then((res) => {
if (res.has_active_provider) {
hasActiveProvider.value = true
}
hasActiveProvider.value = res.hasActiveProvider
})
.catch(() => {
// Silently fail
hasActiveProvider.value = false
})
}
}
@@ -176,11 +178,7 @@ async function getCurrentExchangeRate(v: number | string | null | undefined): Pr
isFetching.value = true
try {
const res = await exchangeRateService.getRate(Number(v))
if (res && res.exchange_rate != null) {
formData.value.exchange_rate = res.exchange_rate
} else {
formData.value.exchange_rate = null
}
formData.value.exchange_rate = res.exchangeRate
} catch {
// Silently fail
} finally {