From ca6b962662260f4fa6d47336c6602698fbcf2b62 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Tue, 11 Aug 2026 10:39:06 +0200 Subject: [PATCH] feat(receipt-hunt): a directory of where suppliers keep their invoices (#1506) Some vendors never attach anything. They mail "your invoice is ready", or nothing at all, and the invoice waits behind a login. Seeing "OPENAI CHATGPT SUBSCR" on a statement is enough to say which page it is on, and that turns a dead end into a one-click errand. Deliberately not what Presto does. They log in for you across a thousand portals, with a desktop app so credentials never leave the machine. Holding a customer's supplier passwords is the expensive and legally heavy half of that product, and skipping it costs little of the value: knowing *where* is most of it. The lookup runs at read time and nothing is stored against a transaction, so adding an entry fixes every ledger at once, retroactively, with no migration and no backfill. A supplier added today starts answering for purchases made last year. Eighteen entries, ordered by how many companies actually pay them, measured across production ledgers and cross-checked against a customer poll. The two agree closely: Google and OpenAI lead both by a wide margin. Measured coverage: 769 of 10 182 undocumented purchases, which is 7.6% of rows but 120 of 224 companies, so more than half of all companies have at least one purchase this can now answer. Two things it refuses to do. It offers nothing for payments that have no invoice, reusing the rule that already keeps the hunt from searching mail for salary and tax: a link there implies somewhere to go. And longer aliases win, so a Google Cloud bill is not sent to the Workspace console. Vendors whose billing page could not be pinned down were left out rather than guessed at, including some with real volume: Fortnox, Apple, Klarna and Mynt. A wrong URL is worse than a missing one, because it spends the trust the feature runs on. Every entry here still deserves a human clicking it before anyone relies on it. No interface yet: this is the data and the lookup, so the workspace can use it the day it lands. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) --- .../__tests__/portal-directory.test.ts | 77 ++++++++ lib/receipt-hunt/portal-directory.ts | 185 ++++++++++++++++++ 2 files changed, 262 insertions(+) create mode 100644 lib/receipt-hunt/__tests__/portal-directory.test.ts create mode 100644 lib/receipt-hunt/portal-directory.ts 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 +}