feat(exchange-rate): make providers extendible via module Registry

Exchange rate providers are now pluggable via the module Registry. The four built-in drivers (currency_converter, currency_freak, currency_layer, open_exchange_rate) move from a static config array into App\\Providers\\DriverRegistryProvider, which calls Registry::registerExchangeRateDriver() for each during app boot with metadata the frontend needs: label (i18n key), website (help-text URL), and config_fields (schema for driver-specific driver_config JSON).

The Currency Converter's server-type selector and dedicated URL field — previously hardcoded in ExchangeRateProviderModal.vue — are now just another config_fields entry with a visible_when rule that shows the URL input only when type=DEDICATED. Any module that wants to ship a custom driver gets the same treatment for free: declare config_fields in the registration, and the host app's modal renders them automatically.

ExchangeRateDriverFactory::make() falls back to Registry::driverMeta() when a name isn't in the local built-in map, and availableDrivers() merges both sources. ConfigController handles the exchange_rate_drivers key specially by mapping Registry::allDrivers('exchange_rate') to enriched option objects, so the config-file route still works for every other key. The static exchange_rate_drivers + currency_converter_servers arrays in config/invoiceshelf.php are deleted.

Unit tests cover the new Registry::register/flushDrivers, the factory merging built-ins with Registry-contributed drivers, and the factory rejecting unknown names. A feature test exercises the end-to-end /api/v1/config?key=exchange_rate_drivers response shape.

NOTE: this commit depends on invoiceshelf/modules package commit e44d951 which adds the Registry driver API. The package needs to be released and pinned in composer.json before a fresh composer install on this commit will work.
This commit is contained in:
Darko Gjorgjijoski
2026-04-11 04:00:00 +02:00
parent 112cc56922
commit e44657bf7e
10 changed files with 511 additions and 167 deletions

View File

