Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions app/frontend/src/catalogue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
export interface KnownService {
name: string;
category: string;
cancelUrl: string;
}

export const catalogue: KnownService[] = [
{ name: 'Netflix', category: 'streaming', cancelUrl: 'https://www.netflix.com/cancelplan' },
{ name: 'Disney+', category: 'streaming', cancelUrl: 'https://www.disneyplus.com/account' },
{ name: 'YouTube Premium', category: 'streaming', cancelUrl: 'https://www.youtube.com/paid_memberships' },
{ name: 'Apple TV+', category: 'streaming', cancelUrl: 'https://tv.apple.com/settings' },
{ name: 'Max', category: 'streaming', cancelUrl: 'https://www.max.com/account' },
{ name: 'Hulu', category: 'streaming', cancelUrl: 'https://www.hulu.com/account' },
{ name: 'Crunchyroll', category: 'streaming', cancelUrl: 'https://www.crunchyroll.com/account/membership' },
{ name: 'Amazon Prime', category: 'streaming', cancelUrl: 'https://www.amazon.com/mc' },
{ name: 'Twitch', category: 'streaming', cancelUrl: 'https://www.twitch.tv/subscriptions' },
{ name: 'Spotify', category: 'music', cancelUrl: 'https://www.spotify.com/account/subscription/' },
{ name: 'Apple Music', category: 'music', cancelUrl: 'https://music.apple.com/account/subscriptions' },
{ name: 'Deezer', category: 'music', cancelUrl: 'https://www.deezer.com/account/subscription' },
{ name: 'Audible', category: 'other', cancelUrl: 'https://www.audible.com/account/overview' },
{ name: 'Google One', category: 'cloud', cancelUrl: 'https://one.google.com/settings' },
{ name: 'iCloud+', category: 'cloud', cancelUrl: 'https://apps.apple.com/account/subscriptions' },
{ name: 'Dropbox', category: 'cloud', cancelUrl: 'https://www.dropbox.com/account/plan' },
{ name: 'Microsoft 365', category: 'software', cancelUrl: 'https://account.microsoft.com/services' },
{ name: 'Adobe Creative Cloud', category: 'software', cancelUrl: 'https://account.adobe.com/plans' },
{ name: '1Password', category: 'software', cancelUrl: 'https://my.1password.com/' },
{ name: 'Figma', category: 'software', cancelUrl: 'https://www.figma.com/settings' },
{ name: 'Zoom', category: 'software', cancelUrl: 'https://zoom.us/billing' },
{ name: 'LinkedIn Premium', category: 'software', cancelUrl: 'https://www.linkedin.com/premium/manage/' },
{ name: 'Duolingo Super', category: 'education', cancelUrl: 'https://www.duolingo.com/settings/super' },
{ name: 'The New York Times', category: 'news', cancelUrl: 'https://www.nytimes.com/subscription/manage' },
{ name: 'Patreon', category: 'other', cancelUrl: 'https://www.patreon.com/settings/memberships' },
{ name: 'PlayStation Plus', category: 'other', cancelUrl: 'https://store.playstation.com/subscriptions' },
{ name: 'Strava', category: 'fitness', cancelUrl: 'https://www.strava.com/account' },
];

export function knownService(name: string) {
const wanted = name.trim().toLowerCase();
return catalogue.find((service) => service.name.toLowerCase() === wanted);
}
25 changes: 24 additions & 1 deletion app/frontend/src/components/SubscriptionEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Dialog, TextField } from '@project-graphite/ui';
import { useAuth } from '../auth';
import { catalogue, knownService } from '../catalogue';
import { billingCycles, categories, fromMinor, toMinor, type Subscription } from '../subscriptions';
import { useAction } from '../useAction';
import { atMost, required, useFormErrors, type Check } from '../validation';
Expand Down Expand Up @@ -76,7 +77,29 @@ export function SubscriptionEditor({
.then((saved) => saved && onClose());
}}
>
<TextField defaultValue={subscription?.name} label="Name" maxLength={100} {...form.field('name')} />
<TextField
autoComplete="off"
defaultValue={subscription?.name}
label="Name"
list="known-services"
maxLength={100}
{...form.field('name')}
onInput={(event) => {
form.field('name').onInput();
const known = knownService(event.currentTarget.value);
const fields = event.currentTarget.form?.elements;
if (!known || subscription || !fields) return;
const category = fields.namedItem('category');
const cancelUrl = fields.namedItem('cancelUrl');
if (category instanceof HTMLSelectElement && category.value === 'other') category.value = known.category;
if (cancelUrl instanceof HTMLInputElement && !cancelUrl.value) cancelUrl.value = known.cancelUrl;
}}
/>
<datalist id="known-services">
{catalogue.map((service) => (
<option key={service.name} value={service.name} />
))}
</datalist>
<div className="grid gap-4 sm:grid-cols-3">
<TextField
defaultValue={subscription ? fromMinor(subscription.amountMinor, subscription.currency) : ''}
Expand Down
14 changes: 14 additions & 0 deletions app/frontend/src/pages/RecurringPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ interface Total {
}

