Files
InvoiceShelf/resources/scripts/api/services/exchange-rate.service.ts
Darko Gjorgjijoski e44657bf7e 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.
2026-04-11 04:00:00 +02:00

157 lines
4.8 KiB
TypeScript

import { client } from '../client'
import { API } from '../endpoints'
import type { ExchangeRateProvider, Currency } from '@/scripts/types/domain/currency'
import type { ApiResponse, ListParams } from '@/scripts/types/api'
export interface CreateExchangeRateProviderPayload {
driver: string
key: string
active?: boolean
currencies?: string[]
driver_config?: Record<string, string>
}
// Normalized response types (what callers receive)
export interface ExchangeRateResponse {
exchangeRate: number | null
}
export interface ActiveProviderResponse {
hasActiveProvider: boolean
}
export interface SupportedCurrenciesResponse {
supportedCurrencies: string[]
}
export interface UsedCurrenciesResponse {
activeUsedCurrencies: string[]
allUsedCurrencies: string[]
}
export interface BulkCurrenciesResponse {
currencies: Array<Currency & { exchange_rate: number | null }>
}
export interface BulkUpdatePayload {
currencies: Array<{
id: number
exchange_rate: number
}>
}
export interface DriverConfigFieldOption {
label: string
value: string
}
export interface DriverConfigField {
key: string
type: 'text' | 'select'
label: string
options?: DriverConfigFieldOption[]
default?: string
visible_when?: Record<string, string>
}
export interface ExchangeRateDriverOption {
value: string
label: string
website?: string
config_fields?: DriverConfigField[]
}
export interface ConfigDriversResponse {
exchange_rate_drivers: ExchangeRateDriverOption[]
}
export interface SupportedCurrenciesParams {
driver: string
key: string
driver_config?: Record<string, string>
}
export const exchangeRateService = {
// Providers CRUD
async listProviders(params?: ListParams): Promise<ApiResponse<ExchangeRateProvider[]>> {
const { data } = await client.get(API.EXCHANGE_RATE_PROVIDERS, { params })
return data
},
async getProvider(id: number): Promise<ApiResponse<ExchangeRateProvider>> {
const { data } = await client.get(`${API.EXCHANGE_RATE_PROVIDERS}/${id}`)
return data
},
async createProvider(payload: CreateExchangeRateProviderPayload): Promise<ApiResponse<ExchangeRateProvider>> {
const { data } = await client.post(API.EXCHANGE_RATE_PROVIDERS, payload)
return data
},
async updateProvider(
id: number,
payload: Partial<CreateExchangeRateProviderPayload>,
): Promise<ApiResponse<ExchangeRateProvider>> {
const { data } = await client.put(`${API.EXCHANGE_RATE_PROVIDERS}/${id}`, payload)
return data
},
async deleteProvider(id: number): Promise<{ success: boolean }> {
const { data } = await client.delete(`${API.EXCHANGE_RATE_PROVIDERS}/${id}`)
return data
},
// 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`)
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`)
const raw = data as Record<string, unknown>
return { hasActiveProvider: raw.success === true }
},
// Currency lists
async getSupportedCurrencies(params: SupportedCurrenciesParams): Promise<SupportedCurrenciesResponse> {
const { data } = await client.get(API.SUPPORTED_CURRENCIES, { params })
return data
},
// 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
},
async getBulkCurrencies(): Promise<BulkCurrenciesResponse> {
const { data } = await client.get(API.CURRENCIES_USED)
return data
},
async bulkUpdateExchangeRate(payload: BulkUpdatePayload): Promise<{ success: boolean }> {
const { data } = await client.post(API.CURRENCIES_BULK_UPDATE, payload)
return data
},
// Config
// 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
},
}