@@ -40,17 +40,29 @@ export interface BulkUpdatePayload {
}>
}
export interface ConfigOption {
key: string
export interface DriverConfigFieldOption {
label: string
value: string
}
export interface ConfigDriversResponse {
exchange_rate_drivers: ConfigOption[]
export interface DriverConfigField {
key: string
type: 'text' | 'select'
label: string
options?: DriverConfigFieldOption[]
default?: string
visible_when?: Record<string, string>
}
export interface ConfigServersResponse {
currency_converter_servers: ConfigOption[]
export interface ExchangeRateDriverOption {
value: string
label: string
website?: string
config_fields?: DriverConfigField[]
}
export interface ConfigDriversResponse {
exchange_rate_drivers: ExchangeRateDriverOption[]
}
export interface SupportedCurrenciesParams {
@@ -133,15 +145,12 @@ export const exchangeRateService = {
},
// Config
// Backend returns { exchange_rate_drivers: Array<{ key, value }> }
// Backend returns { exchange_rate_drivers: ExchangeRateDriverOption[] } where each option
// includes the metadata needed to render a driver-specific config form (label, website,
// config_fields). The list is built dynamically from the module Registry, so module-
// contributed drivers appear here automatically.
async getDrivers(): Promise<ConfigDriversResponse> {
const { data } = await client.get(API.CONFIG, { params: { key: 'exchange_rate_drivers' } })
return data
},
// 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

@@ -2,21 +2,16 @@
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import useVuelidate from '@vuelidate/core'
import { required, helpers, requiredIf, url } from '@vuelidate/validators'
import { required, helpers } from '@vuelidate/validators'
import { useModalStore } from '@/scripts/stores/modal.store'
import { useNotificationStore } from '@/scripts/stores/notification.store'
import { exchangeRateService } from '@/scripts/api/services/exchange-rate.service'
import type {
DriverConfigField,
ExchangeRateDriverOption,
} from '@/scripts/api/services/exchange-rate.service'
import { useDebounceFn } from '@vueuse/core'
interface DriverOption {
key: string
value: string
}
interface ServerOption {
value: string
}
interface ExchangeRateForm {
id: number | null
driver: string
@@ -25,11 +20,6 @@ interface ExchangeRateForm {
currencies: string[]
}
interface CurrencyConverterForm {
type: string
url: string
}
const { t } = useI18n()
const modalStore = useModalStore()
const notificationStore = useNotificationStore()
@@ -40,21 +30,19 @@ const isFetchingCurrencies = ref<boolean>(false)
const isEdit = ref<boolean>(false)
const currenciesAlreadyInUsed = ref<string[]>([])
const supportedCurrencies = ref<string[]>([])
const serverOptions = ref<ServerOption[]>([])
const drivers = ref<DriverOption[]>([])
const drivers = ref<ExchangeRateDriverOption[]>([])
const currentExchangeRate = ref<ExchangeRateForm>({
id: null,
driver: 'currency_converter',
driver: '',
key: null,
active: true,
currencies: [],
})
const currencyConverter = ref<CurrencyConverterForm>({
type: '',
url: '',
})
// Generic key/value bag for driver-specific config (driver_config JSON column).
// Populated from each driver's `config_fields` metadata; reset when driver changes.
const driverConfig = ref<Record<string, string>>({})
const modalActive = computed<boolean>(
() =>
@@ -62,36 +50,41 @@ const modalActive = computed<boolean>(
modalStore.componentName === 'ExchangeRateProviderModal'
)
const isCurrencyConverter = computed<boolean>(
() => currentExchangeRate.value.driver === 'currency_converter'
const selectedDriver = computed<ExchangeRateDriverOption | undefined>(() =>
drivers.value.find((d) => d.value === currentExchangeRate.value.driver)
)
const isDedicatedServer = computed<boolean>(
() => currencyConverter.value.type === 'DEDICATED'
const driverSite = computed<string>(() => selectedDriver.value?.website ?? '')
const driverConfigFields = computed<DriverConfigField[]>(
() => selectedDriver.value?.config_fields ?? []
)
const driverSite = computed<string>(() => {
switch (currentExchangeRate.value.driver) {
case 'currency_converter':
return 'https://www.currencyconverterapi.com'
case 'currency_freak':
return 'https://currencyfreaks.com'
case 'currency_layer':
return 'https://currencylayer.com'
case 'open_exchange_rate':
return 'https://openexchangerates.org'
default:
return ''
}
})
const visibleConfigFields = computed<DriverConfigField[]>(() =>
driverConfigFields.value.filter((field) => isFieldVisible(field))
)
const driversLists = computed(() =>
drivers.value.map((item) => ({
...item,
key: t(item.key),
drivers.value.map((driver) => ({
value: driver.value,
label: t(driver.label),
}))
)
function isFieldVisible(field: DriverConfigField): boolean {
if (!field.visible_when) return true
return Object.entries(field.visible_when).every(
([key, value]) => driverConfig.value[key] === value
)
}
function fieldOptions(field: DriverConfigField) {
return (field.options ?? []).map((option) => ({
value: option.value,
label: t(option.label),
}))
}
const rules = computed(() => ({
driver: {
required: helpers.withMessage(t('validation.required'), required),
@@ -102,29 +95,10 @@ const rules = computed(() => ({
currencies: {
required: helpers.withMessage(t('validation.required'), required),
},
converterType: {
required: helpers.withMessage(
t('validation.required'),
requiredIf(isCurrencyConverter)
),
},
converterUrl: {
required: helpers.withMessage(
t('validation.required'),
requiredIf(isDedicatedServer)
),
url: helpers.withMessage(t('validation.invalid_url'), url),
},
}))
const v$ = useVuelidate(rules, currentExchangeRate)
watch(isCurrencyConverter, (newVal) => {
if (newVal) {
fetchServers()
}
}, { immediate: true })
const fetchCurrenciesDebounced = useDebounceFn(() => {
fetchCurrencies()
}, 500)
@@ -133,9 +107,11 @@ watch(() => currentExchangeRate.value.key, (newVal) => {
if (newVal) fetchCurrenciesDebounced()
})
watch(() => currencyConverter.value.type, (newVal) => {
if (newVal) fetchCurrenciesDebounced()
})
// Refetch supported currencies whenever any driver_config field changes —
// some drivers (e.g. Currency Converter) need the config to construct the API URL.
watch(driverConfig, () => {
if (currentExchangeRate.value.key) fetchCurrenciesDebounced()
}, { deep: true })
function dismiss(): void {
currenciesAlreadyInUsed.value = []
@@ -154,18 +130,31 @@ function resetCurrency(): void {
currentExchangeRate.value.key = null
currentExchangeRate.value.currencies = []
supportedCurrencies.value = []
resetDriverConfig()
}
// Rebuild driver_config from the selected driver's field defaults whenever the
// driver changes. Without this, fields from a previous driver would linger.
function resetDriverConfig(): void {
const fresh: Record<string, string> = {}
for (const field of driverConfigFields.value) {
if (field.default !== undefined) {
fresh[field.key] = field.default
}
}
driverConfig.value = fresh
}
function resetModalData(): void {
supportedCurrencies.value = []
currentExchangeRate.value = {
id: null,
driver: 'currency_converter',
driver: drivers.value[0]?.value ?? '',
key: null,
active: true,
currencies: [],
}
currencyConverter.value = { type: '', url: '' }
resetDriverConfig()
currenciesAlreadyInUsed.value = []
isEdit.value = false
}
@@ -173,56 +162,57 @@ function resetModalData(): void {
async function fetchInitialData(): Promise<void> {
isFetchingInitialData.value = true
const driversRes = await exchangeRateService.getDrivers()
if (driversRes.exchange_rate_drivers) {
drivers.value = (driversRes.exchange_rate_drivers as unknown as DriverOption[])
}
try {
const driversRes = await exchangeRateService.getDrivers()
drivers.value = driversRes.exchange_rate_drivers ?? []
if (modalStore.data && typeof modalStore.data === 'number') {
isEdit.value = true
const response = await exchangeRateService.getProvider(modalStore.data)
if (response.data) {
const provider = response.data
currentExchangeRate.value = {
id: provider.id,
driver: provider.driver,
key: provider.key,
active: provider.active,
currencies: provider.currencies ?? [],
if (modalStore.data && typeof modalStore.data === 'number') {
isEdit.value = true
const response = await exchangeRateService.getProvider(modalStore.data)
if (response.data) {
const provider = response.data
currentExchangeRate.value = {
id: provider.id,
driver: provider.driver,
key: provider.key,
active: provider.active,
currencies: provider.currencies ?? [],
}
// Hydrate driverConfig from the persisted JSON column. Field defaults from
// the schema fill any missing keys.
const persisted = (provider as { driver_config?: Record<string, string> }).driver_config ?? {}
const merged: Record<string, string> = {}
for (const field of driverConfigFields.value) {
merged[field.key] = persisted[field.key] ?? field.default ?? ''
}
driverConfig.value = merged
}
} else {
currentExchangeRate.value.driver = drivers.value[0]?.value ?? ''
resetDriverConfig()
}
} else {
currentExchangeRate.value.driver = 'currency_converter'
} finally {
isFetchingInitialData.value = false
}
isFetchingInitialData.value = false
}
async function fetchServers(): Promise<void> {
const res = await exchangeRateService.getCurrencyConverterServers()
serverOptions.value = (res as Record<string, ServerOption[]>).currency_converter_servers ?? []
currencyConverter.value.type = 'FREE'
}
async function fetchCurrencies(): Promise<void> {
const { driver, key } = currentExchangeRate.value
if (!driver || !key) return
if (isCurrencyConverter.value && !currencyConverter.value.type) return
// If any visible config field is empty, hold off — the driver likely needs it
// to talk to its API.
for (const field of visibleConfigFields.value) {
if (!driverConfig.value[field.key]) return
}
isFetchingCurrencies.value = true
try {
const driverConfig: Record<string, string> = {}
if (currencyConverter.value.type) {
driverConfig.type = currencyConverter.value.type
}
if (currencyConverter.value.url) {
driverConfig.url = currencyConverter.value.url
}
const config = buildDriverConfigPayload()
const res = await exchangeRateService.getSupportedCurrencies({
driver,
key,
driver_config: Object.keys(driverConfig).length ? driverConfig : undefined,
driver_config: Object.keys(config).length ? config : undefined,
})
supportedCurrencies.value = res.supportedCurrencies ?? []
} finally {
@@ -230,6 +220,19 @@ async function fetchCurrencies(): Promise<void> {
}
}
// Strip values for fields that aren't currently visible (e.g. the URL field
// when the server type isn't DEDICATED) so we never persist stale config.
function buildDriverConfigPayload(): Record<string, string> {
const payload: Record<string, string> = {}
for (const field of visibleConfigFields.value) {
const value = driverConfig.value[field.key]
if (value !== undefined && value !== '') {
payload[field.key] = value
}
}
return payload
}
async function submitExchangeRate(): Promise<void> {
v$.value.$touch()
if (v$.value.$invalid) return
@@ -238,11 +241,9 @@ async function submitExchangeRate(): Promise<void> {
...currentExchangeRate.value,
}
if (isCurrencyConverter.value) {
data.driver_config = { ...currencyConverter.value }
if (!isDedicatedServer.value) {
(data.driver_config as CurrencyConverterForm).url = ''
}
const config = buildDriverConfigPayload()
if (Object.keys(config).length) {
data.driver_config = config
}
isSaving.value = true
@@ -317,30 +318,41 @@ function closeExchangeRateModal(): void {
:content-loading="isFetchingInitialData"
value-prop="value"
:can-deselect="true"
label="key"
label="label"
:searchable="true"
:invalid="v$.driver.$error"
track-by="key"
track-by="label"
@update:model-value="resetCurrency"
/>
</BaseInputGroup>
<!-- Driver-specific config fields rendered from metadata -->
<BaseInputGroup
v-if="isCurrencyConverter"
required
:label="$t('settings.exchange_rate.server')"
v-for="field in visibleConfigFields"
:key="field.key"
:label="$t(field.label)"
:content-loading="isFetchingInitialData"
required
>
<BaseMultiselect
v-model="currencyConverter.type"
v-if="field.type === 'select'"
v-model="driverConfig[field.key]"
:options="fieldOptions(field)"
:content-loading="isFetchingInitialData"
value-prop="value"
searchable
:options="serverOptions"
label="value"
track-by="value"
label="label"
:can-deselect="false"
:searchable="true"
track-by="label"
@update:model-value="resetCurrency"
/>
<BaseInput
v-else
v-model="driverConfig[field.key]"
:content-loading="isFetchingInitialData"
type="text"
:name="field.key"
/>
</BaseInputGroup>
<BaseInputGroup
@@ -383,18 +395,6 @@ function closeExchangeRateModal(): void {
/>
</BaseInputGroup>
<BaseInputGroup
v-if="isDedicatedServer"
:label="$t('settings.exchange_rate.url')"
:content-loading="isFetchingInitialData"
>
<BaseInput
v-model="currencyConverter.url"
:content-loading="isFetchingInitialData"
type="url"
/>
</BaseInputGroup>
<BaseSwitch
v-model="currentExchangeRate.active"
class="flex"