Browse Source

Merge 341103b6cb into 82cee20f2d

pull/7865/merge
Robert Tidball 11 hours ago
committed by GitHub
parent
commit
843de6885d
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 1
      CHANGELOG.md
  2. 1
      apps/api/src/services/configuration/configuration.service.ts
  3. 5
      apps/api/src/services/data-provider/data-provider.module.ts
  4. 324
      apps/api/src/services/data-provider/fxmacrodata/fxmacrodata.service.spec.ts
  5. 333
      apps/api/src/services/data-provider/fxmacrodata/fxmacrodata.service.ts
  6. 19
      apps/api/src/services/data-provider/fxmacrodata/interfaces/interfaces.ts
  7. 1
      apps/api/src/services/interfaces/environment.interface.ts
  8. 2
      prisma/migrations/20260911000000_added_fxmacrodata_to_data_source/migration.sql
  9. 1
      prisma/schema.prisma

1
CHANGELOG.md

@ -83,6 +83,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added the holdings table to the overview tab of the home page on desktop (experimental) - Added the holdings table to the overview tab of the home page on desktop (experimental)
- Added a tool to search for asset profiles to the server of the Model Context Protocol (MCP) (experimental) - Added a tool to search for asset profiles to the server of the Model Context Protocol (MCP) (experimental)
- Added a data provider for official foreign exchange reference rates (FXMacroData)
### Changed ### Changed

1
apps/api/src/services/configuration/configuration.service.ts

