mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-04-15 09:14:08 +00:00
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:
@@ -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
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user