From a9906d6229489ae0365c6107af576a1c23fd4905 Mon Sep 17 00:00:00 2001 From: hkonsti <45428767+hkonsti@users.noreply.github.com> Date: Mon, 19 Oct 2026 04:48:19 +0000 Subject: [PATCH] show balances and spending from the home page, behind the plaid experiment Two widgets for what a person types about their banks. Balances lists every bank's accounts with what each holds or owes and their sums, the account asked about standing out. Transactions shows a stretch of days, at a merchant, of a kind, in a direction or on an account where those are said, what they add up to beside the same days before, and the newest of them. The days are read from the text in the browser ("last month", "in september", "the past 90 days"), which also sifts the transactions as typing goes on, so a range is read from the bank once. The model fills merchant, category (Plaid's primary categories, in @proxy/utils), flow and account. A merchant naming an account is taken for the account, and one no transaction has beside a kind for the kind. The classifier builds its instructions and schema per set of widgets offered, so an organization without the experiment is never offered the bank widgets, and asks OpenRouter for hosts that neither keep prompts nor train on them. --- packages/api/src/intent/classify.test.ts | 60 ++- packages/api/src/intent/classify.ts | 80 ++-- packages/api/src/intent/route.test.ts | 6 +- packages/api/src/intent/route.ts | 2 +- packages/api/src/intent/widgets.ts | 47 +- packages/api/src/records/plaid-connector.ts | 6 +- packages/api/src/server/create-api-router.ts | 3 + packages/api/src/widgets/bank.test.ts | 144 ++++++ packages/api/src/widgets/bank.ts | 192 ++++++++ packages/utils/package.json | 3 +- packages/utils/src/transaction-categories.ts | 86 ++++ packages/web/src/client/widgets-client.ts | 71 +++ .../components/widgets/balances-widget.tsx | 159 ++++++ .../components/widgets/intent-widget.test.tsx | 121 +++++ .../src/components/widgets/intent-widget.tsx | 8 + .../widgets/transactions-widget.tsx | 227 +++++++++ .../components/widgets/unsupported-widget.tsx | 4 +- packages/web/src/lib/bank-values.test.ts | 250 ++++++++++ packages/web/src/lib/bank-values.ts | 451 ++++++++++++++++++ packages/web/src/lib/widget-lookups.ts | 6 + packages/web/src/pages/home-page.tsx | 20 +- packages/web/src/test-widgets-api.ts | 116 ++++- 22 files changed, 2007 insertions(+), 55 deletions(-) create mode 100644 packages/api/src/widgets/bank.test.ts create mode 100644 packages/api/src/widgets/bank.ts create mode 100644 packages/utils/src/transaction-categories.ts create mode 100644 packages/web/src/components/widgets/balances-widget.tsx create mode 100644 packages/web/src/components/widgets/transactions-widget.tsx create mode 100644 packages/web/src/lib/bank-values.test.ts create mode 100644 packages/web/src/lib/bank-values.ts diff --git a/packages/api/src/intent/classify.test.ts b/packages/api/src/intent/classify.test.ts index e3e6a282..a4655895 100644 --- a/packages/api/src/intent/classify.test.ts +++ b/packages/api/src/intent/classify.test.ts @@ -5,6 +5,10 @@ mock.module('../utils/env', () => ({env: mockEnv})); const text = 'Create a calendar invite with Jon Burke and Jacky Doe at One Boulevard Plaza at 7pm tomorrow'; +// An organization with no experiment on, and one with banks. +const NOBODY = {experiments: []}; +const WITH_BANKS = {experiments: ['plaid' as const]}; + // What OpenRouter answers: the model's JSON as the message's text. let content = ''; let status = 200; @@ -19,7 +23,7 @@ function answer(values: Record) { function sent(): { model: string; messages: Array<{role: string; content: string}>; - provider: {order: string[]}; + provider: {order: string[]; data_collection: string}; response_format: {json_schema: {schema: {required: string[]; properties: {widget: {enum: string[]}}}}}; } { return JSON.parse(String(fetchSpy.mock.calls.at(-1)?.[1]?.body)); @@ -47,11 +51,12 @@ beforeEach(() => { describe('classifyIntent', () => { test('asks gpt-oss-120b, on Cerebras first, for the widget and every field in one answer', async () => { const {classifyIntent} = await import('./classify'); - await classifyIntent(text); + await classifyIntent(text, NOBODY); const body = sent(); expect(body.model).toBe('openai/gpt-oss-120b'); expect(body.provider.order).toEqual(['Cerebras', 'Groq']); + expect(body.provider.data_collection).toBe('deny'); expect(body.messages[1]).toEqual({role: 'user', content: text}); expect(body.messages[0]?.content).toContain('- find_file: Find or open a file'); const schema = body.response_format.json_schema.schema; @@ -100,7 +105,7 @@ describe('classifyIntent', () => { test("answers the picked widget's fields alone, each value trimmed and once", async () => { const {classifyIntent} = await import('./classify'); - const intent = (await classifyIntent(text)).unwrap(); + const intent = (await classifyIntent(text, NOBODY)).unwrap(); expect(intent.widget).toBe('new_event'); expect(intent.fields).toEqual([ @@ -113,7 +118,7 @@ describe('classifyIntent', () => { test('answers search terms as each one\'s words joined by " | "', async () => { answer({widget: 'find_email', from: null, about: [['flight', ' itinerary ', 'e|ticket'], ['SFO'], []]}); const {classifyIntent} = await import('./classify'); - const intent = (await classifyIntent('find the email about my flight to sfo')).unwrap(); + const intent = (await classifyIntent('find the email about my flight to sfo', NOBODY)).unwrap(); expect(intent.fields).toEqual([ {id: 'from', name: 'From', values: []}, @@ -123,6 +128,39 @@ describe('classifyIntent', () => { expect(schema.properties.about).toEqual({type: 'array', items: {type: 'array', items: {type: 'string'}}}); }); + test('offers the bank widgets, and their fields, only to an organization with the plaid experiment', async () => { + const {classifyIntent} = await import('./classify'); + await classifyIntent('how much did I spend on restaurants last month', WITH_BANKS); + + const withBanks = sent(); + expect(withBanks.messages[0]?.content).toContain('- find_transactions: Find bank or card transactions'); + expect(withBanks.messages[0]?.content).toContain('FOOD_AND_DRINK (restaurants, groceries'); + expect(withBanks.response_format.json_schema.schema.properties.widget.enum).toContain('show_balances'); + expect(withBanks.response_format.json_schema.schema.required).toEqual(expect.arrayContaining(['balanceAccount', 'merchant', 'category', 'flow', 'transactionAccount'])); + + await classifyIntent('how much did I spend on restaurants last month', NOBODY); + const without = sent(); + expect(without.messages[0]?.content).not.toContain('find_transactions'); + expect(without.response_format.json_schema.schema.properties.widget.enum).not.toContain('show_balances'); + expect(without.response_format.json_schema.schema.required).not.toContain('merchant'); + }); + + test('answers a bank widget to an organization with the experiment, and none to one without', async () => { + const {classifyIntent} = await import('./classify'); + answer({widget: 'find_transactions', merchant: 'uber', category: 'TRANSPORTATION', flow: 'out', transactionAccount: null}); + + expect((await classifyIntent('uber charges this month', WITH_BANKS)).unwrap()).toMatchObject({ + widget: 'find_transactions', + fields: [ + {id: 'merchant', values: ['uber']}, + {id: 'category', values: ['TRANSPORTATION']}, + {id: 'flow', values: ['out']}, + {id: 'transactionAccount', values: []}, + ], + }); + expect((await classifyIntent('uber charges this month', NOBODY)).unwrap()).toMatchObject({widget: 'none', fields: []}); + }); + test("every field id is one widget's alone, as the model answers them all in one object", async () => { const {WIDGETS} = await import('./widgets'); const ids = WIDGETS.flatMap((widget) => widget.fields.map((field) => field.id)); @@ -131,7 +169,7 @@ describe('classifyIntent', () => { test('holds a unit to the units there are', async () => { const {classifyIntent} = await import('./classify'); - await classifyIntent('5 miles in km'); + await classifyIntent('5 miles in km', NOBODY); const schema = sent().response_format.json_schema.schema as unknown as {properties: Record}>}; expect(schema.properties.fromUnit?.enum).toContain('mi'); @@ -142,7 +180,7 @@ describe('classifyIntent', () => { const {classifyIntent} = await import('./classify'); for (const widget of ['none', 'make_coffee']) { answer({widget}); - expect((await classifyIntent('hi')).unwrap()).toMatchObject({widget: 'none', fields: []}); + expect((await classifyIntent('hi', NOBODY)).unwrap()).toMatchObject({widget: 'none', fields: []}); } }); @@ -150,7 +188,7 @@ describe('classifyIntent', () => { const {classifyIntent} = await import('./classify'); answer({widget: 'incomplete'}); - expect((await classifyIntent('find the')).unwrap()).toMatchObject({widget: 'incomplete', fields: []}); + expect((await classifyIntent('find the', NOBODY)).unwrap()).toMatchObject({widget: 'incomplete', fields: []}); expect(sent().messages[0]?.content).toContain('- incomplete: the text is cut off'); }); @@ -158,7 +196,7 @@ describe('classifyIntent', () => { delete mockEnv.OPENROUTER_KEY; const {classifyIntent} = await import('./classify'); - expect((await classifyIntent(text)).unwrapErr()).toMatchObject({kind: 'unavailable', message: 'The home page is not set up: set `OPENROUTER_KEY`.'}); + expect((await classifyIntent(text, NOBODY)).unwrapErr()).toMatchObject({kind: 'unavailable', message: 'The home page is not set up: set `OPENROUTER_KEY`.'}); expect(fetchSpy).not.toHaveBeenCalled(); }); @@ -166,11 +204,11 @@ describe('classifyIntent', () => { const {classifyIntent} = await import('./classify'); status = 503; - expect((await classifyIntent(text)).unwrapErr().kind).toBe('provider_unreachable'); + expect((await classifyIntent(text, NOBODY)).unwrapErr().kind).toBe('provider_unreachable'); status = 200; content = 'Sure! Here is the form:'; - expect((await classifyIntent(text)).unwrapErr().kind).toBe('provider_unreachable'); + expect((await classifyIntent(text, NOBODY)).unwrapErr().kind).toBe('provider_unreachable'); answer({attendees: ['Jon']}); - expect((await classifyIntent(text)).unwrapErr().kind).toBe('provider_unreachable'); + expect((await classifyIntent(text, NOBODY)).unwrapErr().kind).toBe('provider_unreachable'); }); }); diff --git a/packages/api/src/intent/classify.ts b/packages/api/src/intent/classify.ts index df506d7b..6134eb63 100644 --- a/packages/api/src/intent/classify.ts +++ b/packages/api/src/intent/classify.ts @@ -1,10 +1,10 @@ import {Err, Ok, Result} from 'ts-results-es'; import {z} from 'zod'; -import {ApiErr, type ApiError, httpRequest, parseSchema} from '@proxy/utils'; +import {ApiErr, type ApiError, type Experiment, httpRequest, parseSchema} from '@proxy/utils'; import {env} from '../utils/env'; -import {INCOMPLETE, NO_WIDGET, WIDGETS, type WidgetField} from './widgets'; +import {INCOMPLETE, NO_WIDGET, offeredWidgets, type Widget, type WidgetField} from './widgets'; /** * What a person typed into the home page's input, in one request to gpt-oss-120b over OpenRouter, @@ -12,7 +12,9 @@ import {INCOMPLETE, NO_WIDGET, WIDGETS, type WidgetField} from './widgets'; * text, in about 150 ms. Picked over Jev and GLiNER, one after the other, by trying them on typed * requests: as fast, and every field right on nine in ten where GLiNER managed half, since it * found no title it wasn't told of, no subject and no file's name. The answer is held to a JSON - * schema: the widget's id, and every widget's fields, the others left empty. + * schema: the widget's id, and every widget's fields, the others left empty. The widgets offered + * are the organization's: a widget behind an experiment it doesn't have is in neither the + * instructions nor the schema. */ const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions'; @@ -36,15 +38,15 @@ export type Intent = { ms: number; }; -const FIELDS = WIDGETS.flatMap((widget) => widget.fields); - -const INSTRUCTIONS = [ - 'Pick the widget for what the person typed into a command box, and fill in its fields from the text. Copy values from the text, but write the body of an email; never invent a person, place or address. Leave the fields of other widgets empty. Answer with JSON only.', - 'Widgets:', - ...WIDGETS.map((widget) => `- ${widget.id}: ${widget.description}. Fields: ${widget.fields.map((field) => `${field.id}${field.many ? ' (list)' : ''}: ${field.hint}`).join('; ')}`), - `- ${INCOMPLETE}: the text is cut off or says too little to tell what is wanted yet, as it is while still being typed ("find the", "email", "what's")`, - `- ${NO_WIDGET}: a whole request that nothing above does ("summarize my week", "what's the weather tomorrow")`, -].join('\n'); +function instructions(widgets: Widget[]): string { + return [ + 'Pick the widget for what the person typed into a command box, and fill in its fields from the text. Copy values from the text, but write the body of an email; never invent a person, place or address. Leave the fields of other widgets empty. Answer with JSON only.', + 'Widgets:', + ...widgets.map((widget) => `- ${widget.id}: ${widget.description}. Fields: ${widget.fields.map((field) => `${field.id}${field.many ? ' (list)' : ''}: ${field.hint}`).join('; ')}`), + `- ${INCOMPLETE}: the text is cut off or says too little to tell what is wanted yet, as it is while still being typed ("find the", "email", "what's"); never a whole sentence or question, which ends in a question mark or a full stop`, + `- ${NO_WIDGET}: a whole request that nothing above does ("summarize my week", "what's the weather tomorrow")`, + ].join('\n'); +} function fieldSchema(field: WidgetField) { if (field.options) { @@ -59,15 +61,35 @@ function fieldSchema(field: WidgetField) { return {type: ['string', 'null']}; } -const JSON_SCHEMA = { - type: 'object', - additionalProperties: false, - required: ['widget', ...FIELDS.map((field) => field.id)], - properties: { - widget: {type: 'string', enum: [...WIDGETS.map((widget) => widget.id), INCOMPLETE, NO_WIDGET]}, - ...Object.fromEntries(FIELDS.map((field) => [field.id, fieldSchema(field)])), - }, -}; +function jsonSchema(widgets: Widget[]) { + const fields = widgets.flatMap((widget) => widget.fields); + return { + type: 'object', + additionalProperties: false, + required: ['widget', ...fields.map((field) => field.id)], + properties: { + widget: {type: 'string', enum: [...widgets.map((widget) => widget.id), INCOMPLETE, NO_WIDGET]}, + ...Object.fromEntries(fields.map((field) => [field.id, fieldSchema(field)])), + }, + }; +} + +type Form = {widgets: Widget[]; instructions: string; schema: ReturnType}; + +// The instructions and schema for each set of widgets offered, built once: there are few sets. +const forms = new Map(); + +function formFor(holder: {experiments: Experiment[]}): Form { + const widgets = offeredWidgets(holder); + const key = widgets.map((widget) => widget.id).join(' '); + const kept = forms.get(key); + if (kept) { + return kept; + } + const form = {widgets, instructions: instructions(widgets), schema: jsonSchema(widgets)}; + forms.set(key, form); + return form; +} const completionSchema = z.object({choices: z.array(z.object({message: z.object({content: z.string()})})).min(1)}); @@ -92,12 +114,13 @@ function cleaned(value: AnswerValue | undefined): string[] { return unique(value.map((one) => (Array.isArray(one) ? unique(one.map((word) => word.replace(/\|/g, ' '))).join(' | ') : one))); } -/** Which widget fits `text`, and its fields as the model fills them in. */ -export async function classifyIntent(text: string): Promise> { +/** Which of the widgets offered to `holder` fits `text`, and its fields as the model fills them in. */ +export async function classifyIntent(text: string, holder: {experiments: Experiment[]}): Promise> { if (!env.OPENROUTER_KEY) { return Err(ApiErr.unavailable('The home page is not set up: set `OPENROUTER_KEY`.')); } + const form = formFor(holder); const start = performance.now(); const answered = await httpRequest( OPENROUTER_URL, @@ -107,7 +130,7 @@ export async function classifyIntent(text: string): Promise candidate.id === answer.value.widget); + const widget = form.widgets.find((candidate) => candidate.id === answer.value.widget); if (!widget) { return Ok({widget: NO_WIDGET, fields: [], ms}); } diff --git a/packages/api/src/intent/route.test.ts b/packages/api/src/intent/route.test.ts index 91dd29ca..56adc545 100644 --- a/packages/api/src/intent/route.test.ts +++ b/packages/api/src/intent/route.test.ts @@ -6,7 +6,7 @@ const classifyIntent = mock(); mock.module('./classify', () => ({classifyIntent})); function makeRequest(body: unknown) { - return {user: {userId: 'user-1'}, body} as never; + return {user: {userId: 'user-1', experiments: ['plaid']}, body} as never; } beforeEach(() => { @@ -15,12 +15,12 @@ beforeEach(() => { }); describe('handleClassifyIntentRoute', () => { - test('classifies the text, trimmed', async () => { + test("classifies the text, trimmed, among the widgets the person's organization is offered", async () => { const {handleClassifyIntentRoute} = await import('./route'); const result = await handleClassifyIntentRoute(makeRequest({text: ' who is hugo '})); expect(result.isOk()).toBe(true); - expect(classifyIntent).toHaveBeenCalledWith('who is hugo'); + expect(classifyIntent).toHaveBeenCalledWith('who is hugo', {userId: 'user-1', experiments: ['plaid']}); }); test('refuses empty text, and text past 500 characters, without asking the models', async () => { diff --git a/packages/api/src/intent/route.ts b/packages/api/src/intent/route.ts index a67d7741..ccc794b5 100644 --- a/packages/api/src/intent/route.ts +++ b/packages/api/src/intent/route.ts @@ -12,6 +12,6 @@ const intentBodySchema = z.object({text: z.string().trim().min(1).max(500)}); export function handleClassifyIntentRoute(request: AuthenticatedRequest): Promise> { return Do(async ($) => { const {text} = $(parseSchema(intentBodySchema, request.body)); - return $(await classifyIntent(text)); + return $(await classifyIntent(text, request.user)); }); } diff --git a/packages/api/src/intent/widgets.ts b/packages/api/src/intent/widgets.ts index ce4a4ea7..f0665455 100644 --- a/packages/api/src/intent/widgets.ts +++ b/packages/api/src/intent/widgets.ts @@ -1,10 +1,13 @@ +import {type Experiment, hasExperiment} from '@proxy/utils'; +import {TRANSACTION_CATEGORIES} from '@proxy/utils/transaction-categories'; import {UNITS} from '@proxy/utils/units'; /** * The widgets the home page shows for what a person types, Sana's way: a small, fast LLM picks the * one that fits and fills in its fields from the text. Most are meant for one kind of data: - * Calendar, Contacts, Gmail or Drive; Math and Convert units are worked out in the web app. Field ids are unique across widgets, as the LLM answers - * every widget's fields in one object. + * Calendar, Contacts, Gmail or Drive, or a bank; Math and Convert units are worked out in the web + * app. Field ids are unique across widgets, as the LLM answers every widget's fields in one object. + * A widget behind an experiment is offered only to an organization with it on. */ export type WidgetField = { @@ -33,6 +36,8 @@ export type Widget = { /** What the LLM reads to tell the widgets apart. */ description: string; fields: WidgetField[]; + /** The experiment an organization needs on to be offered it, as banks are behind `plaid`. */ + experiment?: Experiment; }; // Times aren't fields: the web app reads them from the whole text, which no field holds whole. A @@ -40,6 +45,10 @@ export type Widget = { // The units Convert units knows, each as the model is told of it: `mi` (mile). const UNIT_LIST = UNITS.map((unit) => `${unit.id} (${unit.name})`).join(', '); +// The kinds of spending Transactions knows, each as the model is told of it: `FOOD_AND_DRINK` +// (restaurants, groceries, coffee, bars, delivery, eating out). +const CATEGORY_LIST = TRANSACTION_CATEGORIES.map((category) => `${category.id} (${category.examples})`).join(', '); + export const WIDGETS: Widget[] = [ { id: 'new_event', @@ -149,8 +158,42 @@ export const WIDGETS: Widget[] = [ {id: 'toUnit', name: 'To', hint: 'the unit to convert it to, of the same list; null when not said', options: UNITS.map((unit) => unit.id)}, ], }, + { + id: 'show_balances', + name: 'Balances', + experiment: 'plaid', + description: 'See the balance of a bank account, card or loan, or how much money the person has or owes in all', + fields: [{id: 'balanceAccount', name: 'Account', hint: 'the account asked about, as written without my ("checking", "amex", "savings", "credit card"); null for every account'}], + }, + { + id: 'find_transactions', + name: 'Transactions', + experiment: 'plaid', + description: + 'Find bank or card transactions, charges, payments and deposits, or add up what was spent or received: at a merchant, of a kind, on an account, over some days ("how much did I spend on restaurants last month", "find the uber charge", "when did I last get paid")', + fields: [ + { + id: 'merchant', + name: 'Merchant', + hint: 'the business or person paid or paying, as written ("uber", "netflix", "my landlord"), without any day or time; never a kind of thing bought ("groceries", "rent", "coffee"), which is category, nor an account or card of the person\'s own ("my sapphire", "checking"), which is transactionAccount; null when no one is named', + }, + { + id: 'category', + name: 'Category', + hint: `the kind of spending or income asked about, one of: ${CATEGORY_LIST}; null when none is said`, + options: TRANSACTION_CATEGORIES.map((category) => category.id), + }, + {id: 'flow', name: 'Flow', hint: '"in" for money coming in (deposits, paychecks, refunds, income), "out" for money spent or paid; null when either or not said', options: ['in', 'out']}, + {id: 'transactionAccount', name: 'Account', hint: 'the account the transactions are on, as written without my ("checking", "amex"); null when not said'}, + ], + }, ]; +/** The widgets offered to an organization: every one not behind an experiment, and those behind one it has on. */ +export function offeredWidgets(holder: {experiments: Experiment[]}): Widget[] { + return WIDGETS.filter((widget) => !widget.experiment || hasExperiment(holder, widget.experiment)); +} + /** The answer when what was asked is whole and none of the widgets does it. */ export const NO_WIDGET = 'none'; diff --git a/packages/api/src/records/plaid-connector.ts b/packages/api/src/records/plaid-connector.ts index 62e86f02..4d4b7df1 100644 --- a/packages/api/src/records/plaid-connector.ts +++ b/packages/api/src/records/plaid-connector.ts @@ -87,12 +87,14 @@ function sentenceCase(text: string): string { return text.charAt(0).toUpperCase() + text.slice(1); } -function accountType(account: PlaidAccount): string { +/** An account's kind as a person reads it: "Checking", "Credit card", "IRA". */ +export function accountType(account: PlaidAccount): string { const kind = account.subtype ?? account.type; return ACRONYMS[kind] ?? sentenceCase(kind); } -function accountNumber(account: PlaidAccount): string { +/** The account number's last digits as the bank shows them, "••0000", or nothing. */ +export function accountNumber(account: PlaidAccount): string { return account.mask ? `••${account.mask}` : ''; } diff --git a/packages/api/src/server/create-api-router.ts b/packages/api/src/server/create-api-router.ts index 5fa09bb1..8a80576c 100644 --- a/packages/api/src/server/create-api-router.ts +++ b/packages/api/src/server/create-api-router.ts @@ -37,6 +37,7 @@ import {handleRegisterClientRoute} from '../oauth/register'; import {handleTokenRoute} from '../oauth/token'; import {handleCreateRecordRoute, handleDeleteRecordRoute, handleGetRecordRoute, handleListRecordsRoute, handleUpdateRecordRoute} from '../records/route'; import {handleAgendaRoute, handleFindEventsRoute} from '../widgets/agenda'; +import {handleBankAccountsRoute, handleBankTransactionsRoute} from '../widgets/bank'; import {handleEditEventRoute} from '../widgets/edit-event'; import {handleFindEmailsRoute, handleMailboxesRoute, handleWriteEmailRoute} from '../widgets/emails'; import {handleFindFilesRoute} from '../widgets/files'; @@ -134,6 +135,8 @@ export function createApiRouter(): express.Router { router.get('/api/widgets/mailboxes', withAuthResult('Widget mailboxes route', handleMailboxesRoute)); router.get('/api/widgets/files', withAuthResult('Widget find files route', handleFindFilesRoute)); router.get('/api/widgets/rates', withAuthResult('Widget exchange rates route', handleRatesRoute)); + router.get('/api/widgets/accounts', withAuthResult('Widget bank accounts route', handleBankAccountsRoute)); + router.get('/api/widgets/transactions', withAuthResult('Widget bank transactions route', handleBankTransactionsRoute)); // The agent side: its own sign-in and cookie, and every request checked against the agent's // access and logged. diff --git a/packages/api/src/widgets/bank.test.ts b/packages/api/src/widgets/bank.test.ts new file mode 100644 index 00000000..f49a4025 --- /dev/null +++ b/packages/api/src/widgets/bank.test.ts @@ -0,0 +1,144 @@ +import {beforeEach, describe, expect, mock, test} from 'bun:test'; +import {Err, Ok} from 'ts-results-es'; + +import {ApiErr} from '@proxy/utils'; + +const getAccounts = mock(); +const getTransactions = mock(); + +mock.module('../connections/plaid/plaid-api', () => ({getAccounts, getTransactions})); +mock.module('../connections/plaid/plaid-client', () => ({ + callPlaid: (connection: {credential: string}, call: (client: unknown, accessToken: string) => unknown) => call({}, connection.credential), +})); +mock.module('./sources', () => ({ + findSources: async () => Ok([{connection: {id: 'bank-1', integrationId: 'plaid', account: 'First Platypus Bank', name: null, credential: 'access-1'}, integration: {id: 'plaid'}}]), + summarize: (source: {connection: {id: string}}) => ({connectionId: source.connection.id}), +})); +mock.module('../utils/user-org', () => ({requireUserOrgId: async () => Ok('org-1')})); + +const CHECKING = { + account_id: 'checking', + name: 'Plaid Checking', + mask: '0000', + type: 'depository', + subtype: 'checking', + balances: {current: 110, available: 100, limit: null, iso_currency_code: 'USD'}, +}; + +const CARD = { + account_id: 'card', + name: 'Plaid Credit Card', + mask: '3333', + type: 'credit', + subtype: 'credit card', + balances: {current: 410, available: null, limit: 2000, iso_currency_code: 'USD'}, +}; + +const UBER = { + transaction_id: 'uber', + account_id: 'checking', + amount: 5.4, + iso_currency_code: 'USD', + date: '2026-10-11', + name: 'Uber 063015 SF**POOL**', + merchant_name: 'Uber', + pending: false, + personal_finance_category: {primary: 'TRANSPORTATION', detailed: 'TRANSPORTATION_TAXIS_AND_RIDE_SHARES'}, +}; + +const PAYROLL = { + transaction_id: 'payroll', + account_id: 'checking', + amount: -2500, + iso_currency_code: 'USD', + date: '2026-10-10', + name: 'ACME PAYROLL', + merchant_name: null, + pending: true, + personal_finance_category: null, +}; + +function request(query: Record = {}) { + return {user: {userId: 'user-1'}, query} as never; +} + +beforeEach(() => { + getAccounts.mockReset(); + getTransactions.mockReset(); + getAccounts.mockResolvedValue(Ok({accounts: [CHECKING, CARD], institution: 'First Platypus Bank'})); + getTransactions.mockResolvedValue(Ok({accounts: [CHECKING, CARD], transactions: [UBER, PAYROLL], total: 2})); +}); + +describe('handleBankAccountsRoute', () => { + test('answers each account with its balances as numbers, a card’s as owed', async () => { + const {handleBankAccountsRoute} = await import('./bank'); + const answer = (await handleBankAccountsRoute(request())).unwrap(); + + expect(answer.sources.map((source) => source.connectionId)).toEqual(['bank-1']); + expect(answer.accounts).toEqual([ + {connectionId: 'bank-1', id: 'checking', name: 'Plaid Checking', type: 'Checking', mask: '0000', owed: false, balance: 110, available: 100, limit: null, currency: 'USD'}, + {connectionId: 'bank-1', id: 'card', name: 'Plaid Credit Card', type: 'Credit card', mask: '3333', owed: true, balance: 410, available: null, limit: 2000, currency: 'USD'}, + ]); + expect(getAccounts).toHaveBeenCalledWith({}, 'access-1'); + }); + + test('a bank that can’t be read is a failure beside the rest', async () => { + getAccounts.mockResolvedValue(Err(ApiErr.validationError('The bank needs its login again'))); + const {handleBankAccountsRoute} = await import('./bank'); + const answer = (await handleBankAccountsRoute(request())).unwrap(); + + expect(answer.accounts).toEqual([]); + expect(answer.failed).toEqual([{connectionId: 'bank-1', account: 'First Platypus Bank', message: 'The bank needs its login again'}]); + }); +}); + +describe('handleBankTransactionsRoute', () => { + test('reads the days asked for, signing amounts as a statement does, with the accounts they are on', async () => { + const {handleBankTransactionsRoute} = await import('./bank'); + const answer = (await handleBankTransactionsRoute(request({from: '2026-10-01', to: '2026-10-19'}))).unwrap(); + + expect(getTransactions).toHaveBeenCalledWith({}, 'access-1', {from: '2026-10-01', to: '2026-10-19', accountId: null, count: 500, offset: 0}); + expect(answer.transactions).toEqual([ + { + connectionId: 'bank-1', + accountId: 'checking', + id: 'uber', + date: '2026-10-11', + name: 'Uber 063015 SF**POOL**', + merchant: 'Uber', + amount: -5.4, + currency: 'USD', + category: 'TRANSPORTATION', + pending: false, + }, + {connectionId: 'bank-1', accountId: 'checking', id: 'payroll', date: '2026-10-10', name: 'ACME PAYROLL', merchant: null, amount: 2500, currency: 'USD', category: null, pending: true}, + ]); + expect(answer.accounts.map((account) => account.id)).toEqual(['checking', 'card']); + expect(answer.more).toBe(false); + }); + + test('reads on from where a page ended, up to three pages, and says when there were more', async () => { + getTransactions.mockResolvedValue(Ok({accounts: [CHECKING], transactions: [UBER], total: 5})); + const {handleBankTransactionsRoute} = await import('./bank'); + const answer = (await handleBankTransactionsRoute(request({from: '2026-10-01', to: '2026-10-19'}))).unwrap(); + + expect(getTransactions.mock.calls.map((call) => (call[2] as {offset: number}).offset)).toEqual([0, 1, 2]); + expect(answer.transactions).toHaveLength(3); + expect(answer.more).toBe(true); + }); + + test('refuses days that aren’t, a range going backwards, and one past two years', async () => { + const {handleBankTransactionsRoute} = await import('./bank'); + + for (const query of [ + {from: '2026-02-30', to: '2026-03-01'}, + {from: '2026-10-19', to: '2026-10-01'}, + {from: '2024-01-01', to: '2026-10-19'}, + {from: 'yesterday', to: 'today'}, + ]) { + const kind = (await handleBankTransactionsRoute(request(query))).unwrapErr().kind; + expect(['parse_error', 'validation_error']).toContain(kind); + } + expect(getTransactions).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/api/src/widgets/bank.ts b/packages/api/src/widgets/bank.ts new file mode 100644 index 00000000..558adb7b --- /dev/null +++ b/packages/api/src/widgets/bank.ts @@ -0,0 +1,192 @@ +import {Err, type Result} from 'ts-results-es'; +import {z} from 'zod'; + +import {ApiErr, type ApiError, Do, parseSchema} from '@proxy/utils'; +import {daysBetween} from '@proxy/utils/days'; +import type {IntegrationId} from '@proxy/integrations'; + +import type {AuthenticatedRequest} from '../server/middleware/require-auth'; +import {requireUserOrgId} from '../utils/user-org'; +import {getAccounts, getTransactions, type PlaidAccount, type PlaidTransaction} from '../connections/plaid/plaid-api'; +import {callPlaid} from '../connections/plaid/plaid-client'; +import {accountNumber, accountType} from '../records/plaid-connector'; +import {eachSource, type Failure} from './each-source'; +import {findSources, summarize, type Source, type SourceSummary} from './sources'; + +/** + * Every bank connected, for the Balances and Transactions widgets: each account with its balances, + * and the transactions of every account between two days, which the web app filters by merchant, + * kind, account and direction as the person types, so a stretch of days is read from the bank once. + * Amounts are numbers here, as a statement signs them: money out of an account negative, money in + * positive, and what a card or a loan owes positive, marked as owed. + */ + +/** An account as the widgets show it: whose, what kind, and what it holds or owes. */ +export type BankAccount = { + connectionId: string; + id: string; + name: string; + /** "Checking", "Credit card", "Mortgage". */ + type: string; + /** The account number's last digits, "0000", or nothing. */ + mask: string; + /** A card or a loan: its balance is what is owed. */ + owed: boolean; + balance: number | null; + available: number | null; + limit: number | null; + /** The ISO code, or a bank's own for a currency without one. */ + currency: string; +}; + +export type BankTransaction = { + connectionId: string; + accountId: string; + id: string; + /** The day, `2026-10-11`. */ + date: string; + /** The bank's own description. */ + name: string; + /** The merchant, where the integration knows it. */ + merchant: string | null; + amount: number; + currency: string; + /** One of `TRANSACTION_CATEGORIES`' ids, or none. */ + category: string | null; + pending: boolean; +}; + +type Transactions = {accounts: BankAccount[]; transactions: BankTransaction[]; more: boolean}; + +/** How an integration's banks are read; a bank integration joins the widgets with one. */ +type BankAdapter = { + accounts: (source: Source) => Promise>; + /** The transactions between two days, both included, newest first, and whether there were more than were read. */ + transactions: (source: Source, range: DayRange) => Promise>; +}; + +export type DayRange = {from: string; to: string}; + +// Plaid answers at most 500 transactions a call; a range is read in a few calls, then given up on. +const PAGE = 500; +const PAGES = 3; + +// Plaid has at most two years before the bank was connected. +const MAX_DAYS = 731; + +function currencyOf(money: {iso_currency_code?: string | null; unofficial_currency_code?: string | null}): string { + return money.iso_currency_code ?? money.unofficial_currency_code ?? ''; +} + +function bankAccount(connectionId: string, account: PlaidAccount): BankAccount { + const {balances} = account; + return { + connectionId, + id: account.account_id, + name: account.name, + type: accountType(account), + mask: accountNumber(account).replace(/^••/, ''), + owed: account.type === 'credit' || account.type === 'loan', + balance: balances.current ?? null, + available: balances.available ?? null, + limit: balances.limit ?? null, + currency: currencyOf(balances), + }; +} + +function bankTransaction(connectionId: string, transaction: PlaidTransaction): BankTransaction { + return { + connectionId, + accountId: transaction.account_id, + id: transaction.transaction_id, + date: transaction.date, + name: transaction.name, + merchant: transaction.merchant_name ?? null, + // Plaid's amount is what left the account; a statement shows it taken off. + amount: transaction.amount === 0 ? 0 : -transaction.amount, + currency: currencyOf(transaction), + category: transaction.personal_finance_category?.primary ?? null, + pending: transaction.pending, + }; +} + +const plaidBank: BankAdapter = { + accounts: (source) => + callPlaid(source.connection, (client, accessToken) => + Do(async ($) => { + const {accounts} = $(await getAccounts(client, accessToken)); + return accounts.map((account) => bankAccount(source.connection.id, account)); + }), + ), + transactions: (source, range) => + callPlaid(source.connection, (client, accessToken) => + Do(async ($) => { + const transactions: BankTransaction[] = []; + let accounts: PlaidAccount[] = []; + let total = 0; + for (let page = 0; page < PAGES; page += 1) { + const answer = $(await getTransactions(client, accessToken, {...range, accountId: null, count: PAGE, offset: transactions.length})); + transactions.push(...answer.transactions.map((transaction) => bankTransaction(source.connection.id, transaction))); + accounts = answer.accounts; + total = answer.total; + if (answer.transactions.length === 0 || transactions.length >= total) { + break; + } + } + return {accounts: accounts.map((account) => bankAccount(source.connection.id, account)), transactions, more: transactions.length < total}; + }), + ), +}; + +const ADAPTERS: Partial> = {plaid: plaidBank}; + +// Every bank connected whose collection a widget can read, with how. +async function bankSources(orgId: string, collectionId: 'accounts' | 'transactions'): Promise, ApiError>> { + return (await findSources(orgId, collectionId, 'list')).map((sources) => + sources.flatMap((source) => { + const adapter = ADAPTERS[source.integration.id]; + return adapter ? [{...source, adapter}] : []; + }), + ); +} + +export type BankAccounts = {sources: SourceSummary[]; accounts: BankAccount[]; failed: Failure[]}; + +/** `GET /api/widgets/accounts`: every bank's accounts with their balances. */ +export function handleBankAccountsRoute(request: AuthenticatedRequest): Promise> { + return Do(async ($) => { + const orgId = $(await requireUserOrgId(request)); + const sources = $(await bankSources(orgId, 'accounts')); + const {found, failed} = await eachSource(sources, (source) => source.adapter.accounts(source)); + return {sources: sources.map(summarize), accounts: found.flatMap(({value}) => value), failed}; + }); +} + +// A day the calendar has, as `2026-10-11`: not 2026-02-30, which Date rolls into March. +const day = z.string().refine((value) => /^\d{4}-\d{2}-\d{2}$/.test(value) && new Date(`${value}T00:00:00Z`).toISOString().startsWith(value), 'Not a day'); + +const rangeSchema = z.object({from: day, to: day}); + +export type BankTransactions = {sources: SourceSummary[]; accounts: BankAccount[]; transactions: BankTransaction[]; more: boolean; failed: Failure[]}; + +/** `GET /api/widgets/transactions?from=2026-09-01&to=2026-09-30`: every bank's transactions between two days, both included, newest first. */ +export function handleBankTransactionsRoute(request: AuthenticatedRequest): Promise> { + return Do(async ($) => { + const range = $(parseSchema(rangeSchema, request.query)); + const days = daysBetween(range.from, range.to); + if (days < 0 || days >= MAX_DAYS) { + return $(Err(ApiErr.validationError('Ask for at most two years, from before to'))); + } + const orgId = $(await requireUserOrgId(request)); + const sources = $(await bankSources(orgId, 'transactions')); + const {found, failed} = await eachSource(sources, (source) => source.adapter.transactions(source, range)); + const transactions = found.flatMap(({value}) => value.transactions).sort((a, b) => b.date.localeCompare(a.date)); + return { + sources: sources.map(summarize), + accounts: found.flatMap(({value}) => value.accounts), + transactions, + more: found.some(({value}) => value.more), + failed, + }; + }); +} diff --git a/packages/utils/package.json b/packages/utils/package.json index 68a422bb..8ef53932 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -6,7 +6,8 @@ "exports": { ".": "./src/index.ts", "./days": "./src/days.ts", - "./units": "./src/units.ts" + "./units": "./src/units.ts", + "./transaction-categories": "./src/transaction-categories.ts" }, "scripts": { "test": "bun test", diff --git a/packages/utils/src/transaction-categories.ts b/packages/utils/src/transaction-categories.ts new file mode 100644 index 00000000..569a0f32 --- /dev/null +++ b/packages/utils/src/transaction-categories.ts @@ -0,0 +1,86 @@ +/** + * The kinds of spending the home page's Transactions widget knows, Plaid's primary personal finance + * categories: the api offers their ids to the model to pick from, so "restaurants", "eating out" and + * "groceries" are all `FOOD_AND_DRINK`, and the web app filters a bank's transactions by them. Each + * transaction a bank integration answers the widget with carries one of these ids, or none. + */ + +export type TransactionCategory = { + id: string; + /** As a sentence writes it: "Food and drink". */ + name: string; + /** What people call it, for the model: "restaurants, groceries, coffee, bars". */ + examples: string; +}; + +export const TRANSACTION_CATEGORIES: TransactionCategory[] = [ + {id: 'INCOME', name: 'Income', examples: 'paychecks, salary, dividends, interest earned'}, + { + id: 'TRANSFER_IN', + name: 'Transfers in', + examples: 'deposits, money moved in from another account, refunds', + }, + { + id: 'TRANSFER_OUT', + name: 'Transfers out', + examples: 'money moved to another account, savings, investments, Venmo', + }, + { + id: 'LOAN_PAYMENTS', + name: 'Loan payments', + examples: 'mortgage, car loan, student loan and credit card payments', + }, + { + id: 'BANK_FEES', + name: 'Bank fees', + examples: 'overdraft, ATM and foreign transaction fees, interest charged', + }, + { + id: 'ENTERTAINMENT', + name: 'Entertainment', + examples: 'streaming, movies, concerts, games, sports events', + }, + { + id: 'FOOD_AND_DRINK', + name: 'Food and drink', + examples: 'restaurants, groceries, coffee, bars, delivery, eating out', + }, + { + id: 'GENERAL_MERCHANDISE', + name: 'Shopping', + examples: 'shopping, clothing, electronics, online stores, Amazon', + }, + { + id: 'HOME_IMPROVEMENT', + name: 'Home improvement', + examples: 'furniture, hardware, repairs, gardening', + }, + {id: 'MEDICAL', name: 'Medical', examples: 'doctors, dentists, pharmacies, veterinarians, gyms'}, + {id: 'PERSONAL_CARE', name: 'Personal care', examples: 'hair, beauty, laundry'}, + { + id: 'GENERAL_SERVICES', + name: 'Services', + examples: 'insurance, education, child care, accounting, postage, storage', + }, + { + id: 'GOVERNMENT_AND_NON_PROFIT', + name: 'Government and non-profit', + examples: 'taxes, donations, government fees', + }, + { + id: 'TRANSPORTATION', + name: 'Transportation', + examples: 'gas, parking, tolls, taxis, Uber, public transit', + }, + {id: 'TRAVEL', name: 'Travel', examples: 'flights, hotels, rental cars, Airbnb'}, + { + id: 'RENT_AND_UTILITIES', + name: 'Rent and utilities', + examples: 'rent, electricity, water, internet, phone bills', + }, +]; + +/** The category by its id, or none. */ +export function findTransactionCategory(id: string): TransactionCategory | undefined { + return TRANSACTION_CATEGORIES.find((category) => category.id === id); +} diff --git a/packages/web/src/client/widgets-client.ts b/packages/web/src/client/widgets-client.ts index 014f2a93..583740b4 100644 --- a/packages/web/src/client/widgets-client.ts +++ b/packages/web/src/client/widgets-client.ts @@ -253,3 +253,74 @@ export type DayRates = z.infer; export function getRates() { return apiRequest('GET', '/api/widgets/rates', ratesSchema); } + +const bankAccountSchema = z.object({ + connectionId: z.string(), + id: z.string(), + name: z.string(), + // "Checking", "Credit card", "Mortgage". + type: z.string(), + // The account number's last digits, or nothing. + mask: z.string(), + // A card or a loan: its balance is what is owed. + owed: z.boolean(), + balance: z.number().nullable(), + available: z.number().nullable(), + limit: z.number().nullable(), + currency: z.string(), +}); + +const bankAccountsSchema = z.object({ + sources: z.array(sourceSchema), + accounts: z.array(bankAccountSchema), + failed: z.array(failureSchema), +}); + +export type BankAccount = z.infer; +export type BankAccounts = z.infer; + +/** Every bank's accounts with their balances. */ +export function getAccounts() { + return apiRequest('GET', '/api/widgets/accounts', bankAccountsSchema); +} + +const bankTransactionSchema = z.object({ + connectionId: z.string(), + accountId: z.string(), + id: z.string(), + // The day, 2026-10-11. + date: z.string(), + // The bank's own description, and the merchant where the bank knows it. + name: z.string(), + merchant: z.string().nullable(), + // As a statement signs it: money out negative, money in positive. + amount: z.number(), + currency: z.string(), + // One of TRANSACTION_CATEGORIES' ids, or none. + category: z.string().nullable(), + pending: z.boolean(), +}); + +const bankTransactionsSchema = z.object({ + sources: z.array(sourceSchema), + accounts: z.array(bankAccountSchema), + transactions: z.array(bankTransactionSchema), + // Whether a bank had more than were read. + more: z.boolean(), + failed: z.array(failureSchema), +}); + +export type BankTransaction = z.infer; +export type BankTransactions = z.infer; + +/** Two days, both included, as 2026-09-01. */ +export type DayRange = {from: string; to: string}; + +/** Every bank's transactions between two days, newest first, with the accounts they are on. */ +export function findTransactions(range: DayRange) { + return apiRequest( + 'GET', + `/api/widgets/transactions${query({from: range.from, to: range.to})}`, + bankTransactionsSchema, + ); +} diff --git a/packages/web/src/components/widgets/balances-widget.tsx b/packages/web/src/components/widgets/balances-widget.tsx new file mode 100644 index 00000000..af4e831f --- /dev/null +++ b/packages/web/src/components/widgets/balances-widget.tsx @@ -0,0 +1,159 @@ +import {LandmarkIcon} from 'lucide-react'; +import {useState} from 'react'; +import type {Intent} from '@/client/intent-client'; +import type {BankAccount, Source} from '@/client/widgets-client'; +import {ConnectWidget} from '@/components/widgets/connect-widget'; +import {Missing, WidgetFrame} from '@/components/widgets/widget-frame'; +import {formatMoney, matchAccounts} from '@/lib/bank-values'; +import {useAccounts} from '@/lib/widget-lookups'; +import {fieldTexts} from '@/lib/widget-values'; + +/** What a widget asks for when nothing is connected to read. */ +export const CONNECT_BANK = 'Connect a bank to see your balances and spending from here.'; + +// "Checking ••0000 · $100.00 available": what can be spent now, or on a card what credit is left, +// where the bank says and it isn't the balance itself. +function detailOf(account: BankAccount): string { + const kind = [account.type, account.mask ? `••${account.mask}` : ''].filter(Boolean).join(' '); + if (account.available !== null && account.available !== account.balance) { + return `${kind} · ${formatMoney(account.available, account.currency)} available`; + } + return kind; +} + +// What the accounts hold and owe, per currency: "In accounts $1,110.00 · Owed $410.00". +function sumsOf(accounts: BankAccount[]): string[] { + const byCurrency = new Map(); + for (const account of accounts) { + if (account.balance === null) { + continue; + } + const sum = byCurrency.get(account.currency) ?? {held: 0, owed: 0}; + sum[account.owed ? 'owed' : 'held'] += account.balance; + byCurrency.set(account.currency, sum); + } + return [...byCurrency.entries()].map(([currency, sum]) => + [ + ...(sum.held !== 0 || sum.owed === 0 + ? [`In accounts ${formatMoney(sum.held, currency)}`] + : []), + ...(sum.owed !== 0 ? [`Owed ${formatMoney(sum.owed, currency)}`] : []), + ].join(' · '), + ); +} + +// Balances: every account of every bank connected, what each holds or owes, and their sums. Asked +// about one account, that one stands out and the rest fold away. +export function BalancesWidget({intent}: {intent: Intent}) { + const [said = null] = fieldTexts(intent, 'balanceAccount'); + const read = useAccounts(true); + const [unfolded, setUnfolded] = useState(false); + const sources = read.data?.sources ?? []; + + if (read.data && sources.length === 0) { + return ( + + {CONNECT_BANK} + + ); + } + + const accounts = read.data?.accounts ?? []; + const matched = matchAccounts(accounts, said); + const folded = + matched.length > 0 && !unfolded ? accounts.filter((account) => !matched.includes(account)) : []; + const shown = accounts.filter((account) => !folded.includes(account)); + const banks = sources.length > 1 ? sources : []; + + return ( + +

