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 && (