+
All together, in {summary.data.home.currency}
+
≈ {money(summary.data.home.monthlyMinor, summary.data.home.currency)} a month
+
+ {money(summary.data.home.yearlyMinor, summary.data.home.currency)} over the next year
+ {summary.data.home.ratesOn && ` · ECB rates of ${shortDate(summary.data.home.ratesOn)}`}
+
+ {summary.data.home.missing.length > 0 && (
+
Not included, with no rate available: {summary.data.home.missing.join(', ')}.
+ )}
+
+ )}
{summary.data.totals.map((total) => (
diff --git a/app/frontend/src/pages/settings/ProfileSettings.tsx b/app/frontend/src/pages/settings/ProfileSettings.tsx
index 277760a..c484c49 100644
--- a/app/frontend/src/pages/settings/ProfileSettings.tsx
+++ b/app/frontend/src/pages/settings/ProfileSettings.tsx
@@ -13,6 +13,7 @@ export function ProfileSettings() {
const auth = useAuth();
const me = useResource('/me', true);
const twoStep = useResource('/me/two-step', true);
+ const currencies = useResource<{ currencies: string[] }>('/exchange-rates', true);
const profile = useAction();
const email = useAction();
const notifications = useAction();
@@ -49,6 +50,7 @@ export function ProfileSettings() {
body: JSON.stringify({
displayName: String(values.get('displayName')).trim(),
timeZone: String(values.get('timeZone')),
+ homeCurrency: String(values.get('homeCurrency')) || null,
}),
});
me.mutate(() => next);
@@ -75,6 +77,17 @@ export function ProfileSettings() {
))}
+
+ Home currency
+
+ None, keep each currency separate
+ {[...new Set([...(current.homeCurrency ? [current.homeCurrency] : []), ...(currencies.data?.currencies ?? [])])].map((code) => (
+
+ {code}
+
+ ))}
+
+
{profile.busy ? 'Saving…' : 'Save'}
diff --git a/app/frontend/src/pages/settings/SettingsLayout.tsx b/app/frontend/src/pages/settings/SettingsLayout.tsx
index 5561b40..509ae92 100644
--- a/app/frontend/src/pages/settings/SettingsLayout.tsx
+++ b/app/frontend/src/pages/settings/SettingsLayout.tsx
@@ -9,6 +9,7 @@ export interface Me {
displayName: string;
timeZone: string;
tidySummary: boolean;
+ homeCurrency: string | null;
role: string;
}
diff --git a/app/frontend/test/subscriptions.spec.tsx b/app/frontend/test/subscriptions.spec.tsx
index 84f7e37..b9b97b0 100644
--- a/app/frontend/test/subscriptions.spec.tsx
+++ b/app/frontend/test/subscriptions.spec.tsx
@@ -144,4 +144,46 @@ describe('Recurring', () => {
expect(container.textContent).toContain('Still worth it?');
expect(container.textContent).toContain('Old app');
});
+
+ it('fills the category and cancel link for a known service', async () => {
+ serve((path) => (path === '/spaces/home/subscriptions' ? json([]) : undefined));
+ await render('/spaces/home/recurring');
+
+ const add = [...container.querySelectorAll('button')].find((button) => button.textContent === 'Add subscription')!;
+ await act(async () => add.click());
+ const form = document.querySelector('dialog form') as HTMLFormElement;
+ expect([...form.querySelectorAll('datalist option')].map((option) => option.getAttribute('value'))).toContain('Netflix');
+ const name = form.querySelector('input[name="name"]')!;
+ name.value = 'netflix';
+ await act(async () => {
+ name.dispatchEvent(new Event('input', { bubbles: true }));
+ });
+
+ expect(form.querySelector('select[name="category"]')!.value).toBe('streaming');
+ expect(form.querySelector('input[name="cancelUrl"]')!.value).toBe('https://www.netflix.com/cancelplan');
+ });
+
+ it('adds up every currency in the home currency at ECB rates', async () => {
+ serve((path) =>
+ path === '/subscriptions/summary'
+ ? json({
+ home: { currency: 'EUR', yearlyMinor: 24_000, monthlyMinor: 2000, ratesOn: '2026-10-05', missing: ['XAU'] },
+ totals: [
+ { currency: 'EUR', yearlyMinor: 11_988, monthlyMinor: 999 },
+ { currency: 'USD', yearlyMinor: 12_000, monthlyMinor: 1000 },
+ ],
+ categories: [],
+ upcoming: [],
+ trials: [],
+ stillWorthIt: [],
+ })
+ : undefined,
+ );
+ await render('/recurring');
+
+ expect(container.textContent).toContain('All together, in EUR');
+ expect(container.textContent).toContain(`≈ ${euros(2000)} a month`);
+ expect(container.textContent).toContain('ECB rates of');
+ expect(container.textContent).toContain('Not included, with no rate available: XAU.');
+ });
});
diff --git a/app/server/prisma/migrations/20261005000000_exchange_rates/migration.sql b/app/server/prisma/migrations/20261005000000_exchange_rates/migration.sql
new file mode 100644
index 0000000..d3d4531
--- /dev/null
+++ b/app/server/prisma/migrations/20261005000000_exchange_rates/migration.sql
@@ -0,0 +1,9 @@
+ALTER TABLE "users" ADD COLUMN "home_currency" TEXT;
+
+CREATE TABLE "exchange_rates" (
+ "currency" TEXT NOT NULL,
+ "per_euro" DOUBLE PRECISION NOT NULL,
+ "published_on" DATE NOT NULL,
+ "updated_at" TIMESTAMP(3) NOT NULL,
+ CONSTRAINT "exchange_rates_pkey" PRIMARY KEY ("currency")
+);
diff --git a/app/server/prisma/schema.prisma b/app/server/prisma/schema.prisma
index fad5b54..5e71f9a 100644
--- a/app/server/prisma/schema.prisma
+++ b/app/server/prisma/schema.prisma
@@ -73,6 +73,7 @@ model User {
verifiedAt DateTime? @map("verified_at")
timeZone String @default("Etc/UTC") @map("time_zone")
tidySummary Boolean @default(false) @map("tidy_summary")
+ homeCurrency String? @map("home_currency")
isActive Boolean @default(true) @map("is_active")
role UserRole @default(MEMBER)
createdAt DateTime @default(now()) @map("created_at")
@@ -634,3 +635,12 @@ model TidyRule {
@@index([spaceId])
@@map("tidy_rules")
}
+
+model ExchangeRate {
+ currency String @id
+ perEuro Float @map("per_euro")
+ publishedOn DateTime @map("published_on") @db.Date
+ updatedAt DateTime @updatedAt @map("updated_at")
+
+ @@map("exchange_rates")
+}
diff --git a/app/server/src/app.module.ts b/app/server/src/app.module.ts
index 2293e82..addb5ed 100644
--- a/app/server/src/app.module.ts
+++ b/app/server/src/app.module.ts
@@ -10,6 +10,7 @@ import { validateEnvironment } from './config/environment';
import { CryptoModule } from './crypto/crypto.module';
import { DashboardsModule } from './dashboards/dashboards.module';
import { EventsModule } from './events/events.module';
+import { ExchangeRatesModule } from './exchange-rates/exchange-rates.module';
import { FilesModule } from './files/files.module';
import { HealthModule } from './health/health.module';
import { InboxModule } from './inbox/inbox.module';
@@ -56,6 +57,7 @@ import { WidgetsModule } from './widgets/widgets.module';
SubscriptionsModule,
DashboardsModule,
EventsModule,
+ ExchangeRatesModule,
TidyModule,
WidgetsModule,
HealthModule,
diff --git a/app/server/src/exchange-rates/exchange-rates.controller.ts b/app/server/src/exchange-rates/exchange-rates.controller.ts
new file mode 100644
index 0000000..1dff85a
--- /dev/null
+++ b/app/server/src/exchange-rates/exchange-rates.controller.ts
@@ -0,0 +1,15 @@
+import { Controller, Get, UseGuards } from '@nestjs/common';
+import { JwtAuthGuard } from '../auth/jwt-auth.guard';
+import { ExchangeRatesService } from './exchange-rates.service';
+
+@Controller('exchange-rates')
+@UseGuards(JwtAuthGuard)
+export class ExchangeRatesController {
+ constructor(private readonly exchangeRates: ExchangeRatesService) {}
+
+ @Get()
+ async currencies() {
+ const { publishedOn, rates } = await this.exchangeRates.table();
+ return { publishedOn, currencies: [...rates.keys()] };
+ }
+}
diff --git a/app/server/src/exchange-rates/exchange-rates.module.ts b/app/server/src/exchange-rates/exchange-rates.module.ts
new file mode 100644
index 0000000..d717119
--- /dev/null
+++ b/app/server/src/exchange-rates/exchange-rates.module.ts
@@ -0,0 +1,12 @@
+import { Module } from '@nestjs/common';
+import { AuthModule } from '../auth/auth.module';
+import { ExchangeRatesController } from './exchange-rates.controller';
+import { ExchangeRatesService } from './exchange-rates.service';
+
+@Module({
+ imports: [AuthModule],
+ controllers: [ExchangeRatesController],
+ providers: [ExchangeRatesService],
+ exports: [ExchangeRatesService],
+})
+export class ExchangeRatesModule {}
diff --git a/app/server/src/exchange-rates/exchange-rates.service.ts b/app/server/src/exchange-rates/exchange-rates.service.ts
new file mode 100644
index 0000000..2de5658
--- /dev/null
+++ b/app/server/src/exchange-rates/exchange-rates.service.ts
@@ -0,0 +1,72 @@
+import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
+import { MaintenanceScheduler } from '../jobs/maintenance.scheduler';
+import { PrismaService } from '../prisma/prisma.service';
+import { dateText, dateValue } from '../recurrence/dates';
+
+export const ratesUrl = 'https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml';
+const refreshAfterMs = 12 * 60 * 60 * 1000;
+
+export type RateTable = Map;
+
+export function parseRates(xml: string) {
+ const publishedOn = xml.match(/ ({ currency: currency!, perEuro: Number(rate) }))
+ .filter(({ perEuro }) => Number.isFinite(perEuro) && perEuro > 0);
+ if (!publishedOn || rates.length === 0) throw new Error('The ECB rates file had no rates');
+ return { publishedOn, rates: [...rates, { currency: 'EUR', perEuro: 1 }] };
+}
+
+function fractionDigits(currency: string) {
+ return new Intl.NumberFormat('en', { style: 'currency', currency }).resolvedOptions().maximumFractionDigits ?? 2;
+}
+
+export function convertMinor(amountMinor: number, from: string, to: string, rates: RateTable) {
+ const fromRate = rates.get(from);
+ const toRate = rates.get(to);
+ if (fromRate === undefined || toRate === undefined) return null;
+ const major = amountMinor / 10 ** fractionDigits(from);
+ return Math.round((major / fromRate) * toRate * 10 ** fractionDigits(to));
+}
+
+@Injectable()
+export class ExchangeRatesService implements OnModuleInit {
+ private readonly logger = new Logger(ExchangeRatesService.name);
+
+ constructor(
+ private readonly prisma: PrismaService,
+ private readonly maintenance: MaintenanceScheduler,
+ ) {}
+
+ onModuleInit() {
+ this.maintenance.register((now) => this.refresh(now));
+ }
+
+ async refresh(now: Date) {
+ const latest = await this.prisma.exchangeRate.findFirst({ orderBy: { updatedAt: 'desc' }, select: { updatedAt: true } });
+ if (latest && now.getTime() - latest.updatedAt.getTime() < refreshAfterMs) return;
+ const response = await fetch(ratesUrl, { signal: AbortSignal.timeout(10_000) }).catch((error: Error) => error);
+ if (response instanceof Error || !response.ok) {
+ this.logger.warn('Could not reach the ECB rates; trying again on the next run');
+ return;
+ }
+ const { publishedOn, rates } = parseRates(await response.text());
+ await this.prisma.$transaction(
+ rates.map(({ currency, perEuro }) =>
+ this.prisma.exchangeRate.upsert({
+ where: { currency },
+ create: { currency, perEuro, publishedOn: dateValue(publishedOn) },
+ update: { perEuro, publishedOn: dateValue(publishedOn) },
+ }),
+ ),
+ );
+ }
+
+ async table() {
+ const rows = await this.prisma.exchangeRate.findMany({ orderBy: { currency: 'asc' } });
+ return {
+ rates: new Map(rows.map((row) => [row.currency, row.perEuro])) as RateTable,
+ publishedOn: rows[0] ? dateText(rows[0].publishedOn) : null,
+ };
+ }
+}
diff --git a/app/server/src/subscriptions/subscriptions.module.ts b/app/server/src/subscriptions/subscriptions.module.ts
index 707fbad..23ce92e 100644
--- a/app/server/src/subscriptions/subscriptions.module.ts
+++ b/app/server/src/subscriptions/subscriptions.module.ts
@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common';
+import { ExchangeRatesModule } from '../exchange-rates/exchange-rates.module';
import { SubscriptionsController } from './subscriptions.controller';
import { SubscriptionsService } from './subscriptions.service';
@Module({
+ imports: [ExchangeRatesModule],
controllers: [SubscriptionsController],
providers: [SubscriptionsService],
exports: [SubscriptionsService],
diff --git a/app/server/src/subscriptions/subscriptions.service.ts b/app/server/src/subscriptions/subscriptions.service.ts
index 76185f8..2e29de9 100644
--- a/app/server/src/subscriptions/subscriptions.service.ts
+++ b/app/server/src/subscriptions/subscriptions.service.ts
@@ -2,6 +2,7 @@ import { BadRequestException, Injectable, NotFoundException, OnModuleInit } from
import { ItemKind, Prisma } from '@prisma/client';
import { AccessService } from '../access/access.service';
import { ActivityService } from '../activity/activity.service';
+import { convertMinor, ExchangeRatesService } from '../exchange-rates/exchange-rates.service';
import { InboxService } from '../inbox/inbox.service';
import { JobsService } from '../jobs/jobs.service';
import { MaintenanceScheduler } from '../jobs/maintenance.scheduler';
@@ -105,6 +106,7 @@ export class SubscriptionsService implements OnModuleInit {
private readonly inbox: InboxService,
private readonly jobs: JobsService,
private readonly maintenance: MaintenanceScheduler,
+ private readonly exchangeRates: ExchangeRatesService,
) {}
onModuleInit() {
@@ -316,7 +318,9 @@ export class SubscriptionsService implements OnModuleInit {
}
}
const withMonthly = (entry: T) => ({ ...entry, monthlyMinor: Math.round(entry.yearlyMinor / 12) });
+ const { homeCurrency } = await this.prisma.user.findUniqueOrThrow({ where: { id: userId }, select: { homeCurrency: true } });
return {
+ home: homeCurrency && totals.size > 0 ? await this.homeTotal(homeCurrency, [...totals.values()]) : null,
totals: [...totals.values()].map(withMonthly).sort((a, b) => a.currency.localeCompare(b.currency)),
categories: [...byCategory.values()].map(withMonthly).sort((a, b) => b.yearlyMinor - a.yearlyMinor),
upcoming: [...rows]
@@ -328,6 +332,18 @@ export class SubscriptionsService implements OnModuleInit {
};
}
+ private async homeTotal(currency: string, totals: { currency: string; yearlyMinor: number }[]) {
+ const { publishedOn, rates } = await this.exchangeRates.table();
+ let yearlyMinor = 0;
+ const missing: string[] = [];
+ for (const total of totals) {
+ const converted = convertMinor(total.yearlyMinor, total.currency, currency, rates);
+ if (converted === null) missing.push(total.currency);
+ else yearlyMinor += converted;
+ }
+ return { currency, yearlyMinor, monthlyMinor: Math.round(yearlyMinor / 12), ratesOn: publishedOn, missing };
+ }
+
async advanceRenewals(now: Date) {
const due = await this.prisma.subscription.findMany({
where: { cancelledOn: null, nextRenewal: { lte: dateValue(dateText(now)) }, item: { trashedAt: null } },
diff --git a/app/server/src/users/dto/users.dto.ts b/app/server/src/users/dto/users.dto.ts
index bb82275..000d28d 100644
--- a/app/server/src/users/dto/users.dto.ts
+++ b/app/server/src/users/dto/users.dto.ts
@@ -1,5 +1,5 @@
import { Transform } from 'class-transformer';
-import { IsBoolean, IsString, IsTimeZone, Length, MaxLength } from 'class-validator';
+import { IsBoolean, IsString, IsTimeZone, Length, Matches, MaxLength, ValidateIf } from 'class-validator';
import { IsOptionalNotNull } from '../../validation/is-optional-not-null.decorator';
export class UpdateProfileDto {
@@ -18,4 +18,8 @@ export class UpdateProfileDto {
@IsOptionalNotNull()
@IsBoolean()
tidySummary?: boolean;
+
+ @ValidateIf((_input, value) => value !== undefined && value !== null)
+ @Matches(/^[A-Z]{3}$/, { message: 'Choose a currency from the list.' })
+ homeCurrency?: string | null;
}
diff --git a/app/server/src/users/users.service.ts b/app/server/src/users/users.service.ts
index 9967044..f4d5d31 100644
--- a/app/server/src/users/users.service.ts
+++ b/app/server/src/users/users.service.ts
@@ -15,6 +15,7 @@ export class UsersService {
displayName: user.displayName,
timeZone: user.timeZone,
tidySummary: user.tidySummary,
+ homeCurrency: user.homeCurrency,
role: user.role.toLowerCase(),
};
}
@@ -22,7 +23,7 @@ export class UsersService {
async updateProfile(userId: string, input: UpdateProfileDto) {
await this.prisma.user.update({
where: { id: userId },
- data: { displayName: input.displayName, timeZone: input.timeZone, tidySummary: input.tidySummary },
+ data: { displayName: input.displayName, timeZone: input.timeZone, tidySummary: input.tidySummary, homeCurrency: input.homeCurrency },
});
return this.me(userId);
}
diff --git a/app/server/test/exchange-rates.spec.ts b/app/server/test/exchange-rates.spec.ts
new file mode 100644
index 0000000..7fe0571
--- /dev/null
+++ b/app/server/test/exchange-rates.spec.ts
@@ -0,0 +1,30 @@
+import { describe, expect, it } from 'vitest';
+import { convertMinor, parseRates } from '../src/exchange-rates/exchange-rates.service';
+
+const xml = `
+
+ `;
+
+describe('ECB reference rates', () => {
+ it('reads the date and every rate, and adds the euro', () => {
+ expect(parseRates(xml)).toEqual({
+ publishedOn: '2026-10-05',
+ rates: [
+ { currency: 'USD', perEuro: 1.1204 },
+ { currency: 'JPY', perEuro: 177.28 },
+ { currency: 'GBP', perEuro: 0.8472 },
+ { currency: 'EUR', perEuro: 1 },
+ ],
+ });
+ expect(() => parseRates('maintenance')).toThrow('no rates');
+ });
+
+ it('converts between currencies with different minor units', () => {
+ const rates = new Map(parseRates(xml).rates.map(({ currency, perEuro }) => [currency, perEuro]));
+ expect(convertMinor(1000, 'EUR', 'GBP', rates)).toBe(847);
+ expect(convertMinor(11_204, 'USD', 'EUR', rates)).toBe(10_000);
+ expect(convertMinor(1000, 'GBP', 'JPY', rates)).toBe(2093);
+ expect(convertMinor(17_728, 'JPY', 'EUR', rates)).toBe(10_000);
+ expect(convertMinor(1000, 'CHF', 'EUR', rates)).toBeNull();
+ });
+});
diff --git a/app/server/test/integration/exchange-rates.spec.ts b/app/server/test/integration/exchange-rates.spec.ts
new file mode 100644
index 0000000..cd76805
--- /dev/null
+++ b/app/server/test/integration/exchange-rates.spec.ts
@@ -0,0 +1,50 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { ExchangeRatesService, ratesUrl } from '../../src/exchange-rates/exchange-rates.service';
+import { integrationApp } from './harness';
+
+const xml = `
+
+ `;
+
+describe('Home currency totals against Postgres', () => {
+ const strata = integrationApp();
+ const { member } = strata;
+ const realFetch = globalThis.fetch;
+
+ afterEach(() => vi.restoreAllMocks());
+
+ it('refreshes the ECB rates at most twice a day and converts totals into the home currency', async () => {
+ const owner = await member('converter');
+ const ecb = vi.fn(() => Promise.resolve(new Response(xml, { status: 200 })));
+ vi.spyOn(globalThis, 'fetch').mockImplementation((input, init) => (String(input) === ratesUrl ? ecb() : realFetch(input, init)));
+ const rates = strata.service(ExchangeRatesService);
+ const later = () => new Date(Date.now() + 13 * 60 * 60 * 1000);
+
+ await rates.refresh(later());
+ await rates.refresh(new Date());
+ expect(ecb).toHaveBeenCalledTimes(1);
+ await rates.refresh(later());
+ expect(ecb).toHaveBeenCalledTimes(2);
+ const listed = (await owner.call('GET', '/exchange-rates')).body;
+ expect(listed.publishedOn).toBe('2026-10-05');
+ expect(listed.currencies).toEqual(expect.arrayContaining(['EUR', 'GBP', 'USD']));
+
+ const space = owner.personalSpaceId;
+ for (const [name, amountMinor, currency] of [['Music', 1250, 'USD'], ['Video', 850, 'GBP'], ['Gym', 3000, 'XAU']] as const) {
+ await owner.call('POST', `/spaces/${space}/subscriptions`, { name, amountMinor, currency, repeatRule: 'FREQ=YEARLY', startDate: '2026-01-10' });
+ }
+ expect((await owner.call('GET', '/subscriptions/summary')).body.home).toBeNull();
+
+ expect((await owner.call('PATCH', '/me', { homeCurrency: 'eur' })).status).toBe(400);
+ expect((await owner.call('PATCH', '/me', { homeCurrency: 'EUR' })).body.homeCurrency).toBe('EUR');
+ expect((await owner.call('GET', '/subscriptions/summary')).body.home).toEqual({
+ currency: 'EUR',
+ yearlyMinor: 2000,
+ monthlyMinor: 167,
+ ratesOn: '2026-10-05',
+ missing: ['XAU'],
+ });
+
+ expect((await owner.call('PATCH', '/me', { homeCurrency: null })).body.homeCurrency).toBeNull();
+ });
+});