+ Balances + {said ? `of ${said}` : of every account} +

+ {said && accounts.length > 0 && matched.length === 0 && ( +

+ No account called “{said}”, so every account is shown. +

+ )} +
    + {accounts.length === 0 && ( +
  • + {read.loading || !read.data ? 'Reading your bank…' : 'No accounts.'} +
  • + )} + {(banks.length > 0 ? banks : [null]).map((bank) => ( + !bank || account.connectionId === bank.connectionId, + )} + matched={matched} + /> + ))} + {folded.length > 0 && ( +
  • + +
  • + )} +
+ {accounts.length > 1 && ( +

+ {sumsOf(accounts).join(' · ')} +

+ )} +
+ ); +} + +// A bank's accounts, under its name where several banks are connected. +function BankRows({ + bank, + accounts, + matched, +}: { + bank: Source | null; + accounts: BankAccount[]; + matched: BankAccount[]; +}) { + if (accounts.length === 0) { + return null; + } + return ( + <> + {bank && ( +
  • + {bank.name ?? bank.account} +
  • + )} + {accounts.map((account) => ( +
  • + {account.name} + + {detailOf(account)} + + + {account.balance === null ? ( + — + ) : ( + <> + {formatMoney(account.balance, account.currency)} + {account.owed && owed} + + )} + +
  • + ))} + + ); +} diff --git a/packages/web/src/components/widgets/intent-widget.test.tsx b/packages/web/src/components/widgets/intent-widget.test.tsx index 398789b3..f094e03e 100644 --- a/packages/web/src/components/widgets/intent-widget.test.tsx +++ b/packages/web/src/components/widgets/intent-widget.test.tsx @@ -427,6 +427,127 @@ describe('IntentWidget', () => { ).toBeTruthy(); }); + test('Balances: every account with what it holds or owes, the one asked about standing out', async () => { + show(intentWith('show_balances', {balanceAccount: ['checking']})); + const widget = widgetNamed('Balances'); + expect(within(widget).getByText('of checking')).toBeTruthy(); + expect(await within(widget).findByText('Plaid Checking')).toBeTruthy(); + expect(within(widget).getByText('$110.00')).toBeTruthy(); + expect(within(widget).getByText('Checking ••0000 · $100.00 available')).toBeTruthy(); + // The card folds away, until asked for. + expect(within(widget).queryByText('Plaid Credit Card')).toBeNull(); + fireEvent.click(within(widget).getByRole('button', {name: '1 more account'})); + expect(within(widget).getByText('Plaid Credit Card')).toBeTruthy(); + expect(within(widget).getByText('owed')).toBeTruthy(); + expect(within(widget).getByText('Credit card ••3333 · $1,590.00 available')).toBeTruthy(); + expect(within(widget).getByLabelText('In all').textContent).toBe( + 'In accounts $110.00 · Owed $410.00', + ); + cleanup(); + + show(intentWith('show_balances', {balanceAccount: ['chase']})); + expect( + await within(widgetNamed('Balances')).findByText( + 'No account called “chase”, so every account is shown.', + ), + ).toBeTruthy(); + expect(within(widgetNamed('Balances')).getByText('Plaid Credit Card')).toBeTruthy(); + }); + + test('Transactions: the days asked about are read once, and sifted by kind, with the days before beside the total', async () => { + show( + intentWith('find_transactions', {category: ['FOOD_AND_DRINK'], flow: ['out']}), + 'how much did I spend on restaurants this month', + ); + const widget = widgetNamed('Transactions'); + expect(within(widget).getByText('Food and drink · this month')).toBeTruthy(); + expect(await within(widget).findByText('Whole Foods')).toBeTruthy(); + // The kind goes without saying in the rows, once asked for; the account doesn't. + expect(within(widget).queryByText('Food and drink')).toBeNull(); + expect(within(widget).getByText('Plaid Credit Card ••3333')).toBeTruthy(); + // September 17 to October 14: this month and the 14 days before it, in one read. + expect(fetchMock).toHaveBeenCalledWith( + '/api/widgets/transactions?from=2026-09-17&to=2026-10-14', + expect.anything(), + ); + expect(within(widget).getByLabelText('In all').textContent).toBe( + '$82.151 transaction · Oct 1 – Oct 14 · $64.00 the 14 days before', + ); + expect(within(widget).queryByText('Uber')).toBeNull(); + expect(within(widget).queryByText('Netflix')).toBeNull(); + }); + + test('Transactions: a merchant by name, money in, and an account, the default days said as missing', async () => { + show(intentWith('find_transactions', {merchant: ['uber']}), 'find the uber charge'); + const widget = widgetNamed('Transactions'); + expect(within(widget).getByText('the last 30 days')).toBeTruthy(); + expect(await within(widget).findByText('Uber')).toBeTruthy(); + // September's Uber is before the 30 days, and beside the total. + expect(within(widget).getByLabelText('In all').textContent).toBe( + '$5.401 transaction · Sep 15 – Oct 14 · $12.80 the 30 days before', + ); + cleanup(); + + show(intentWith('find_transactions', {flow: ['in']}), 'when did I last get paid'); + expect(await within(widgetNamed('Transactions')).findByText(/ACME PAYROLL/)).toBeTruthy(); + expect(within(widgetNamed('Transactions')).getByText('+$2,500.00')).toBeTruthy(); + expect(within(widgetNamed('Transactions')).getByText('· pending')).toBeTruthy(); + cleanup(); + + show( + intentWith('find_transactions', {transactionAccount: ['card']}), + 'what did I spend on my card in october', + ); + const onCard = widgetNamed('Transactions'); + expect(await within(onCard).findByText('Netflix')).toBeTruthy(); + expect(within(onCard).getByText('Plaid Credit Card ••3333 · October')).toBeTruthy(); + expect(within(onCard).getByText('Entertainment')).toBeTruthy(); + expect(within(onCard).queryByText('Uber')).toBeNull(); + cleanup(); + + // A merchant no transaction has beside a kind is that kind said twice. + show( + intentWith('find_transactions', {merchant: ['groceries'], category: ['FOOD_AND_DRINK']}), + 'how much did I spend on groceries this month', + ); + expect(await within(widgetNamed('Transactions')).findByText('Whole Foods')).toBeTruthy(); + expect( + within(widgetNamed('Transactions')).getByText('Food and drink · this month'), + ).toBeTruthy(); + cleanup(); + + // A merchant the model took an account for is the account. + show( + intentWith('find_transactions', {merchant: ['credit card']}), + 'what did I spend on my credit card in october', + ); + expect(await within(widgetNamed('Transactions')).findByText('Netflix')).toBeTruthy(); + expect( + within(widgetNamed('Transactions')).getByText('Plaid Credit Card ••3333 · October'), + ).toBeTruthy(); + }); + + test('Balances and Transactions ask for a bank when none is connected', async () => { + fetchMock.mockImplementation(() => + Promise.resolve( + Response.json({sources: [], accounts: [], transactions: [], more: false, failed: []}), + ), + ); + show(intentWith('show_balances', {})); + expect( + await screen.findByText('Connect a bank to see your balances and spending from here.'), + ).toBeTruthy(); + expect(screen.getByRole('link', {name: /Connect Plaid/}).getAttribute('href')).toBe( + '/connections/new/plaid', + ); + cleanup(); + + show(intentWith('find_transactions', {merchant: ['uber']}), 'uber'); + expect( + await screen.findByText('Connect a bank to see your balances and spending from here.'), + ).toBeTruthy(); + }); + test('shows nothing when no widget fits', () => { const {container} = show(intentWith('none', {})); expect(container.innerHTML).toBe(''); diff --git a/packages/web/src/components/widgets/intent-widget.tsx b/packages/web/src/components/widgets/intent-widget.tsx index 650f7b80..45edf2ba 100644 --- a/packages/web/src/components/widgets/intent-widget.tsx +++ b/packages/web/src/components/widgets/intent-widget.tsx @@ -1,4 +1,5 @@ import type {Intent} from '@/client/intent-client'; +import {BalancesWidget} from '@/components/widgets/balances-widget'; import {CalendarWidget} from '@/components/widgets/calendar-widget'; import {ConvertWidget} from '@/components/widgets/convert-widget'; import {EditEventWidget} from '@/components/widgets/edit-event-widget'; @@ -7,6 +8,7 @@ import {FindEmailWidget} from '@/components/widgets/find-email-widget'; import {FindFileWidget} from '@/components/widgets/find-file-widget'; import {MathWidget} from '@/components/widgets/math-widget'; import {PersonWidget} from '@/components/widgets/person-widget'; +import {TransactionsWidget} from '@/components/widgets/transactions-widget'; import {WriteEmailWidget} from '@/components/widgets/write-email-widget'; type Props = { @@ -50,5 +52,11 @@ export function IntentWidget({intent, typed, now, onAsk}: Props) { if (id === 'convert_units') { return ; } + if (id === 'show_balances') { + return ; + } + if (id === 'find_transactions') { + return ; + } return null; } diff --git a/packages/web/src/components/widgets/transactions-widget.tsx b/packages/web/src/components/widgets/transactions-widget.tsx new file mode 100644 index 00000000..e24a21a0 --- /dev/null +++ b/packages/web/src/components/widgets/transactions-widget.tsx @@ -0,0 +1,227 @@ +import {findTransactionCategory} from '@proxy/utils/transaction-categories'; +import {ReceiptTextIcon} from 'lucide-react'; +import type {Intent} from '@/client/intent-client'; +import type {BankAccount, BankTransaction} from '@/client/widgets-client'; +import {CONNECT_BANK} from '@/components/widgets/balances-widget'; +import {ConnectWidget} from '@/components/widgets/connect-widget'; +import {Missing, WidgetFrame} from '@/components/widgets/widget-frame'; +import { + filterTransactions, + formatMoney, + matchAccounts, + rangeAsked, + rangeBefore, + rangeRead, + totalsOf, + type Total, +} from '@/lib/bank-values'; +import {useTransactions} from '@/lib/widget-lookups'; +import {fieldTexts, shortDay} from '@/lib/widget-values'; + +// What the widget lists at most. +const LIMIT = 8; + +// Plaid has at most two years; a range and the same stretch before it have to fit in that. +const MAX_READ_DAYS = 730; + +function flowOf(intent: Intent): 'in' | 'out' | null { + const [flow] = fieldTexts(intent, 'flow'); + return flow === 'in' || flow === 'out' ? flow : null; +} + +// "$412.30", or where money went both ways, "$412.30 out · $2,500.00 in". +function totalRead(total: Total, flow: 'in' | 'out' | null): string { + if (flow === 'in' || total.out === 0) { + return formatMoney(total.in, total.currency); + } + if (flow === 'out' || total.in === 0) { + return formatMoney(total.out, total.currency); + } + return `${formatMoney(total.out, total.currency)} out · ${formatMoney(total.in, total.currency)} in`; +} + +function accountLabel(account: BankAccount | undefined): string { + if (!account) { + return ''; + } + return account.mask ? `${account.name} ••${account.mask}` : account.name; +} + +// Transactions: the transactions of every bank over the days asked about, at a merchant, of a kind, +// in a direction and on an account where those are said, what they add up to against the same +// days before, and the newest of them. The days are read from the bank once; the rest is sifted here +// as the person types on. +export function TransactionsWidget({ + intent, + typed, + now, +}: { + intent: Intent; + typed: string; + now: Date; +}) { + const [merchant = null] = fieldTexts(intent, 'merchant'); + const [category = null] = fieldTexts(intent, 'category'); + const [accountSaid = null] = fieldTexts(intent, 'transactionAccount'); + const flow = flowOf(intent); + const range = rangeAsked(typed, now); + const before = rangeBefore(range); + // The days asked about and the same before them, in one read, where the bank has that far back. + const withBefore = Date.parse(range.to) - Date.parse(before.from) < MAX_READ_DAYS * 86_400_000; + const readRange = {from: withBefore ? before.from : range.from, to: range.to}; + const read = useTransactions(readRange); + const sources = read.data?.sources ?? []; + + if (read.data && sources.length === 0) { + return ( + + {CONNECT_BANK} + + ); + } + + const accounts = read.data?.accounts ?? []; + const all = read.data?.transactions ?? []; + // A merchant no transaction has but an account is named for ("my sapphire") is the account, and + // one no transaction has beside a kind ("groceries") is that kind said twice. + const merchantIsAccount = + merchant !== null && accountSaid === null && matchAccounts(accounts, merchant).length > 0; + const merchantIsCategory = + merchant !== null && + category !== null && + all.length > 0 && + filterTransactions(all, { + range: readRange, + merchant, + category: null, + flow: null, + accountIds: null, + }).length === 0; + const matchedAccounts = matchAccounts(accounts, merchantIsAccount ? merchant : accountSaid); + const accountIds = + matchedAccounts.length > 0 ? matchedAccounts.map((account) => account.id) : null; + const filter = { + merchant: merchantIsAccount || merchantIsCategory ? null : merchant, + category, + flow, + accountIds, + }; + const found = filterTransactions(all, {...filter, range}); + const earlier = withBefore ? filterTransactions(all, {...filter, range: before}) : []; + const totals = totalsOf(found); + const earlierTotals = totalsOf(earlier); + const categoryName = category ? findTransactionCategory(category)?.name : undefined; + const lead = + flow === 'in' ? 'Money in' : merchant || category || flow === 'out' ? 'Spent' : 'Transactions'; + const described = [ + ...(filter.merchant ? [`${flow === 'in' ? 'from' : 'at'} ${filter.merchant}`] : []), + ...(categoryName ? [categoryName] : []), + ...(accountIds ? [matchedAccounts.map(accountLabel).join(', ')] : []), + ]; + const shown = found.slice(0, LIMIT); + + return ( + +

    + {lead} + {described.join(' · ')} + {described.length > 0 && ' · '} + {range.defaulted ? {range.said} : range.said} +

    + {accountSaid && accounts.length > 0 && !accountIds && ( +

    + No account called “{accountSaid}”, so every account is searched. +

    + )} + {totals.length > 0 && ( +
    + {totals.map((total) => { + const earlierTotal = earlierTotals.find((one) => one.currency === total.currency); + return ( +
    + + {totalRead(total, flow)} + + + {total.count === 1 ? '1 transaction' : `${total.count} transactions`} ·{' '} + {rangeRead(range)} + {earlierTotal && ` · ${totalRead(earlierTotal, flow)} ${before.said}`} + +
    + ); + })} +
    + )} +
      + {found.length === 0 && ( +
    • + {read.loading || !read.data ? 'Reading your bank…' : 'No transactions match.'} +
    • + )} + {shown.map((transaction) => ( + 1 && matchedAccounts.length !== 1 + ? accounts.find((account) => account.id === transaction.accountId) + : undefined + } + /> + ))} + {found.length > shown.length && ( +
    • + {found.length - shown.length === 1 ? '1 more' : `${found.length - shown.length} more`} + {read.data?.more && ', of the newest the bank gave'} +
    • + )} +
    +
    + ); +} + +function TransactionRow({ + transaction, + withCategory, + account, +}: { + transaction: BankTransaction; + withCategory: boolean; + account: BankAccount | undefined; +}) { + const categoryName = + withCategory && transaction.category + ? findTransactionCategory(transaction.category)?.name + : undefined; + const incoming = transaction.amount > 0; + return ( +
  • + + {transaction.merchant ?? transaction.name} + {transaction.pending && · pending} + + {categoryName && ( + + {categoryName} + + )} + {account && ( + + {accountLabel(account)} + + )} + + {shortDay(new Date(`${transaction.date}T00:00`))} + + + {incoming ? '+' : ''} + {formatMoney(Math.abs(transaction.amount), transaction.currency)} + +
  • + ); +} diff --git a/packages/web/src/components/widgets/unsupported-widget.tsx b/packages/web/src/components/widgets/unsupported-widget.tsx index bcb16c1c..ff9eaaa1 100644 --- a/packages/web/src/components/widgets/unsupported-widget.tsx +++ b/packages/web/src/components/widgets/unsupported-widget.tsx @@ -2,6 +2,7 @@ import {BotIcon} from 'lucide-react'; import {Link} from 'react-router'; import {buttonVariants} from '@proxy/ui/components/button'; import {WidgetFrame} from '@/components/widgets/widget-frame'; +import {useExperiment} from '@/hooks/use-experiment'; import type {AgentLogin} from '@/lib/types'; // "Claude", "Claude or Poke", and past two, "one of your agents". @@ -17,6 +18,7 @@ function whomToAsk(agents: AgentLogin[]): string { // rest is for the person's agent, or, with none yet, offers to add one. export function UnsupportedWidget({agents}: {agents: AgentLogin[]}) { const active = agents.filter((agent) => agent.revokedAt === null); + const banks = useExperiment('plaid'); return (

    Quick actions make events, find people, emails and files, write emails, do math and convert - units.{' '} + units{banks ? ', and show your balances and spending' : ''}.{' '} {active.length > 0 ? `For anything else, ask ${whomToAsk(active)}, which can work with your connections.` : 'For anything else, add an agent and ask it: it can work with your connections.'} diff --git a/packages/web/src/lib/bank-values.test.ts b/packages/web/src/lib/bank-values.test.ts new file mode 100644 index 00000000..37d1e6c7 --- /dev/null +++ b/packages/web/src/lib/bank-values.test.ts @@ -0,0 +1,250 @@ +/// + +import {describe, expect, test} from 'bun:test'; +import type {BankAccount, BankTransaction} from '@/client/widgets-client'; +import { + filterTransactions, + formatMoney, + matchAccounts, + rangeAsked, + rangeBefore, + totalsOf, +} from '@/lib/bank-values'; + +// Wednesday 14 October 2026, 14:30 local time. +const now = new Date(2026, 9, 14, 14, 30); + +function account(name: string, type: string, mask: string, owed = false): BankAccount { + return { + connectionId: 'b', + id: name, + name, + type, + mask, + owed, + balance: 1, + available: null, + limit: null, + currency: 'USD', + }; +} + +const checking = account('Plaid Checking', 'Checking', '0000'); +const savings = account('Plaid Saving', 'Savings', '1111'); +const amex = account('Amex Gold', 'Credit card', '3333', true); +const mortgage = account('Home loan', 'Mortgage', '', true); +const accounts = [checking, savings, amex, mortgage]; + +function transaction( + date: string, + merchant: string | null, + name: string, + amount: number, + category: string | null, + accountId = 'Plaid Checking', +): BankTransaction { + return { + connectionId: 'b', + accountId, + id: `${date} ${name}`, + date, + name, + merchant, + amount, + currency: 'USD', + category, + pending: false, + }; +} + +describe('rangeAsked', () => { + test('reads days said by name, on the calendar', () => { + const asked = (typed: string) => { + const {from, to, said} = rangeAsked(typed, now); + return [from, to, said]; + }; + expect(asked('what did I spend today')).toEqual(['2026-10-14', '2026-10-14', 'today']); + expect(asked('uber yesterday')).toEqual(['2026-10-13', '2026-10-13', 'yesterday']); + expect(asked('spending this week')).toEqual(['2026-10-12', '2026-10-14', 'this week']); + expect(asked('spending last week')).toEqual(['2026-10-05', '2026-10-11', 'last week']); + expect(asked('restaurants this month')).toEqual(['2026-10-01', '2026-10-14', 'this month']); + expect(asked('restaurants last month')).toEqual(['2026-09-01', '2026-09-30', 'last month']); + expect(asked('how much did I spend this year')).toEqual([ + '2026-01-01', + '2026-10-14', + 'this year', + ]); + expect(asked('travel last year')).toEqual(['2025-01-01', '2025-12-31', 'last year']); + }); + + test('reads a stretch up to today: the last 90 days, the past two weeks, the past month', () => { + expect(rangeAsked('spending in the last 90 days', now)).toMatchObject({ + from: '2026-07-17', + to: '2026-10-14', + said: 'the last 90 days', + }); + expect(rangeAsked('uber the past two weeks', now)).toMatchObject({ + from: '2026-10-01', + to: '2026-10-14', + said: 'the last 2 weeks', + }); + expect(rangeAsked('the past month', now)).toMatchObject({ + from: '2026-09-15', + to: '2026-10-14', + said: 'the last month', + }); + expect(rangeAsked('since october 1', now)).toMatchObject({ + from: '2026-10-01', + to: '2026-10-14', + said: 'since Oct 1', + }); + }); + + test('reads a month by name as the last one of that name, and a year in digits', () => { + expect(rangeAsked('restaurants in september', now)).toMatchObject({ + from: '2026-09-01', + to: '2026-09-30', + said: 'September', + }); + expect(rangeAsked('restaurants in october', now)).toMatchObject({ + from: '2026-10-01', + to: '2026-10-14', + said: 'October', + }); + expect(rangeAsked('restaurants in december', now)).toMatchObject({ + from: '2025-12-01', + to: '2025-12-31', + said: 'December 2025', + }); + expect(rangeAsked('what did I spend in 2025', now)).toMatchObject({ + from: '2025-01-01', + to: '2025-12-31', + said: '2025', + }); + }); + + test('reads two days, a day, and the last friday; never a time of day alone', () => { + expect(rangeAsked('from october 1 to october 10', now)).toMatchObject({ + from: '2026-10-01', + to: '2026-10-10', + said: 'Oct 1 – Oct 10', + }); + expect(rangeAsked('charges on october 3', now)).toMatchObject({ + from: '2026-10-03', + to: '2026-10-03', + said: 'Oct 3', + }); + expect(rangeAsked('what did I buy on friday', now)).toMatchObject({ + from: '2026-10-09', + to: '2026-10-09', + said: 'Oct 9', + }); + expect(rangeAsked('the 3pm charge', now)).toMatchObject({defaulted: true}); + }); + + test('is the last 30 days when no days are said', () => { + expect(rangeAsked('uber charges', now)).toEqual({ + from: '2026-09-15', + to: '2026-10-14', + said: 'the last 30 days', + defaulted: true, + }); + }); +}); + +describe('rangeBefore', () => { + test('is the same stretch before: whole months as months, else as many days', () => { + expect(rangeBefore({from: '2026-09-01', to: '2026-09-30'})).toEqual({ + from: '2026-08-01', + to: '2026-08-31', + said: 'the month before', + }); + expect(rangeBefore({from: '2026-01-01', to: '2026-12-31'})).toEqual({ + from: '2025-01-01', + to: '2025-12-31', + said: 'the year before', + }); + expect(rangeBefore({from: '2026-10-01', to: '2026-10-14'})).toEqual({ + from: '2026-09-17', + to: '2026-09-30', + said: 'the 14 days before', + }); + expect(rangeBefore({from: '2026-10-05', to: '2026-10-11'})).toEqual({ + from: '2026-09-28', + to: '2026-10-04', + said: 'the week before', + }); + expect(rangeBefore({from: '2026-10-13', to: '2026-10-13'})).toEqual({ + from: '2026-10-12', + to: '2026-10-12', + said: 'the day before', + }); + }); +}); + +describe('matchAccounts', () => { + test('finds accounts by name, kind, last digits, or a word for the kind', () => { + expect(matchAccounts(accounts, 'checking')).toEqual([checking]); + expect(matchAccounts(accounts, 'my amex')).toEqual([amex]); + expect(matchAccounts(accounts, 'the card')).toEqual([amex]); + expect(matchAccounts(accounts, 'credit card')).toEqual([amex]); + expect(matchAccounts(accounts, 'savings account')).toEqual([savings]); + expect(matchAccounts(accounts, 'account ending in 3333')).toEqual([amex]); + expect(matchAccounts(accounts, 'my mortgage')).toEqual([mortgage]); + expect(matchAccounts(accounts, 'the loan')).toEqual([mortgage]); + }); + + test('finds none for nothing said, filler alone, or a name no account has', () => { + expect(matchAccounts(accounts, null)).toEqual([]); + expect(matchAccounts(accounts, 'my account')).toEqual([]); + expect(matchAccounts(accounts, 'chase')).toEqual([]); + }); +}); + +describe('filterTransactions and totalsOf', () => { + const uber = transaction('2026-10-11', 'Uber', 'Uber 063015 SF**POOL**', -5.4, 'TRANSPORTATION'); + const payroll = transaction('2026-10-10', null, 'ACME PAYROLL', 2500, 'INCOME'); + const groceries = transaction( + '2026-10-03', + 'Whole Foods', + 'WHOLEFDS SF', + -82.15, + 'FOOD_AND_DRINK', + 'Amex Gold', + ); + const older = transaction('2026-09-12', 'Uber', 'Uber 091215', -12.8, 'TRANSPORTATION'); + const all = [uber, payroll, groceries, older]; + const october = {from: '2026-10-01', to: '2026-10-14'}; + const any = {range: october, merchant: null, category: null, flow: null, accountIds: null}; + + test('keeps the days, the merchant by its name or the bank’s words, the kind, the direction and the accounts', () => { + expect(filterTransactions(all, any)).toEqual([uber, payroll, groceries]); + expect(filterTransactions(all, {...any, merchant: 'uber'})).toEqual([uber]); + expect(filterTransactions(all, {...any, merchant: 'acme'})).toEqual([payroll]); + expect(filterTransactions(all, {...any, category: 'FOOD_AND_DRINK'})).toEqual([groceries]); + expect(filterTransactions(all, {...any, flow: 'in'})).toEqual([payroll]); + expect(filterTransactions(all, {...any, flow: 'out'})).toEqual([uber, groceries]); + expect(filterTransactions(all, {...any, accountIds: ['Amex Gold']})).toEqual([groceries]); + expect( + filterTransactions(all, {...any, range: {from: '2026-09-01', to: '2026-09-30'}}), + ).toEqual([older]); + }); + + test('adds up what went out and what came in, per currency', () => { + expect(totalsOf([uber, payroll, groceries])).toEqual([ + {currency: 'USD', out: 87.55, in: 2500, count: 3}, + ]); + expect(totalsOf([{...uber, currency: 'EUR'}, payroll, groceries])).toEqual([ + {currency: 'USD', out: 82.15, in: 2500, count: 2}, + {currency: 'EUR', out: 5.4, in: 0, count: 1}, + ]); + }); +}); + +describe('formatMoney', () => { + test('writes an amount in its currency, and one Intl doesn’t know with its code', () => { + expect(formatMoney(1234.5, 'USD')).toBe('$1,234.50'); + expect(formatMoney(15000, 'JPY')).toBe('¥15,000'); + expect(formatMoney(0.00342, 'BTC')).toBe('0.00342 BTC'); + }); +}); diff --git a/packages/web/src/lib/bank-values.ts b/packages/web/src/lib/bank-values.ts new file mode 100644 index 00000000..cb270c89 --- /dev/null +++ b/packages/web/src/lib/bank-values.ts @@ -0,0 +1,451 @@ +import * as chrono from 'chrono-node'; +import {Result} from 'ts-results-es'; +import {addDays, daysBetween, dayIn} from '@proxy/utils/days'; +import type {BankAccount, BankTransaction, DayRange} from '@/client/widgets-client'; +import {browserTimeZone} from '@/lib/activity-graph'; + +// What the Balances and Transactions widgets make of what was typed: the days asked about, the +// accounts a name stands for, the transactions that match, and what they add up to. + +// How far back a search goes when no days are said. +const DEFAULT_DAYS = 30; + +/** The days asked about, as the widget reads and names them: "last month", "September", "the last 30 days". */ +export type AskedRange = DayRange & {said: string; defaulted: boolean}; + +const MONTH = new Intl.DateTimeFormat('en-US', {month: 'long'}); +const SHORT_DAY = new Intl.DateTimeFormat('en-US', {month: 'short', day: 'numeric'}); +const SHORT_DAY_YEAR = new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', +}); + +function localDay(date: Date): string { + return dayIn(date, browserTimeZone()); +} + +function dayOf(day: string): Date { + return new Date(`${day}T00:00`); +} + +// The first and last day of the month a day is in. +function monthOf(day: string): DayRange { + const date = dayOf(day); + const first = new Date(date.getFullYear(), date.getMonth(), 1); + const last = new Date(date.getFullYear(), date.getMonth() + 1, 0); + return {from: localDay(first), to: localDay(last)}; +} + +function yearOf(year: number): DayRange { + return {from: `${year}-01-01`, to: `${year}-12-31`}; +} + +// The Monday a day's week starts on. +function mondayOf(day: string): string { + const weekday = (dayOf(day).getDay() + 6) % 7; + return addDays(day, -weekday); +} + +const COUNT_WORDS: Record = { + a: 1, + an: 1, + one: 1, + two: 2, + three: 3, + four: 4, + five: 5, + six: 6, + seven: 7, + eight: 8, + nine: 9, + ten: 10, + twelve: 12, +}; + +function countOf(word: string): number { + return COUNT_WORDS[word.toLowerCase()] ?? Number(word); +} + +// "the last 30 days", "past two weeks", "last 3 months", "the past week": that long, up to today. +// "Last week" and "last month" alone, the calendar's, are read before this. +const LAST_SOME = + /\b(?:last|past|previous)\s+(?:(\d+|a|an|one|two|three|four|five|six|seven|eight|nine|ten|twelve)\s+)?(days?|weeks?|months?|years?)\b/i; + +function lastSome(typed: string, today: string): AskedRange | null { + const match = LAST_SOME.exec(typed); + if (!match) { + return null; + } + const count = countOf(match[1] ?? '1'); + const unit = (match[2] ?? 'days').toLowerCase(); + if (!Number.isFinite(count) || count <= 0) { + return null; + } + const date = dayOf(today); + if (unit.startsWith('day')) { + return { + from: addDays(today, -(count - 1)), + to: today, + said: `the last ${count} days`, + defaulted: false, + }; + } + if (unit.startsWith('week')) { + return { + from: addDays(today, -(count * 7 - 1)), + to: today, + said: count === 1 ? 'the last week' : `the last ${count} weeks`, + defaulted: false, + }; + } + if (unit.startsWith('month')) { + date.setMonth(date.getMonth() - count); + return { + from: addDays(localDay(date), 1), + to: today, + said: count === 1 ? 'the last month' : `the last ${count} months`, + defaulted: false, + }; + } + date.setFullYear(date.getFullYear() - count); + return { + from: addDays(localDay(date), 1), + to: today, + said: count === 1 ? 'the last year' : `the last ${count} years`, + defaulted: false, + }; +} + +// Days said by name, relative to today. +function namedRange(typed: string, today: string): AskedRange | null { + const said = (pattern: RegExp) => pattern.test(typed); + if (said(/\btoday\b/i)) { + return {from: today, to: today, said: 'today', defaulted: false}; + } + if (said(/\byesterday\b/i)) { + const day = addDays(today, -1); + return {from: day, to: day, said: 'yesterday', defaulted: false}; + } + if (said(/\bthis week\b/i)) { + return {from: mondayOf(today), to: today, said: 'this week', defaulted: false}; + } + if (said(/\blast week\b/i)) { + const monday = addDays(mondayOf(today), -7); + return {from: monday, to: addDays(monday, 6), said: 'last week', defaulted: false}; + } + if (said(/\bthis month\b/i)) { + return {from: monthOf(today).from, to: today, said: 'this month', defaulted: false}; + } + if (said(/\blast month\b/i)) { + const previous = monthOf(addDays(monthOf(today).from, -1)); + return {...previous, said: 'last month', defaulted: false}; + } + if (said(/\bthis year\b/i)) { + return {from: `${today.slice(0, 4)}-01-01`, to: today, said: 'this year', defaulted: false}; + } + if (said(/\blast year\b/i)) { + return {...yearOf(Number(today.slice(0, 4)) - 1), said: 'last year', defaulted: false}; + } + return null; +} + +// "since october 1", "since monday": from that day to today. +function sinceRange(typed: string, now: Date, today: string): AskedRange | null { + const match = /\bsince\s+(.+)$/i.exec(typed); + const [result] = match ? chrono.parse(match[1] ?? '', now) : []; + if (!result) { + return null; + } + const day = pastOrSame(result, today); + return {from: day, to: today, said: `since ${SHORT_DAY.format(dayOf(day))}`, defaulted: false}; +} + +// The day a result names, among those a transaction can be on: "friday" alone is the last one, and a +// month said alone the last one of that name. +function pastOrSame(result: chrono.ParsedResult, today: string): string { + const {start} = result; + const day = localDay(start.date()); + if (day <= today) { + return day; + } + if (start.isCertain('weekday') && !start.isCertain('day')) { + return addDays(day, -7); + } + if (start.isCertain('month') && !start.isCertain('year')) { + const date = dayOf(day); + date.setFullYear(date.getFullYear() - 1); + return localDay(date); + } + return day; +} + +// "in 2025": a year alone, said in digits. +function yearRange(typed: string, today: string): AskedRange | null { + const match = /\b(20\d\d)\b/.exec(typed); + if (!match) { + return null; + } + const year = Number(match[1]); + const range = yearOf(year); + return { + from: range.from, + to: range.to < today ? range.to : today, + said: String(year), + defaulted: false, + }; +} + +// What chrono reads: two days ("from october 1 to october 15"), a month alone ("in september"), or +// a day ("on friday", "on the 3rd"); never a time of day alone. +function parsedRange(typed: string, now: Date, today: string): AskedRange | null { + for (const result of chrono.parse(typed, now)) { + const {start} = result; + if ( + start.isCertain('hour') && + !start.isCertain('day') && + !start.isCertain('weekday') && + !start.isCertain('month') + ) { + continue; + } + if (result.end) { + const from = localDay(start.date()); + const to = localDay(result.end.date()); + return { + from, + to, + said: `${SHORT_DAY.format(dayOf(from))} – ${SHORT_DAY.format(dayOf(to))}`, + defaulted: false, + }; + } + if (start.isCertain('month') && !start.isCertain('day') && !start.isCertain('weekday')) { + const month = monthOf(pastOrSame(result, today)); + const monthName = MONTH.format(dayOf(month.from)); + return { + from: month.from, + to: month.to < today ? month.to : today, + said: + month.from.slice(0, 4) === today.slice(0, 4) + ? monthName + : `${monthName} ${month.from.slice(0, 4)}`, + defaulted: false, + }; + } + if (start.isCertain('day') || start.isCertain('weekday')) { + const day = pastOrSame(result, today); + return { + from: day, + to: day, + said: + day.slice(0, 4) === today.slice(0, 4) + ? SHORT_DAY.format(dayOf(day)) + : SHORT_DAY_YEAR.format(dayOf(day)), + defaulted: false, + }; + } + } + return null; +} + +/** + * The days asked about in what was typed, on the browser's clock: "last month", "this week", "the + * past 90 days", "in september", "since october 1", "yesterday"; the last 30 days when none are said. + */ +export function rangeAsked(typed: string, now: Date): AskedRange { + const today = localDay(now); + const asked = + namedRange(typed, today) ?? + lastSome(typed, today) ?? + sinceRange(typed, now, today) ?? + parsedRange(typed, now, today) ?? + yearRange(typed, today); + if (asked) { + return asked; + } + return { + from: addDays(today, -(DEFAULT_DAYS - 1)), + to: today, + said: `the last ${DEFAULT_DAYS} days`, + defaulted: true, + }; +} + +/** The same stretch of days before a range, and what to call it: "the month before", "the 30 days before". */ +export function rangeBefore(range: DayRange): DayRange & {said: string} { + const days = daysBetween(range.from, range.to) + 1; + const wholeMonths = range.from.endsWith('-01') && monthOf(range.to).to === range.to; + if (wholeMonths) { + const months = + (Number(range.to.slice(0, 4)) - Number(range.from.slice(0, 4))) * 12 + + Number(range.to.slice(5, 7)) - + Number(range.from.slice(5, 7)) + + 1; + const start = dayOf(range.from); + start.setMonth(start.getMonth() - months); + return { + from: localDay(start), + to: addDays(range.from, -1), + said: + months === 12 + ? 'the year before' + : months === 1 + ? 'the month before' + : `the ${months} months before`, + }; + } + const to = addDays(range.from, -1); + return { + from: addDays(to, -(days - 1)), + to, + said: + days === 1 ? 'the day before' : days === 7 ? 'the week before' : `the ${days} days before`, + }; +} + +/** Whether a day is in the range, both ends included. */ +export function inRange(day: string, range: DayRange): boolean { + return day >= range.from && day <= range.to; +} + +/** The days as a line reads them: "Oct 1 – Oct 19", or one day. */ +export function rangeRead(range: DayRange): string { + if (range.from === range.to) { + return SHORT_DAY.format(dayOf(range.from)); + } + return `${SHORT_DAY.format(dayOf(range.from))} – ${SHORT_DAY.format(dayOf(range.to))}`; +} + +function words(text: string): string[] { + return text + .toLowerCase() + .split(/[^\p{L}\p{N}]+/u) + .filter((word) => word !== ''); +} + +// What a request says around an account's name, no part of it. +const ACCOUNT_FILLER = new Set([ + 'my', + 'the', + 'account', + 'accounts', + 'on', + 'in', + 'from', + 'bank', + 'balance', + 'of', + 'ending', + 'number', + 'with', +]); + +// Words that mean a kind of account rather than naming one. +const ACCOUNT_KINDS: Array<[RegExp, (account: BankAccount) => boolean]> = [ + [/^(card|cards|cc|credit)$/, (account) => /credit/i.test(account.type)], + [/^(checking|current)$/, (account) => /checking/i.test(account.type)], + [/^(savings?)$/, (account) => /saving/i.test(account.type)], + [/^(loan|loans|mortgage)$/, (account) => account.owed && !/credit/i.test(account.type)], +]; + +/** + * The accounts a name stands for: those whose name, kind or last digits have every word said, "my + * amex" and "checking" and "the card ending in 3333" alike. None when no account does. + */ +export function matchAccounts(accounts: BankAccount[], said: string | null): BankAccount[] { + const wanted = words(said ?? '').filter((word) => !ACCOUNT_FILLER.has(word)); + if (wanted.length === 0) { + return []; + } + return accounts.filter((account) => { + const text = `${account.name} ${account.type} ${account.mask}`.toLowerCase(); + return wanted.every( + (word) => + text.includes(word) || + ACCOUNT_KINDS.some(([pattern, is]) => pattern.test(word) && is(account)), + ); + }); +} + +export type TransactionFilter = { + range: DayRange; + merchant: string | null; + category: string | null; + flow: 'in' | 'out' | null; + /** The accounts to keep to, or null for all. */ + accountIds: string[] | null; +}; + +/** The transactions of the days, at the merchant, of the kind, in the direction and on the accounts asked. */ +export function filterTransactions( + transactions: BankTransaction[], + filter: TransactionFilter, +): BankTransaction[] { + const merchantWords = words(filter.merchant ?? ''); + return transactions.filter((transaction) => { + if (!inRange(transaction.date, filter.range)) { + return false; + } + if (filter.category && transaction.category !== filter.category) { + return false; + } + if (filter.flow === 'out' && transaction.amount >= 0) { + return false; + } + if (filter.flow === 'in' && transaction.amount <= 0) { + return false; + } + if (filter.accountIds && !filter.accountIds.includes(transaction.accountId)) { + return false; + } + const text = `${transaction.merchant ?? ''} ${transaction.name}`.toLowerCase(); + return merchantWords.every((word) => text.includes(word)); + }); +} + +/** What transactions add up to in a currency: what went out, what came in, and how many there were. */ +export type Total = {currency: string; out: number; in: number; count: number}; + +/** The totals of the transactions, one per currency, the currency with most transactions first. */ +export function totalsOf(transactions: BankTransaction[]): Total[] { + const totals = new Map(); + for (const transaction of transactions) { + const total = totals.get(transaction.currency) ?? { + currency: transaction.currency, + out: 0, + in: 0, + count: 0, + }; + total.count += 1; + if (transaction.amount < 0) { + total.out = cents(total.out - transaction.amount); + } + if (transaction.amount > 0) { + total.in = cents(total.in + transaction.amount); + } + totals.set(transaction.currency, total); + } + return [...totals.values()].sort((a, b) => b.count - a.count); +} + +// A sum without the drift of adding floats: 5.4 + 82.15 is 87.55, not 87.55000000000001. +function cents(amount: number): number { + return Math.round(amount * 1e8) / 1e8; +} + +// The currencies Intl writes with a symbol or its code: a bank's own code for one without an ISO +// code, as a cryptocurrency's, it would write as "BTC 0.00". +const KNOWN_CURRENCIES = new Set(Intl.supportedValuesOf('currency')); + +/** An amount in its currency, "$1,234.56", or with the code after it for one Intl doesn't know, "0.00342 BTC". */ +export function formatMoney(amount: number, currency: string): string { + const formatted = KNOWN_CURRENCIES.has(currency.toUpperCase()) + ? Result.wrap(() => + new Intl.NumberFormat('en-US', {style: 'currency', currency}).format(amount), + ) + : null; + if (formatted?.isOk()) { + return formatted.value; + } + return `${amount.toLocaleString('en-US', {useGrouping: false, maximumFractionDigits: 20})} ${currency}`.trim(); +} diff --git a/packages/web/src/lib/widget-lookups.ts b/packages/web/src/lib/widget-lookups.ts index 63146413..50a3fea0 100644 --- a/packages/web/src/lib/widget-lookups.ts +++ b/packages/web/src/lib/widget-lookups.ts @@ -3,6 +3,8 @@ import { findEvents, findFiles, findPeople, + findTransactions, + getAccounts, getAgenda, getMailboxes, getRates, @@ -27,3 +29,7 @@ export const useMailboxes = lookup((_all: true) => getMailboxes()); export const useFiles = lookup(findFiles); export const useRates = lookup((_today: true) => getRates()); + +export const useAccounts = lookup((_all: true) => getAccounts()); + +export const useTransactions = lookup(findTransactions); diff --git a/packages/web/src/pages/home-page.tsx b/packages/web/src/pages/home-page.tsx index 01371ea1..da781931 100644 --- a/packages/web/src/pages/home-page.tsx +++ b/packages/web/src/pages/home-page.tsx @@ -10,6 +10,7 @@ import {AppShell} from '@/components/app-shell'; import {IntentWidget} from '@/components/widgets/intent-widget'; import {UnsupportedWidget} from '@/components/widgets/unsupported-widget'; import {Input} from '@proxy/ui/components/input'; +import {useExperiment} from '@/hooks/use-experiment'; import {apiMessage} from '@/lib/agent-errors'; import type {homeLoader} from '@/loaders'; @@ -34,6 +35,12 @@ const EXAMPLES = [ '72°F in Celsius', ]; +// Examples for an organization with banks connected through Plaid. +const BANK_EXAMPLES = [ + "What's my checking balance?", + 'How much did I spend on restaurants last month?', +]; + // How long each example shows. const EXAMPLE_MS = 3500; @@ -71,6 +78,7 @@ function saysUnsupported(answer: Answer, pausedOn: string): boolean { // it back. export function HomePage() { const me = useRouteLoaderData('human'); + const banks = useExperiment('plaid'); const {connections, agents, activity} = useLoaderData(); // The time the page opened, for its greeting. const [opened] = useState(() => new Date()); @@ -200,7 +208,9 @@ export function HomePage() {

    - {!active && } + {!active && ( + + )} {active && (