interface Summary {
home: (Total & { ratesOn: string | null; missing: string[] }) | null;
totals: Total[];
categories: (Total & { category: string })[];
upcoming: Subscription[];
Expand Down Expand Up @@ -55,6 +56,19 @@ export function RecurringPage() {
</EmptyState>
) : (
<>
{summary.data.home && summary.data.totals.length > 1 && (
<div className="rounded-xl border border-line bg-surface p-5">
<p className="m-0 text-sm text-muted">All together, in {summary.data.home.currency}</p>
<p className="m-0 mt-2 text-3xl text-ink">≈ {money(summary.data.home.monthlyMinor, summary.data.home.currency)} a month</p>
<p className="mono-sm m-0 mt-1 text-faint">
{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)}`}
</p>
{summary.data.home.missing.length > 0 && (
<p className="m-0 mt-2 text-sm text-muted">Not included, with no rate available: {summary.data.home.missing.join(', ')}.</p>
)}
</div>
)}
<div className="grid gap-4 sm:grid-cols-2">
{summary.data.totals.map((total) => (
<div className="rounded-xl border border-line bg-surface p-5" key={total.currency}>
Expand Down
13 changes: 13 additions & 0 deletions app/frontend/src/pages/settings/ProfileSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export function ProfileSettings() {
const auth = useAuth();
const me = useResource<Me>('/me', true);
const twoStep = useResource<TwoStepStatus>('/me/two-step', true);
const currencies = useResource<{ currencies: string[] }>('/exchange-rates', true);
const profile = useAction();
const email = useAction();
const notifications = useAction();
Expand Down Expand Up @@ -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);
Expand All @@ -75,6 +77,17 @@ export function ProfileSettings() {
))}
</select>
</label>
<label className="field-label">
Home currency
<select defaultValue={current.homeCurrency ?? ''} key={currencies.data ? 'loaded' : 'loading'} name="homeCurrency">
<option value="">None, keep each currency separate</option>
{[...new Set([...(current.homeCurrency ? [current.homeCurrency] : []), ...(currencies.data?.currencies ?? [])])].map((code) => (
<option key={code} value={code}>
{code}
</option>
))}
</select>
</label>
</div>
<button className="primary-button inline-flex w-fit" disabled={profile.busy} type="submit">
{profile.busy ? 'Saving…' : 'Save'}
Expand Down
1 change: 1 addition & 0 deletions app/frontend/src/pages/settings/SettingsLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface Me {
displayName: string;
timeZone: string;
tidySummary: boolean;
homeCurrency: string | null;
role: string;
}

Expand Down
42 changes: 42 additions & 0 deletions app/frontend/test/subscriptions.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLInputElement>('input[name="name"]')!;
name.value = 'netflix';
await act(async () => {
name.dispatchEvent(new Event('input', { bubbles: true }));
});

expect(form.querySelector<HTMLSelectElement>('select[name="category"]')!.value).toBe('streaming');
expect(form.querySelector<HTMLInputElement>('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.');
});
});
Original file line number Diff line number Diff line change
@@ -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")
);
10 changes: 10 additions & 0 deletions app/server/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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")
}
2 changes: 2 additions & 0 deletions app/server/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -56,6 +57,7 @@ import { WidgetsModule } from './widgets/widgets.module';
SubscriptionsModule,
DashboardsModule,
EventsModule,
ExchangeRatesModule,
TidyModule,
WidgetsModule,
HealthModule,
Expand Down
15 changes: 15 additions & 0 deletions app/server/src/exchange-rates/exchange-rates.controller.ts
Original file line number Diff line number Diff line change
@@ -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()] };
}
}
12 changes: 12 additions & 0 deletions app/server/src/exchange-rates/exchange-rates.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
72 changes: 72 additions & 0 deletions app/server/src/exchange-rates/exchange-rates.service.ts
Original file line number Diff line number Diff line change
@@ -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<string, number>;

export function parseRates(xml: string) {
const publishedOn = xml.match(/<Cube\s+time=['"](\d{4}-\d{2}-\d{2})['"]/)?.[1];
const rates = [...xml.matchAll(/<Cube\s+currency=['"]([A-Z]{3})['"]\s+rate=['"]([\d.]+)['"]/g)]
.map(([, currency, rate]) => ({ 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,
};
}
}
2 changes: 2 additions & 0 deletions app/server/src/subscriptions/subscriptions.module.ts
Original file line number Diff line number Diff line change
@@ -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],
Expand Down
Loading
Loading