diff --git a/lib/receipt-hunt/__tests__/portal-directory.test.ts b/lib/receipt-hunt/__tests__/portal-directory.test.ts new file mode 100644 index 00000000..62db26a8 --- /dev/null +++ b/lib/receipt-hunt/__tests__/portal-directory.test.ts @@ -0,0 +1,77 @@ +/** + * The directory turns a bank descriptor into a page where the invoice lives. + * What must not happen: sending someone to a portal for a salary run, guessing + * a vendor from a fragment, or pointing a cloud bill at the wrong Google page. + */ +import { describe, it, expect } from 'vitest' +import { PORTAL_DIRECTORY, lookupPortal } from '../portal-directory' + +describe('lookupPortal', () => { + it('reads a card descriptor the bank has mangled', () => { + // Real strings from a production ledger. + expect(lookupPortal('ANTHROPIC* CLAUDE SUB SAN FRANCISCO Kortköp/uttag')?.vendor).toBe('Anthropic') + expect(lookupPortal('OPENAI CHATGPT SUBSCR')?.vendor).toBe('OpenAI') + expect(lookupPortal('Kortköp 260228 HETZNER ONLINE GMBH')?.vendor).toBe('Hetzner') + }) + + it('prefers the longer alias, so a cloud bill is not sent to Workspace', () => { + expect(lookupPortal('GOOGLE CLOUD 6ZZS77')?.vendor).toBe('Google Cloud') + expect(lookupPortal('GOOGLE WORKSPACE REDOV')?.vendor).toBe('Google Workspace') + }) + + it('never offers a portal for a payment that has no invoice', () => { + // A salary run has nothing to fetch, and a link would be worse than + // silence: it implies somewhere to go. + expect(lookupPortal('Lön Juli Jakob Överföring via internet')).toBeNull() + expect(lookupPortal('Inbetalning skat BG 0000050501055')).toBeNull() + expect(lookupPortal('Skatt lön Juni BG 0000050501055')).toBeNull() + }) + + it('says nothing about a supplier it does not know', () => { + expect(lookupPortal('ALVIKS KOETT OCH FISK K3667')).toBeNull() + expect(lookupPortal('RESTAURANG RIDD K3667 Kortköp/uttag')).toBeNull() + }) + + it('handles an empty or missing descriptor', () => { + expect(lookupPortal(null)).toBeNull() + expect(lookupPortal('')).toBeNull() + expect(lookupPortal(' ')).toBeNull() + }) +}) + +describe('the directory itself', () => { + it('sends everyone to a real https page', () => { + // A wrong URL is worse than a missing one: it spends the trust the + // feature runs on. + for (const entry of PORTAL_DIRECTORY) { + expect(() => new URL(entry.url)).not.toThrow() + expect(entry.url.startsWith('https://')).toBe(true) + } + }) + + it('gives every entry something to match on', () => { + for (const entry of PORTAL_DIRECTORY) { + expect(entry.aliases.length).toBeGreaterThan(0) + for (const alias of entry.aliases) { + expect(alias).toBe(alias.toLowerCase()) + // Two characters would match half the mailbox. + expect(alias.length).toBeGreaterThanOrEqual(3) + } + } + }) + + it('does not name two vendors the same thing', () => { + const names = PORTAL_DIRECTORY.map((e) => e.vendor) + expect(new Set(names).size).toBe(names.length) + }) + + it('keeps aliases distinct, so a lookup cannot be ambiguous', () => { + const seen = new Map() + for (const entry of PORTAL_DIRECTORY) { + for (const alias of entry.aliases) { + expect(seen.has(alias)).toBe(false) + seen.set(alias, entry.vendor) + } + } + }) +}) diff --git a/lib/receipt-hunt/portal-directory.ts b/lib/receipt-hunt/portal-directory.ts new file mode 100644 index 00000000..6060843b --- /dev/null +++ b/lib/receipt-hunt/portal-directory.ts @@ -0,0 +1,185 @@ +/** + * Where a supplier keeps its invoices, when it does not send them. + * + * Some vendors never attach anything. They mail "your invoice is ready", or + * nothing at all, and the invoice waits behind a login. Presto built a business + * on logging in for you, across a thousand portals, with a desktop app so the + * credentials never leave the machine. We deliberately do not: holding a + * customer's supplier passwords is the expensive and legally heavy half of that + * product, and skipping it costs little of the value. + * + * What is left is knowing *where*. Seeing "OPENAI CHATGPT SUBSCR" on a + * statement is enough to say which page the invoice is on, and that turns a + * dead end into a one-click errand. + * + * The lookup happens at read time and nothing is stored against a transaction. + * Adding an entry therefore fixes every ledger at once, retroactively, with no + * migration and no backfill: a supplier someone adds today starts answering for + * purchases made last year. + * + * ## What belongs here + * + * A vendor whose invoice a human can fetch from a stable page. Not payment + * types that have no invoice at all: tax, salary, bank fees. The hunt already + * refuses to search mail for those, and offering a link would be worse than + * silence. + * + * ## Entries are unverified until someone checks them + * + * A wrong URL is worse than a missing one: it sends somebody to a page that + * cannot help and spends the trust the feature runs on. These were chosen for + * having a stable, well-known billing page, and each still deserves a human + * clicking it. Vendors whose billing page could not be pinned down were left + * out rather than guessed at. + */ +import { normalizeForMatch } from '@/lib/documents/core-receipt-matcher' +import { canHaveEmailReceipt } from './select' + +export interface PortalEntry { + /** How the vendor calls itself, for the interface to show. */ + vendor: string + /** + * Fragments of a bank descriptor, already folded the way the matcher folds + * them: lowercase, no card tokens, no payment rails. A match on any one is + * a match on the vendor. + */ + aliases: string[] + /** The page a person lands on to fetch the invoice. */ + url: string + /** What they will find there, when it is not obvious. */ + note?: string +} + +/** + * Ordered by how many companies actually pay them, measured across production + * ledgers, cross-checked against a customer poll. Google and OpenAI lead on + * both counts by a wide margin. + */ +export const PORTAL_DIRECTORY: PortalEntry[] = [ + { + vendor: 'Google Workspace', + aliases: ['google workspace', 'google gsuite', 'google apps'], + url: 'https://admin.google.com/ac/billing/history', + note: 'Fakturor ligger under Fakturering i adminkonsolen.', + }, + { + vendor: 'Google Cloud', + aliases: ['google cloud', 'google svcs', 'gcp'], + url: 'https://console.cloud.google.com/billing', + }, + { + vendor: 'OpenAI', + aliases: ['openai', 'chatgpt'], + url: 'https://platform.openai.com/settings/organization/billing/history', + }, + { + vendor: 'Microsoft 365', + aliases: ['microsoft', 'msft', 'office 365'], + url: 'https://admin.microsoft.com/#/billing/bills-payments/invoices', + }, + { + vendor: 'Microsoft Azure', + aliases: ['azure'], + url: 'https://portal.azure.com', + note: 'Kostnadshantering och fakturering.', + }, + { + vendor: 'Amazon Web Services', + aliases: ['amazon web', 'aws emea', 'aws europe'], + url: 'https://console.aws.amazon.com/billing/home#/bills', + }, + { + vendor: 'Adobe', + aliases: ['adobe'], + url: 'https://account.adobe.com/plans', + }, + { + vendor: 'Meta Ads', + aliases: ['meta platforms', 'facebook ads', 'facebk'], + url: 'https://business.facebook.com/billing_hub/accounts', + }, + { + vendor: 'LinkedIn Ads', + aliases: ['linkedin'], + url: 'https://www.linkedin.com/campaignmanager/accounts', + note: 'Fakturor under Billing center i Campaign Manager.', + }, + { + vendor: 'Atlassian', + aliases: ['atlassian', 'jira', 'confluence'], + url: 'https://admin.atlassian.com/billing', + }, + { + vendor: 'Anthropic', + aliases: ['anthropic', 'claude ai', 'claude sub'], + url: 'https://console.anthropic.com/settings/billing', + }, + { + vendor: 'Vercel', + aliases: ['vercel'], + url: 'https://vercel.com/account/invoices', + }, + { + vendor: 'Hetzner', + aliases: ['hetzner'], + url: 'https://accounts.hetzner.com/invoice', + }, + { + vendor: 'GitHub', + aliases: ['github'], + url: 'https://github.com/settings/billing', + }, + { + vendor: 'Supabase', + aliases: ['supabase'], + url: 'https://supabase.com/dashboard/org/_/billing', + }, + { + vendor: 'Cursor', + aliases: ['cursor ai', 'cursor com'], + url: 'https://cursor.com/settings', + }, + { + vendor: 'Loopia', + aliases: ['loopia'], + url: 'https://customerzone.loopia.se', + }, + { + vendor: 'Trygg Hansa', + aliases: ['trygg hansa', 'trygghansa'], + url: 'https://mitt.trygghansa.se', + note: 'Försäkringsbrev och fakturor under Mina sidor.', + }, +] + +/** + * Which supplier a bank descriptor refers to, when we know where its invoices + * live. + * + * The descriptor is folded first, so "ANTHROPIC* CLAUDE SUB SAN FRANCISCO" and + * "Kortköp 260228 HETZNER ONLINE GMBH" resolve like anything else the matcher + * reads. Longer aliases are tried first: "google cloud" must win over a + * hypothetical bare "google", or a cloud bill would point at Workspace. + */ +export function lookupPortal(descriptor: string | null | undefined): PortalEntry | null { + if (!descriptor) return null + + // A salary run or a tax payment has no invoice to fetch, and sending someone + // to a portal for one is worse than saying nothing. + if (!canHaveEmailReceipt(descriptor)) return null + + const folded = normalizeForMatch(descriptor) + if (!folded) return null + + let best: PortalEntry | null = null + let bestLength = 0 + for (const entry of PORTAL_DIRECTORY) { + for (const alias of entry.aliases) { + if (alias.length > bestLength && folded.includes(alias)) { + best = entry + bestLength = alias.length + } + } + } + return best +}