From 8459ce443ee717532e9058a1d484e850da82b493 Mon Sep 17 00:00:00 2001 From: Amr Mohammed El-Sheraey <141947355+AmrMsCLL@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:49:16 +0300 Subject: [PATCH] feat: total subscriptions in a home currency at ECB rates and suggest known services --- app/frontend/src/catalogue.ts | 40 +++++++++++ .../src/components/SubscriptionEditor.tsx | 25 ++++++- app/frontend/src/pages/RecurringPage.tsx | 14 ++++ .../src/pages/settings/ProfileSettings.tsx | 13 ++++ .../src/pages/settings/SettingsLayout.tsx | 1 + app/frontend/test/subscriptions.spec.tsx | 42 +++++++++++ .../migration.sql | 9 +++ app/server/prisma/schema.prisma | 10 +++ app/server/src/app.module.ts | 2 + .../exchange-rates.controller.ts | 15 ++++ .../exchange-rates/exchange-rates.module.ts | 12 ++++ .../exchange-rates/exchange-rates.service.ts | 72 +++++++++++++++++++ .../src/subscriptions/subscriptions.module.ts | 2 + .../subscriptions/subscriptions.service.ts | 16 +++++ app/server/src/users/dto/users.dto.ts | 6 +- app/server/src/users/users.service.ts | 3 +- app/server/test/exchange-rates.spec.ts | 30 ++++++++ .../test/integration/exchange-rates.spec.ts | 50 +++++++++++++ 18 files changed, 359 insertions(+), 3 deletions(-) create mode 100644 app/frontend/src/catalogue.ts create mode 100644 app/server/prisma/migrations/20261005000000_exchange_rates/migration.sql create mode 100644 app/server/src/exchange-rates/exchange-rates.controller.ts create mode 100644 app/server/src/exchange-rates/exchange-rates.module.ts create mode 100644 app/server/src/exchange-rates/exchange-rates.service.ts create mode 100644 app/server/test/exchange-rates.spec.ts create mode 100644 app/server/test/integration/exchange-rates.spec.ts diff --git a/app/frontend/src/catalogue.ts b/app/frontend/src/catalogue.ts new file mode 100644 index 0000000..83c7d1a --- /dev/null +++ b/app/frontend/src/catalogue.ts @@ -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); +} diff --git a/app/frontend/src/components/SubscriptionEditor.tsx b/app/frontend/src/components/SubscriptionEditor.tsx index 4581ab7..c9d4c18 100644 --- a/app/frontend/src/components/SubscriptionEditor.tsx +++ b/app/frontend/src/components/SubscriptionEditor.tsx @@ -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'; @@ -76,7 +77,29 @@ export function SubscriptionEditor({ .then((saved) => saved && onClose()); }} > - + { + 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; + }} + /> + + {catalogue.map((service) => ( +
) : ( <> + {summary.data.home && summary.data.totals.length > 1 && ( +
+

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() { ))} +