@ -51,6 +51,7 @@ export class ConfigurationService {
API_KEY_COINGECKO_PRO: str({ default: '' }), API_KEY_COINGECKO_PRO: str({ default: '' }),
API_KEY_EOD_HISTORICAL_DATA: str({ default: '' }), API_KEY_EOD_HISTORICAL_DATA: str({ default: '' }),
API_KEY_FINANCIAL_MODELING_PREP: str({ default: '' }), API_KEY_FINANCIAL_MODELING_PREP: str({ default: '' }),
API_KEY_FXMACRODATA: str({ default: '' }),
API_KEY_OPEN_FIGI: str({ default: '' }), API_KEY_OPEN_FIGI: str({ default: '' }),
API_KEY_RAPID_API: str({ default: '' }), API_KEY_RAPID_API: str({ default: '' }),
CACHE_QUOTES_TTL: num({ default: ms('1 minute') }), CACHE_QUOTES_TTL: num({ default: ms('1 minute') }),

5
apps/api/src/services/data-provider/data-provider.module.ts

@ -5,6 +5,7 @@ import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alph
import { CoinGeckoService } from '@ghostfolio/api/services/data-provider/coingecko/coingecko.service'; import { CoinGeckoService } from '@ghostfolio/api/services/data-provider/coingecko/coingecko.service';
import { EodHistoricalDataService } from '@ghostfolio/api/services/data-provider/eod-historical-data/eod-historical-data.service'; import { EodHistoricalDataService } from '@ghostfolio/api/services/data-provider/eod-historical-data/eod-historical-data.service';
import { FinancialModelingPrepService } from '@ghostfolio/api/services/data-provider/financial-modeling-prep/financial-modeling-prep.service'; import { FinancialModelingPrepService } from '@ghostfolio/api/services/data-provider/financial-modeling-prep/financial-modeling-prep.service';
import { FXMacroDataService } from '@ghostfolio/api/services/data-provider/fxmacrodata/fxmacrodata.service';
import { GhostfolioService } from '@ghostfolio/api/services/data-provider/ghostfolio/ghostfolio.service'; import { GhostfolioService } from '@ghostfolio/api/services/data-provider/ghostfolio/ghostfolio.service';
import { GoogleSheetsService } from '@ghostfolio/api/services/data-provider/google-sheets/google-sheets.service'; import { GoogleSheetsService } from '@ghostfolio/api/services/data-provider/google-sheets/google-sheets.service';
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service'; import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
@ -40,6 +41,7 @@ import { DataProviderService } from './data-provider.service';
DataProviderService, DataProviderService,
EodHistoricalDataService, EodHistoricalDataService,
FinancialModelingPrepService, FinancialModelingPrepService,
FXMacroDataService,
GhostfolioService, GhostfolioService,
GoogleSheetsService, GoogleSheetsService,
ManualService, ManualService,
@ -51,6 +53,7 @@ import { DataProviderService } from './data-provider.service';
CoinGeckoService, CoinGeckoService,
EodHistoricalDataService, EodHistoricalDataService,
FinancialModelingPrepService, FinancialModelingPrepService,
FXMacroDataService,
GhostfolioService, GhostfolioService,
GoogleSheetsService, GoogleSheetsService,
ManualService, ManualService,
@ -63,6 +66,7 @@ import { DataProviderService } from './data-provider.service';
coinGeckoService, coinGeckoService,
eodHistoricalDataService, eodHistoricalDataService,
financialModelingPrepService, financialModelingPrepService,
fxMacroDataService,
ghostfolioService, ghostfolioService,
googleSheetsService, googleSheetsService,
manualService, manualService,
@ -73,6 +77,7 @@ import { DataProviderService } from './data-provider.service';
coinGeckoService, coinGeckoService,
eodHistoricalDataService, eodHistoricalDataService,
financialModelingPrepService, financialModelingPrepService,
fxMacroDataService,
ghostfolioService, ghostfolioService,
googleSheetsService, googleSheetsService,
manualService, manualService,

324
apps/api/src/services/data-provider/fxmacrodata/fxmacrodata.service.spec.ts

@ -0,0 +1,324 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service';
import { DataSource } from '@prisma/client';
import { FXMacroDataService } from './fxmacrodata.service';
// FetchService pulls in @openrouter/ai-sdk-provider, which ships ESM only and
// is not transformed for Jest. This suite injects its own stub, so the real
// implementation is never needed here.
jest.mock('@ghostfolio/api/services/fetch/fetch.service', () => {
return { FetchService: class {} };
});
const SOURCES_RESPONSE = {
sources: [{ served_pairs: ['EUR/USD', 'USD/JPY', 'GBP/USD'] }]
};
describe('FXMacroDataService', () => {
let configurationService: ConfigurationService;
let fetchService: FetchService;
let fxMacroDataService: FXMacroDataService;
let responses: { [url: string]: unknown };
const jsonResponse = (body: unknown) => {
return Promise.resolve({
ok: true,
json: () => {
return Promise.resolve(body);
}
});
};
beforeEach(() => {
responses = { 'fx/sources': SOURCES_RESPONSE };
configurationService = {
get: jest.fn((key: string) => {
return key === 'API_KEY_FXMACRODATA' ? 'test-key' : 30_000;
})
} as unknown as ConfigurationService;
fetchService = {
fetch: jest.fn((url: string) => {
const match = Object.keys(responses).find((path) => {
return url.includes(path);
});
if (!match) {
return Promise.resolve({ ok: false, status: 404 });
}
const body = responses[match];
if (body instanceof Error) {
return Promise.reject(body);
}
return jsonResponse(body);
})
} as unknown as FetchService;
fxMacroDataService = new FXMacroDataService(
configurationService,
fetchService
);
});
describe('canHandle', () => {
it('handles currency pairs', () => {
expect(fxMacroDataService.canHandle('EURUSD')).toBe(true);
});
it('leaves non-currency symbols to the other providers', () => {
expect(fxMacroDataService.canHandle('AAPL')).toBe(false);
});
it('is disabled without an API key', () => {
configurationService.get = jest.fn(() => {
return '';
}) as unknown as ConfigurationService['get'];
expect(fxMacroDataService.canHandle('EURUSD')).toBe(false);
});
});
describe('getName', () => {
it('reports its data source', () => {
expect(fxMacroDataService.getName()).toEqual(DataSource.FXMACRODATA);
});
});
describe('getQuotes', () => {
it('returns the latest rate of each requested pair', async () => {
responses['forex/eur/usd'] = {
data: [{ date: '2026-09-10', val: 1.16 }]
};
responses['forex/gbp/usd'] = {
data: [{ date: '2026-09-10', val: 1.35 }]
};
const quotes = await fxMacroDataService.getQuotes({
symbols: ['EURUSD', 'GBPUSD']
});
expect(quotes['EURUSD']).toEqual({
currency: 'USD',
dataSource: DataSource.FXMACRODATA,
marketPrice: 1.16,
marketState: 'open'
});
expect(quotes['GBPUSD'].marketPrice).toEqual(1.35);
});
it('asks for a pair in the direction wanted rather than inverting locally', async () => {
// FXMacroData stores USD/JPY, not JPY/USD, and derives the inverse
// itself, so the request must be for the wanted direction.
responses['forex/jpy/usd'] = {
data: [{ date: '2026-09-10', val: 0.00678 }]
};
const quotes = await fxMacroDataService.getQuotes({
symbols: ['JPYUSD']
});
expect(quotes['JPYUSD'].marketPrice).toEqual(0.00678);
expect(fetchService.fetch).toHaveBeenCalledWith(
expect.stringContaining('forex/jpy/usd'),
expect.anything()
);
});
it('sends the API key as a header, never in the URL', async () => {
responses['forex/eur/usd'] = {
data: [{ date: '2026-09-10', val: 1.16 }]
};
await fxMacroDataService.getQuotes({ symbols: ['EURUSD'] });
const [url, init] = (fetchService.fetch as jest.Mock).mock.calls[0] as [
string,
{ headers: Record<string, string> }
];
expect(url).not.toContain('test-key');
expect(init.headers['X-API-Key']).toEqual('test-key');
});
it('drops a null rate rather than publishing it as zero', async () => {
responses['forex/eur/usd'] = {
data: [{ date: '2026-09-10', val: null }]
};
const quotes = await fxMacroDataService.getQuotes({
symbols: ['EURUSD']
});
expect(quotes).toEqual({});
});
it('does not lose the other pairs when one fails', async () => {
responses['forex/eur/usd'] = new Error('unavailable');
responses['forex/gbp/usd'] = {
data: [{ date: '2026-09-10', val: 1.35 }]
};
const quotes = await fxMacroDataService.getQuotes({
symbols: ['EURUSD', 'GBPUSD']
});
expect(Object.keys(quotes)).toEqual(['GBPUSD']);
});
it('ignores a symbol that is not a currency pair', async () => {
const quotes = await fxMacroDataService.getQuotes({ symbols: ['AAPL'] });
expect(quotes).toEqual({});
expect(fetchService.fetch).not.toHaveBeenCalled();
});
it('makes no request for an empty symbol list', async () => {
const quotes = await fxMacroDataService.getQuotes({ symbols: [] });
expect(quotes).toEqual({});
expect(fetchService.fetch).not.toHaveBeenCalled();
});
});
describe('getHistorical', () => {
it('returns a market price per date', async () => {
responses['forex/eur/usd'] = {
data: [
{ date: '2026-09-09', val: 1.15 },
{ date: '2026-09-10', val: 1.16 }
]
};
const historical = await fxMacroDataService.getHistorical({
from: new Date('2026-09-09'),
symbol: 'EURUSD',
to: new Date('2026-09-10')
});
expect(historical).toEqual({
'2026-09-09': { marketPrice: 1.15 },
'2026-09-10': { marketPrice: 1.16 }
});
});
it('follows the pagination offsets until the last page', async () => {
responses['offset=0'] = {
data: [
{ date: '2026-09-10', val: 1.16 },
{ date: '2026-09-09', val: 1.15 }
],
pagination: { has_more: true, next_offset: 2 }
};
responses['offset=2'] = {
data: [{ date: '2026-09-08', val: 1.14 }],
pagination: { has_more: false, next_offset: null }
};
const historical = await fxMacroDataService.getHistorical({
from: new Date('2026-09-08'),
symbol: 'EURUSD',
to: new Date('2026-09-10')
});
expect(historical).toEqual({
'2026-09-08': { marketPrice: 1.14 },
'2026-09-09': { marketPrice: 1.15 },
'2026-09-10': { marketPrice: 1.16 }
});
const urls = (fetchService.fetch as jest.Mock).mock.calls.map(([url]) => {
return url as string;
});
expect(urls).toHaveLength(2);
expect(urls[0]).toContain('limit=100');
expect(urls[1]).toContain('offset=2');
});
it('skips a date whose rate is null', async () => {
responses['forex/eur/usd'] = {
data: [
{ date: '2026-09-09', val: null },
{ date: '2026-09-10', val: 1.16 }
]
};
const historical = await fxMacroDataService.getHistorical({
from: new Date('2026-09-09'),
symbol: 'EURUSD',
to: new Date('2026-09-10')
});
expect(historical).toEqual({ '2026-09-10': { marketPrice: 1.16 } });
});
it('returns nothing for a symbol that is not a currency pair', async () => {
const historical = await fxMacroDataService.getHistorical({
from: new Date('2026-09-09'),
symbol: 'AAPL',
to: new Date('2026-09-10')
});
expect(historical).toEqual({});
expect(fetchService.fetch).not.toHaveBeenCalled();
});
});
describe('search', () => {
it('offers pairs built from the covered currencies', async () => {
const { items } = await fxMacroDataService.search({ query: 'eurus' });
expect(items).toEqual([
expect.objectContaining({
currency: 'USD',
dataSource: DataSource.FXMACRODATA,
name: 'EUR/USD',
symbol: 'EURUSD'
})
]);
});
it('offers a cross the API derives but does not store', async () => {
// GBP/JPY is in neither served_pairs entry, but both currencies are
// covered, so the API serves the cross.
const { items } = await fxMacroDataService.search({ query: 'GBPJPY' });
expect(items.map(({ symbol }) => symbol)).toEqual(['GBPJPY']);
});
it('never offers a currency against itself', async () => {
const { items } = await fxMacroDataService.search({ query: 'USD' });
expect(items.map(({ symbol }) => symbol)).not.toContain('USDUSD');
});
it('returns nothing for an empty query', async () => {
const { items } = await fxMacroDataService.search({ query: ' ' });
expect(items).toEqual([]);
});
});
describe('getAssetProfile', () => {
it('describes the pair as liquidity in the quote currency', async () => {
const profile = await fxMacroDataService.getAssetProfile({
symbol: 'EURUSD'
});
expect(profile).toEqual(
expect.objectContaining({
currency: 'USD',
dataSource: DataSource.FXMACRODATA,
name: 'EUR/USD',
symbol: 'EURUSD'
})
);
});
});
});

333
apps/api/src/services/data-provider/fxmacrodata/fxmacrodata.service.ts

@ -0,0 +1,333 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import {
DataProviderInterface,
GetAssetProfileParams,
GetDividendsParams,
GetHistoricalParams,
GetQuotesParams,
GetSearchParams
} from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service';
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
import { DATE_FORMAT, isCurrencySymbol } from '@ghostfolio/common/helper';
import {
DataProviderHistoricalResponse,
DataProviderInfo,
DataProviderResponse,
LookupResponse
} from '@ghostfolio/common/interfaces';
import { Injectable, Logger } from '@nestjs/common';
import { AssetClass, DataSource, SymbolProfile } from '@prisma/client';
import { format } from 'date-fns';
import {
FXMacroDataForexResponse,
FXMacroDataSourcesResponse
} from './interfaces/interfaces';
const PAGE_SIZE = 100;
const MAX_PAGES = 1000;
@Injectable()
export class FXMacroDataService implements DataProviderInterface {
private readonly baseUrl = 'https://api.fxmacrodata.com/v1';
private readonly logger = new Logger(FXMacroDataService.name);
private currencies: string[];
public constructor(
private readonly configurationService: ConfigurationService,
private readonly fetchService: FetchService
) {}
/**
* FXMacroData publishes official reference rates, so this provider handles
* currency pairs only. Any other symbol is left to the other providers.
*/
public canHandle(symbol?: string) {
if (!this.configurationService.get('API_KEY_FXMACRODATA')) {
return false;
}
return symbol ? isCurrencySymbol(symbol) : true;
}
public async getAssetProfile({
symbol
}: GetAssetProfileParams): Promise<Partial<SymbolProfile>> {
const { base, quote } = this.splitCurrencyPair(symbol);
if (!base || !quote) {
return { symbol, dataSource: this.getName() };
}
return {
symbol,
assetClass: AssetClass.LIQUIDITY,
currency: quote,
dataSource: this.getName(),
name: `${base}/${quote}`
};
}
public getDataProviderInfo(): DataProviderInfo {
return {
dataSource: DataSource.FXMACRODATA,
isPremium: true,
name: 'FXMacroData',
url: 'https://fxmacrodata.com'
};
}
public async getDividends({}: GetDividendsParams) {
return {};
}
public async getHistorical({
from,
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
symbol,
to
}: GetHistoricalParams): Promise<{
[date: string]: DataProviderHistoricalResponse;
}> {
const { base, quote } = this.splitCurrencyPair(symbol);
if (!base || !quote) {
return {};
}
try {
const response: { [date: string]: DataProviderHistoricalResponse } = {};
// The endpoint caps a page at 100 rows and orders most-recent-first, so
// longer ranges are walked page by page rather than silently truncated.
let offset = 0;
for (let page = 0; page < MAX_PAGES; page++) {
const { data, pagination } = await this.get<FXMacroDataForexResponse>({
requestTimeout,
path: `forex/${base.toLowerCase()}/${quote.toLowerCase()}`,
searchParams: {
end_date: format(to, DATE_FORMAT),
limit: PAGE_SIZE.toString(),
offset: offset.toString(),
start_date: format(from, DATE_FORMAT)
}
});
if (!data?.length) {
break;
}
for (const { date, val } of data) {
// val is documented as anyOf[number, null]; a missing rate must not
// become a zero market price.
if (date && typeof val === 'number') {
response[date] = { marketPrice: val };
}
}
if (!pagination?.has_more) {
break;
}
offset = pagination.next_offset ?? offset + data.length;
}
return response;
} catch (error) {
throw new Error(
`Could not get historical market data for ${symbol} (${this.getName()}) from ${format(
from,
DATE_FORMAT
)} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`
);
}
}
public getName(): DataSource {
return DataSource.FXMACRODATA;
}
public async getQuotes({
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
symbols
}: GetQuotesParams): Promise<{ [symbol: string]: DataProviderResponse }> {
const response: { [symbol: string]: DataProviderResponse } = {};
if (!symbols.length) {
return response;
}
const results = await Promise.all(
symbols.map(async (symbol) => {
const { base, quote } = this.splitCurrencyPair(symbol);
if (!base || !quote) {
return undefined;
}
try {
const { data } = await this.get<FXMacroDataForexResponse>({
requestTimeout,
path: `forex/${base.toLowerCase()}/${quote.toLowerCase()}`,
searchParams: { limit: '1' }
});
const [latest] = data ?? [];
if (typeof latest?.val !== 'number') {
return undefined;
}
return { currency: quote, marketPrice: latest.val, symbol };
} catch (error) {
// One unavailable pair must not lose the rates of the others.
this.logger.error(
`Could not get quote for ${symbol} (${this.getName()}): [${error.name}] ${error.message}`
);
return undefined;
}
})
);
for (const result of results) {
if (result) {
response[result.symbol] = {
currency: result.currency,
dataSource: this.getName(),
marketPrice: result.marketPrice,
marketState: 'open'
};
}
}
return response;
}
public getTestSymbol() {
return `EUR${DEFAULT_CURRENCY}`;
}
public async search({ query }: GetSearchParams): Promise<LookupResponse> {
const currencies = await this.getCoveredCurrencies();
const normalizedQuery = query.trim().toUpperCase().replace('/', '');
if (!normalizedQuery) {
return { items: [] };
}
const items = currencies
.flatMap((base) => {
return currencies
.filter((quote) => {
return quote !== base;
})
.map((quote) => {
return { base, quote, symbol: `${base}${quote}` };
});
})
.filter(({ symbol }) => {
return symbol.includes(normalizedQuery);
})
.map(({ base, quote, symbol }) => {
return {
symbol,
assetClass: AssetClass.LIQUIDITY,
assetSubClass: undefined,
currency: quote,
dataProviderInfo: this.getDataProviderInfo(),
dataSource: this.getName(),
name: `${base}/${quote}`
};
});
return { items };
}
/**
* The currencies FXMacroData covers. A pair is available whenever both of its
* currencies are covered: the API stores each pair in one direction only and
* derives the inverse or the cross itself, so the covered set - not the list
* of stored pairs - is what determines availability.
*/
private async getCoveredCurrencies(): Promise<string[]> {
if (this.currencies) {
return this.currencies;
}
try {
const { sources } = await this.get<FXMacroDataSourcesResponse>({
path: 'fx/sources',
requestTimeout: this.configurationService.get('REQUEST_TIMEOUT')
});
const currencies = new Set<string>();
for (const { served_pairs } of sources ?? []) {
for (const pair of served_pairs ?? []) {
const [base, quote] = pair.split('/');
if (base && quote) {
currencies.add(base.toUpperCase());
currencies.add(quote.toUpperCase());
}
}
}
this.currencies = [...currencies].sort();
} catch (error) {
this.logger.error(
`Could not get the covered currencies (${this.getName()}): [${error.name}] ${error.message}`
);
return [];
}
return this.currencies;
}
private async get<T>({
path,
requestTimeout,
searchParams = {}
}: {
path: string;
requestTimeout: number;
searchParams?: { [key: string]: string };
}): Promise<T> {
const url = `${this.baseUrl}/${path}?${new URLSearchParams(searchParams).toString()}`;
const response = await this.fetchService.fetch(url, {
// The key travels in a header rather than a query parameter so it is not
// written to request logs or a proxy's access log.
headers: {
'X-API-Key': this.configurationService.get('API_KEY_FXMACRODATA')
},
signal: AbortSignal.timeout(requestTimeout)
});
if (!response.ok) {
throw new Error(`${url.split('?')[0]} returned HTTP ${response.status}`);
}
return (await response.json()) as T;
}
private splitCurrencyPair(symbol: string): {
base?: string;
quote?: string;
} {
if (!isCurrencySymbol(symbol)) {
return {};
}
return {
base: symbol.substring(0, symbol.length - DEFAULT_CURRENCY.length),
quote: symbol.substring(symbol.length - DEFAULT_CURRENCY.length)
};
}
}

19
apps/api/src/services/data-provider/fxmacrodata/interfaces/interfaces.ts

@ -0,0 +1,19 @@
export interface FXMacroDataForexResponse {
base: string;
data: {
date: string;
// The rate is documented as anyOf[number, null].
val: number | null;
}[];
pagination?: {
has_more: boolean;
next_offset: number | null;
};
quote: string;
}
export interface FXMacroDataSourcesResponse {
sources: {
served_pairs: string[];
}[];
}

1
apps/api/src/services/interfaces/environment.interface.ts

@ -8,6 +8,7 @@ export interface Environment extends CleanedEnvAccessors {
API_KEY_COINGECKO_PRO: string; API_KEY_COINGECKO_PRO: string;
API_KEY_EOD_HISTORICAL_DATA: string; API_KEY_EOD_HISTORICAL_DATA: string;
API_KEY_FINANCIAL_MODELING_PREP: string; API_KEY_FINANCIAL_MODELING_PREP: string;
API_KEY_FXMACRODATA: string;
API_KEY_OPEN_FIGI: string; API_KEY_OPEN_FIGI: string;
API_KEY_RAPID_API: string; API_KEY_RAPID_API: string;
CACHE_QUOTES_TTL: number; CACHE_QUOTES_TTL: number;

2
prisma/migrations/20260911000000_added_fxmacrodata_to_data_source/migration.sql

@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "DataSource" ADD VALUE 'FXMACRODATA';

1
prisma/schema.prisma

@ -373,6 +373,7 @@ enum DataSource {
COINGECKO COINGECKO
EOD_HISTORICAL_DATA EOD_HISTORICAL_DATA
FINANCIAL_MODELING_PREP FINANCIAL_MODELING_PREP
FXMACRODATA
GHOSTFOLIO GHOSTFOLIO
GOOGLE_SHEETS GOOGLE_SHEETS
MANUAL MANUAL

Loading…
Cancel
Save