From 6fc22b710551cb043763cf5e720fcfbe85809975 Mon Sep 17 00:00:00 2001 From: Andrew Zolotukhin Date: Tue, 7 Jul 2026 10:50:50 +0000 Subject: [PATCH 01/16] Add shared budgets --- apps/api/src/api/endpoints.ts | 78 +- apps/api/src/api/handlers/auth.test.ts | 64 +- apps/api/src/api/handlers/budgets.ts | 168 ++++ apps/api/src/api/handlers/categories.ts | 40 +- apps/api/src/api/handlers/index.ts | 20 + .../api/src/api/handlers/transaction-scans.ts | 22 + apps/api/src/api/handlers/transaction-tags.ts | 19 +- apps/api/src/api/handlers/transactions.ts | 150 +++- apps/api/src/api/handlers/vendors.ts | 40 +- apps/api/src/application/budgets.ts | 729 ++++++++++++++++++ apps/api/src/application/categories.test.ts | 45 ++ apps/api/src/application/categories.ts | 103 ++- .../api/src/application/email-reports.test.ts | 5 + apps/api/src/application/email-reports.ts | 127 ++- .../src/application/transaction-scans.test.ts | 39 + apps/api/src/application/transaction-scans.ts | 50 +- .../src/application/transaction-tags.test.ts | 5 +- apps/api/src/application/transaction-tags.ts | 26 +- apps/api/src/application/transactions.test.ts | 85 +- apps/api/src/application/transactions.ts | 266 ++++--- apps/api/src/application/users.test.ts | 48 +- apps/api/src/application/users.ts | 54 +- apps/api/src/application/vendors.test.ts | 37 + apps/api/src/application/vendors.ts | 65 +- apps/api/src/db/migrations/019_budgets.ts | 354 +++++++++ apps/api/src/db/schemas.ts | 142 ++++ apps/api/src/mcp/tools.test.ts | 38 +- apps/api/src/mcp/tools.ts | 91 ++- apps/telegram-bot/src/bot.test.ts | 38 +- apps/telegram-bot/src/bot.ts | 267 ++++++- apps/telegram-bot/src/flow.ts | 2 + apps/web/app/(app)/capture/page.tsx | 19 +- apps/web/app/(app)/dashboard/page.tsx | 24 +- apps/web/app/(app)/layout.tsx | 12 +- .../app/(app)/settings/categories/page.tsx | 6 +- .../app/(app)/settings/preferences/page.tsx | 20 + .../settings/vendors/[vendorId]/page.tsx | 21 +- apps/web/app/(app)/settings/vendors/page.tsx | 5 +- .../stats/categories/[categoryId]/page.tsx | 9 +- apps/web/app/(app)/stats/page.tsx | 9 +- apps/web/app/(app)/transactions/page.tsx | 34 +- apps/web/app/(app)/vendors/page.tsx | 24 +- .../web/app/app-api/dashboard/window/route.ts | 13 +- apps/web/app/app-api/stats/tags/route.ts | 13 +- apps/web/app/app-api/stats/window/route.ts | 13 +- .../app-api/transaction-scans/route.test.ts | 6 + .../app/app-api/transaction-scans/route.ts | 3 + .../app-api/transactions/export.csv/route.ts | 11 +- apps/web/app/app-api/transactions/route.ts | 15 +- .../app/budgets/invitations/accept/page.tsx | 64 ++ apps/web/app/metadata-routes.test.ts | 3 +- apps/web/components/app-nav.tsx | 24 +- apps/web/components/budget-selector.tsx | 46 ++ apps/web/components/budget-settings.tsx | 282 +++++++ apps/web/components/category-manager.test.tsx | 1 + .../forms/preferences-form.test.tsx | 22 + .../components/quick-capture-form.test.tsx | 3 + .../components/transaction-dialog.test.tsx | 2 + .../transaction-scan-capture.test.tsx | 1 + .../transaction-tag-picker.test.tsx | 1 + apps/web/components/vendor-actions.test.tsx | 1 + apps/web/components/vendor-directory.test.tsx | 1 + apps/web/components/vendor-picker.test.tsx | 1 + apps/web/content/blog/shared-budgets.mdx | 40 + apps/web/lib/actions.ts | 139 +++- apps/web/lib/api-session-token.test.ts | 3 +- apps/web/lib/api.test.ts | 3 +- apps/web/lib/budgets.ts | 39 + apps/web/lib/capture-suggestions.test.ts | 1 + apps/web/lib/category-display.test.ts | 1 + packages/contracts/src/api.test.ts | 10 + packages/contracts/src/api.ts | 209 ++++- packages/contracts/src/limits.ts | 2 + packages/contracts/src/schemas.test.ts | 30 +- packages/contracts/src/schemas.ts | 215 ++++++ 75 files changed, 4206 insertions(+), 412 deletions(-) create mode 100644 apps/api/src/api/handlers/budgets.ts create mode 100644 apps/api/src/application/budgets.ts create mode 100644 apps/api/src/db/migrations/019_budgets.ts create mode 100644 apps/web/app/budgets/invitations/accept/page.tsx create mode 100644 apps/web/components/budget-selector.tsx create mode 100644 apps/web/components/budget-settings.tsx create mode 100644 apps/web/content/blog/shared-budgets.mdx create mode 100644 apps/web/lib/budgets.ts diff --git a/apps/api/src/api/endpoints.ts b/apps/api/src/api/endpoints.ts index 1d1e1f5..f41fe0f 100644 --- a/apps/api/src/api/endpoints.ts +++ b/apps/api/src/api/endpoints.ts @@ -122,6 +122,70 @@ export const DisconnectTelegramEndpoint = api.users.disconnectTelegram .tags('users') .operationId('disconnectTelegram'); +export const ListBudgetsEndpoint = api.budgets.list + .authorize(PrincipalSchema) + .inject({ db: DbToken }) + .summary('List budgets') + .description('Lists budgets accessible to the authenticated user.') + .tags('budgets') + .operationId('listBudgets'); + +export const CreateBudgetEndpoint = api.budgets.create + .authorize(PrincipalSchema) + .inject({ db: DbToken }) + .summary('Create budget') + .description('Creates a new budget with the current user as admin.') + .tags('budgets') + .operationId('createBudget'); + +export const UpdateBudgetEndpoint = api.budgets.update + .authorize(PrincipalSchema) + .inject({ db: DbToken }) + .summary('Update budget') + .description('Updates budget name and reporting defaults.') + .tags('budgets') + .operationId('updateBudget'); + +export const ListBudgetMembersEndpoint = api.budgets.members + .authorize(PrincipalSchema) + .inject({ db: DbToken }) + .summary('List budget members') + .description('Lists members for a budget the current user can manage.') + .tags('budgets') + .operationId('listBudgetMembers'); + +export const InviteBudgetMemberEndpoint = api.budgets.invite + .authorize(PrincipalSchema) + .inject({ db: DbToken, config: ConfigToken }) + .summary('Invite budget member') + .description('Sends a magic link invitation for an existing user.') + .tags('budgets') + .operationId('inviteBudgetMember'); + +export const UpdateBudgetMemberEndpoint = api.budgets.updateMember + .authorize(PrincipalSchema) + .inject({ db: DbToken }) + .summary('Update budget member') + .description('Updates budget member role and permissions.') + .tags('budgets') + .operationId('updateBudgetMember'); + +export const RemoveBudgetMemberEndpoint = api.budgets.removeMember + .authorize(PrincipalSchema) + .inject({ db: DbToken }) + .summary('Remove budget member') + .description('Removes a user from a shared budget.') + .tags('budgets') + .operationId('removeBudgetMember'); + +export const AcceptBudgetInvitationEndpoint = api.budgets.acceptInvitation + .authorize(PrincipalSchema) + .inject({ db: DbToken }) + .summary('Accept budget invitation') + .description('Consumes a budget invitation magic link.') + .tags('budgets') + .operationId('acceptBudgetInvitation'); + export const ListApiKeysEndpoint = api.users.listApiKeys .authorize(PrincipalSchema) .inject({ db: DbToken }) @@ -357,7 +421,7 @@ export const UpdateTransactionEndpoint = api.transactions.update export const ListTransactionTagsEndpoint = api.transactionTags.list .authorize(PrincipalSchema) - .inject({ knex: KnexToken }) + .inject({ db: DbToken }) .summary('List transaction tags') .description('Lists transaction tags owned by the authenticated user.') .tags('transaction-tags') @@ -373,7 +437,7 @@ export const DeleteTransactionEndpoint = api.transactions.delete export const GetTransactionScanImageEndpoint = api.transactions.scanImage .authorize(PrincipalSchema) - .inject({ knex: KnexToken }) + .inject({ db: DbToken, knex: KnexToken }) .summary('Get scanned transaction image') .description( 'Returns the original scanner image attached to a confirmed transaction.' @@ -499,6 +563,16 @@ export const endpoints = { listMcpOAuthConnections: ListMcpOAuthConnectionsEndpoint, revokeMcpOAuthConnection: RevokeMcpOAuthConnectionEndpoint }, + budgets: { + list: ListBudgetsEndpoint, + create: CreateBudgetEndpoint, + update: UpdateBudgetEndpoint, + members: ListBudgetMembersEndpoint, + invite: InviteBudgetMemberEndpoint, + updateMember: UpdateBudgetMemberEndpoint, + removeMember: RemoveBudgetMemberEndpoint, + acceptInvitation: AcceptBudgetInvitationEndpoint + }, oauth: { authorizationRequest: McpOAuthAuthorizationRequestEndpoint, authorize: McpOAuthAuthorizeEndpoint diff --git a/apps/api/src/api/handlers/auth.test.ts b/apps/api/src/api/handlers/auth.test.ts index 40e0b4b..bb4af38 100644 --- a/apps/api/src/api/handlers/auth.test.ts +++ b/apps/api/src/api/handlers/auth.test.ts @@ -30,10 +30,55 @@ const singleUserConfig = { } } as Config; +function budgetAccessTables(userId = 12) { + const timestamp = new Date('2026-06-01T00:00:00.000Z'); + const budget = { + id: 1, + name: 'Main', + defaultCurrency: 'USD', + countryCode: 'US', + createdByUserId: userId, + createdAt: timestamp, + updatedAt: timestamp + }; + const member = { + budgetId: 1, + userId, + role: 'admin', + canCreateTransactions: true, + canUpdateTransactions: true, + canDeleteTransactions: true, + canManageCategories: true, + canManageVendors: true, + canManageTags: true, + canManageMembers: true, + createdAt: timestamp, + updatedAt: timestamp + }; + + return { + budgets: { + find: vi.fn(async () => budget), + insert: vi.fn(async () => budget) + }, + budgetMembers: { + insert: vi.fn(async () => member), + where: vi.fn(() => ({ + where: vi.fn(() => ({ + first: vi.fn(async () => member) + })) + })) + } + }; +} + function mockDb(user: object | undefined): AppDb { return { + ...budgetAccessTables(), users: { - find: vi.fn(async () => user) + find: vi.fn(async () => + user ? { mainBudgetId: 1, ...user } : user + ) }, categories: { where: vi.fn(() => ({ @@ -65,15 +110,26 @@ function singleUserDb(existing?: object): AppDb { }; return stored; }), - find: vi.fn(async (id: number) => (stored?.id === id ? stored : null)) + find: vi.fn(async (id: number) => (stored?.id === id ? stored : null)), + where: vi.fn(() => ({ + update: vi.fn(async values => { + stored = stored ? { ...stored, ...values } : stored; + }) + })) }; + const budgetTables = budgetAccessTables(); return { transaction: vi.fn( async ( - callback: (trx: { readonly users: typeof users }) => unknown - ) => callback({ users }) + callback: (trx: { + readonly budgetMembers: typeof budgetTables.budgetMembers; + readonly budgets: typeof budgetTables.budgets; + readonly users: typeof users; + }) => unknown + ) => callback({ ...budgetTables, users }) ), + ...budgetTables, users, categories: { where: vi.fn(() => ({ diff --git a/apps/api/src/api/handlers/budgets.ts b/apps/api/src/api/handlers/budgets.ts new file mode 100644 index 0000000..7704100 --- /dev/null +++ b/apps/api/src/api/handlers/budgets.ts @@ -0,0 +1,168 @@ +import { ActionResult, type Handler } from '@cleverbrush/server'; +import { + acceptBudgetInvitation, + BudgetAccessError, + BudgetInvitationInvalidError, + BudgetMemberError, + BudgetNotFoundError, + BudgetPermissionError, + createBudget, + inviteBudgetMember, + listBudgetMembers, + listBudgets, + removeBudgetMember, + updateBudget, + updateBudgetMember +} from '../../application/budgets.js'; +import type { + AcceptBudgetInvitationEndpoint, + CreateBudgetEndpoint, + InviteBudgetMemberEndpoint, + ListBudgetMembersEndpoint, + ListBudgetsEndpoint, + RemoveBudgetMemberEndpoint, + UpdateBudgetEndpoint, + UpdateBudgetMemberEndpoint +} from '../endpoints.js'; + +function budgetAccessResult(err: unknown) { + if (err instanceof BudgetPermissionError) { + return ActionResult.forbidden({ message: err.message }); + } + if ( + err instanceof BudgetAccessError || + err instanceof BudgetNotFoundError + ) { + return ActionResult.notFound({ message: err.message }); + } + if (err instanceof BudgetMemberError) { + return ActionResult.badRequest({ message: err.message }); + } + return undefined; +} + +export const listBudgetsHandler: Handler = async ( + { principal }, + { db } +) => { + return listBudgets(db, principal.userId); +}; + +export const createBudgetHandler: Handler = async ( + { body, principal }, + { db } +) => { + try { + return ActionResult.created( + await createBudget(db, principal.userId, body) + ); + } catch (err) { + const result = budgetAccessResult(err); + if (result) { + return result; + } + throw err; + } +}; + +export const updateBudgetHandler: Handler = async ( + { body, params, principal }, + { db } +) => { + try { + return await updateBudget(db, principal.userId, params.id, body); + } catch (err) { + const result = budgetAccessResult(err); + if (result) { + return result; + } + throw err; + } +}; + +export const listBudgetMembersHandler: Handler< + typeof ListBudgetMembersEndpoint +> = async ({ params, principal }, { db }) => { + try { + return await listBudgetMembers(db, principal.userId, params.id); + } catch (err) { + const result = budgetAccessResult(err); + if (result) { + return result; + } + throw err; + } +}; + +export const inviteBudgetMemberHandler: Handler< + typeof InviteBudgetMemberEndpoint +> = async ({ body, params, principal }, { db, config }) => { + try { + return await inviteBudgetMember( + db, + config, + principal.userId, + params.id, + body + ); + } catch (err) { + const result = budgetAccessResult(err); + if (result) { + return result; + } + throw err; + } +}; + +export const updateBudgetMemberHandler: Handler< + typeof UpdateBudgetMemberEndpoint +> = async ({ body, params, principal }, { db }) => { + try { + return await updateBudgetMember( + db, + principal.userId, + params.budgetId, + params.userId, + body + ); + } catch (err) { + const result = budgetAccessResult(err); + if (result) { + return result; + } + throw err; + } +}; + +export const removeBudgetMemberHandler: Handler< + typeof RemoveBudgetMemberEndpoint +> = async ({ params, principal }, { db }) => { + try { + await removeBudgetMember( + db, + principal.userId, + params.budgetId, + params.userId + ); + return ActionResult.noContent(); + } catch (err) { + const result = budgetAccessResult(err); + if (result) { + return result; + } + throw err; + } +}; + +export const acceptBudgetInvitationHandler: Handler< + typeof AcceptBudgetInvitationEndpoint +> = async ({ body, principal }, { db }) => { + try { + return await acceptBudgetInvitation(db, principal.userId, body.token); + } catch (err) { + if (err instanceof BudgetInvitationInvalidError) { + return ActionResult.badRequest({ message: err.message }); + } + throw err; + } +}; diff --git a/apps/api/src/api/handlers/categories.ts b/apps/api/src/api/handlers/categories.ts index 1d8d7e4..8b8bf31 100644 --- a/apps/api/src/api/handlers/categories.ts +++ b/apps/api/src/api/handlers/categories.ts @@ -1,4 +1,8 @@ import { ActionResult, type Handler } from '@cleverbrush/server'; +import { + BudgetAccessError, + BudgetPermissionError +} from '../../application/budgets.js'; import { CategoryHierarchyError, CategoryInUseError, @@ -18,10 +22,28 @@ import type { UpdateCategoryEndpoint } from '../endpoints.js'; +function budgetResult(err: unknown) { + if (err instanceof BudgetPermissionError) { + return ActionResult.forbidden({ message: err.message }); + } + if (err instanceof BudgetAccessError) { + return ActionResult.notFound({ message: err.message }); + } + return undefined; +} + export const listCategoriesHandler: Handler< typeof ListCategoriesEndpoint > = async ({ principal, query }, { db }) => { - return listCategories(db, principal.userId, query); + try { + return await listCategories(db, principal.userId, query); + } catch (err) { + const result = budgetResult(err); + if (result) { + return result; + } + throw err; + } }; export const createCategoryHandler: Handler< @@ -32,6 +54,10 @@ export const createCategoryHandler: Handler< await createCategory(db, principal.userId, body) ); } catch (err) { + const result = budgetResult(err); + if (result) { + return result; + } if (err instanceof CategoryHierarchyError) { return ActionResult.badRequest({ message: err.message }); } @@ -45,6 +71,10 @@ export const updateCategoryHandler: Handler< try { return await updateCategory(db, principal.userId, params.id, body); } catch (err) { + const result = budgetResult(err); + if (result) { + return result; + } if (err instanceof CategoryNotFoundError) { return ActionResult.notFound({ message: err.message }); } @@ -65,6 +95,10 @@ export const deleteCategoryHandler: Handler< await deleteCategory(db, principal.userId, params.id); return ActionResult.noContent(); } catch (err) { + const result = budgetResult(err); + if (result) { + return result; + } if (err instanceof CategoryNotFoundError) { return ActionResult.notFound({ message: err.message }); } @@ -93,6 +127,10 @@ export const moveAndDeleteCategoryHandler: Handler< ); return ActionResult.noContent(); } catch (err) { + const result = budgetResult(err); + if (result) { + return result; + } if (err instanceof CategoryNotFoundError) { return ActionResult.notFound({ message: err.message }); } diff --git a/apps/api/src/api/handlers/index.ts b/apps/api/src/api/handlers/index.ts index 7cb6833..2c578a0 100644 --- a/apps/api/src/api/handlers/index.ts +++ b/apps/api/src/api/handlers/index.ts @@ -16,6 +16,16 @@ import { singleUserSessionTokenHandler, updatePreferencesHandler } from './auth.js'; +import { + acceptBudgetInvitationHandler, + createBudgetHandler, + inviteBudgetMemberHandler, + listBudgetMembersHandler, + listBudgetsHandler, + removeBudgetMemberHandler, + updateBudgetHandler, + updateBudgetMemberHandler +} from './budgets.js'; import { createCategoryHandler, deleteCategoryHandler, @@ -93,6 +103,16 @@ export const handlers = { listMcpOAuthConnections: listMcpOAuthConnectionsHandler, revokeMcpOAuthConnection: revokeMcpOAuthConnectionHandler }, + budgets: { + list: listBudgetsHandler, + create: createBudgetHandler, + update: updateBudgetHandler, + members: listBudgetMembersHandler, + invite: inviteBudgetMemberHandler, + updateMember: updateBudgetMemberHandler, + removeMember: removeBudgetMemberHandler, + acceptInvitation: acceptBudgetInvitationHandler + }, oauth: { authorizationRequest: mcpOAuthAuthorizationRequestHandler, authorize: mcpOAuthAuthorizeHandler diff --git a/apps/api/src/api/handlers/transaction-scans.ts b/apps/api/src/api/handlers/transaction-scans.ts index 3d2489a..5786389 100644 --- a/apps/api/src/api/handlers/transaction-scans.ts +++ b/apps/api/src/api/handlers/transaction-scans.ts @@ -3,6 +3,10 @@ import { type Handler, type SubscriptionHandler } from '@cleverbrush/server'; +import { + BudgetAccessError, + BudgetPermissionError +} from '../../application/budgets.js'; import { getTransactionScanJobStatus, startTransactionScanJob, @@ -22,6 +26,16 @@ import type { TransactionScanProgressEndpoint } from '../endpoints.js'; +function budgetResult(err: unknown) { + if (err instanceof BudgetPermissionError) { + return ActionResult.forbidden({ message: err.message }); + } + if (err instanceof BudgetAccessError) { + return ActionResult.notFound({ message: err.message }); + } + return undefined; +} + export const createTransactionScanHandler: Handler< typeof CreateTransactionScanEndpoint > = async ({ body, principal }, { db, config }) => { @@ -37,6 +51,10 @@ export const createTransactionScanHandler: Handler< `/api/transaction-scans/${scan.scanId}` ); } catch (err) { + const result = budgetResult(err); + if (result) { + return result; + } if (err instanceof TransactionScanInputError) { return ActionResult.badRequest({ message: err.message }); } @@ -76,6 +94,10 @@ export const decideTransactionScanItemHandler: Handler< ); return ActionResult.noContent(); } catch (err) { + const result = budgetResult(err); + if (result) { + return result; + } if (err instanceof TransactionScanInputError) { return ActionResult.badRequest({ message: err.message }); } diff --git a/apps/api/src/api/handlers/transaction-tags.ts b/apps/api/src/api/handlers/transaction-tags.ts index 7c24559..964a427 100644 --- a/apps/api/src/api/handlers/transaction-tags.ts +++ b/apps/api/src/api/handlers/transaction-tags.ts @@ -1,9 +1,24 @@ import type { Handler } from '@cleverbrush/server'; +import { ActionResult } from '@cleverbrush/server'; +import { + BudgetAccessError, + BudgetPermissionError +} from '../../application/budgets.js'; import { listTransactionTags } from '../../application/transaction-tags.js'; import type { ListTransactionTagsEndpoint } from '../endpoints.js'; export const listTransactionTagsHandler: Handler< typeof ListTransactionTagsEndpoint -> = async ({ query, principal }, { knex }) => { - return listTransactionTags(knex, principal.userId, query); +> = async ({ query, principal }, { db }) => { + try { + return await listTransactionTags(db, principal.userId, query); + } catch (err) { + if (err instanceof BudgetPermissionError) { + return ActionResult.forbidden({ message: err.message }); + } + if (err instanceof BudgetAccessError) { + return ActionResult.notFound({ message: err.message }); + } + throw err; + } }; diff --git a/apps/api/src/api/handlers/transactions.ts b/apps/api/src/api/handlers/transactions.ts index e317b1a..b6db7d1 100644 --- a/apps/api/src/api/handlers/transactions.ts +++ b/apps/api/src/api/handlers/transactions.ts @@ -1,4 +1,8 @@ import { ActionResult, type Handler } from '@cleverbrush/server'; +import { + BudgetAccessError, + BudgetPermissionError +} from '../../application/budgets.js'; import { TransactionTagError } from '../../application/transaction-tags.js'; import { categoryTrend, @@ -33,10 +37,28 @@ import type { UpdateTransactionEndpoint } from '../endpoints.js'; +function budgetResult(err: unknown) { + if (err instanceof BudgetPermissionError) { + return ActionResult.forbidden({ message: err.message }); + } + if (err instanceof BudgetAccessError) { + return ActionResult.notFound({ message: err.message }); + } + return undefined; +} + export const listTransactionsHandler: Handler< typeof ListTransactionsEndpoint > = async ({ query, principal }, { db, knex }) => { - return listTransactions(db, principal.userId, query, knex); + try { + return await listTransactions(db, principal.userId, query, knex); + } catch (err) { + const result = budgetResult(err); + if (result) { + return result; + } + throw err; + } }; export const exportTransactionsCsvHandler: Handler< @@ -56,6 +78,10 @@ export const exportTransactionsCsvHandler: Handler< 'text/csv; charset=utf-8' ); } catch (err) { + const result = budgetResult(err); + if (result) { + return result; + } if (err instanceof TransactionExportError) { return ActionResult.badRequest({ message: err.message }); } @@ -82,6 +108,10 @@ export const createTransactionHandler: Handler< `/api/transactions/${transaction.id}` ); } catch (err) { + const result = budgetResult(err); + if (result) { + return result; + } if (err instanceof TransactionTagError) { return ActionResult.badRequest({ message: err.message }); } @@ -104,6 +134,10 @@ export const updateTransactionHandler: Handler< body ); } catch (err) { + const result = budgetResult(err); + if (result) { + return result; + } if (err instanceof TransactionNotFoundError) { return ActionResult.notFound({ message: err.message }); } @@ -124,6 +158,10 @@ export const deleteTransactionHandler: Handler< await deleteTransaction(db, principal.userId, params.id); return ActionResult.noContent(); } catch (err) { + const result = budgetResult(err); + if (result) { + return result; + } if (err instanceof TransactionNotFoundError) { return ActionResult.notFound({ message: err.message }); } @@ -133,10 +171,19 @@ export const deleteTransactionHandler: Handler< export const getTransactionScanImageHandler: Handler< typeof GetTransactionScanImageEndpoint -> = async ({ params, principal }, { knex }) => { +> = async ({ params, principal }, { db, knex }) => { try { - return await getTransactionScanImage(knex, principal.userId, params.id); + return await getTransactionScanImage( + db, + knex, + principal.userId, + params.id + ); } catch (err) { + const result = budgetResult(err); + if (result) { + return result; + } if (err instanceof TransactionNotFoundError) { return ActionResult.notFound({ message: err.message }); } @@ -147,52 +194,95 @@ export const getTransactionScanImageHandler: Handler< export const dashboardSummaryHandler: Handler< typeof DashboardSummaryEndpoint > = async ({ query, principal }, { db, config }) => { - return dashboardSummary( - db, - config, - principal.userId, - query.period ?? 'day', - query.date, - query.vendorLimit, - query.currency - ); + try { + return await dashboardSummary( + db, + config, + principal.userId, + query.period ?? 'day', + query.date, + query.vendorLimit, + query.currency, + query.budgetId + ); + } catch (err) { + const result = budgetResult(err); + if (result) { + return result; + } + throw err; + } }; export const dashboardWindowHandler: Handler< typeof DashboardWindowEndpoint > = async ({ query, principal }, { db, config }) => { - return dashboardWindow(db, config, principal.userId, { - after: query.after, - before: query.before, - currency: query.currency, - date: query.date, - vendorLimit: query.vendorLimit, - period: query.period ?? 'day' - }); + try { + return await dashboardWindow(db, config, principal.userId, { + after: query.after, + before: query.before, + budgetId: query.budgetId, + currency: query.currency, + date: query.date, + vendorLimit: query.vendorLimit, + period: query.period ?? 'day' + }); + } catch (err) { + const result = budgetResult(err); + if (result) { + return result; + } + throw err; + } }; export const statsOverviewHandler: Handler< typeof StatsOverviewEndpoint > = async ({ query, principal }, { db }) => { - return statsOverview(db, principal.userId, query); + try { + return await statsOverview(db, principal.userId, query); + } catch (err) { + const result = budgetResult(err); + if (result) { + return result; + } + throw err; + } }; export const statsWindowHandler: Handler = async ( { query, principal }, { db } ) => { - return statsWindow(db, principal.userId, { - after: query.after, - before: query.before, - date: query.date, - period: query.period ?? 'day' - }); + try { + return await statsWindow(db, principal.userId, { + after: query.after, + before: query.before, + budgetId: query.budgetId, + date: query.date, + period: query.period ?? 'day' + }); + } catch (err) { + const result = budgetResult(err); + if (result) { + return result; + } + throw err; + } }; export const statsTagReportHandler: Handler< typeof StatsTagReportEndpoint > = async ({ query, principal }, { db }) => { - return statsTagReport(db, principal.userId, query); + try { + return await statsTagReport(db, principal.userId, query); + } catch (err) { + const result = budgetResult(err); + if (result) { + return result; + } + throw err; + } }; export const categoryTrendHandler: Handler< @@ -201,6 +291,10 @@ export const categoryTrendHandler: Handler< try { return await categoryTrend(db, principal.userId, params.id, query); } catch (err) { + const result = budgetResult(err); + if (result) { + return result; + } if (err instanceof TransactionCategoryError) { return ActionResult.badRequest({ message: err.message }); } diff --git a/apps/api/src/api/handlers/vendors.ts b/apps/api/src/api/handlers/vendors.ts index af8d36e..402abb8 100644 --- a/apps/api/src/api/handlers/vendors.ts +++ b/apps/api/src/api/handlers/vendors.ts @@ -1,4 +1,8 @@ import { ActionResult, type Handler } from '@cleverbrush/server'; +import { + BudgetAccessError, + BudgetPermissionError +} from '../../application/budgets.js'; import { createVendor, getVendorCandidateDetails, @@ -22,6 +26,16 @@ import type { VendorCandidateDetailsEndpoint } from '../endpoints.js'; +function budgetResult(err: unknown) { + if (err instanceof BudgetPermissionError) { + return ActionResult.forbidden({ message: err.message }); + } + if (err instanceof BudgetAccessError) { + return ActionResult.notFound({ message: err.message }); + } + return undefined; +} + export const searchVendorCandidatesHandler: Handler< typeof SearchVendorCandidatesEndpoint > = async ({ query }, { config }) => { @@ -44,7 +58,15 @@ export const listVendorsHandler: Handler = async ( { query, principal }, { db } ) => { - return listVendors(db, principal.userId, query); + try { + return await listVendors(db, principal.userId, query); + } catch (err) { + const result = budgetResult(err); + if (result) { + return result; + } + throw err; + } }; export const getVendorHandler: Handler = async ( @@ -54,6 +76,10 @@ export const getVendorHandler: Handler = async ( try { return await getVendorDetails(db, principal.userId, params.id); } catch (err) { + const result = budgetResult(err); + if (result) { + return result; + } if (err instanceof VendorNotFoundError) { return ActionResult.notFound({ message: err.message }); } @@ -71,6 +97,10 @@ export const createVendorHandler: Handler = async ( '/api/vendors' ); } catch (err) { + const result = budgetResult(err); + if (result) { + return result; + } if (err instanceof VendorNameError) { return ActionResult.badRequest({ message: err.message }); } @@ -85,6 +115,10 @@ export const updateVendorHandler: Handler = async ( try { return await updateVendor(db, principal.userId, params.id, body); } catch (err) { + const result = budgetResult(err); + if (result) { + return result; + } if (err instanceof VendorNotFoundError) { return ActionResult.notFound({ message: err.message }); } @@ -115,6 +149,10 @@ export const enrichVendorHandler: Handler = async ( params.id ); } catch (err) { + const result = budgetResult(err); + if (result) { + return result; + } if (err instanceof VendorNotFoundError) { return ActionResult.notFound({ message: err.message }); } diff --git a/apps/api/src/application/budgets.ts b/apps/api/src/application/budgets.ts new file mode 100644 index 0000000..17c375d --- /dev/null +++ b/apps/api/src/application/budgets.ts @@ -0,0 +1,729 @@ +import { createHash, randomBytes } from 'node:crypto'; +import type { + Budget, + BudgetMember, + BudgetPermissions, + BudgetRole, + CreateBudgetBody, + InviteBudgetMemberBody, + UpdateBudgetBody, + UpdateBudgetMemberBody +} from '@xpenser/contracts'; +import type { Config } from '../config.js'; +import type { + AppDb, + BudgetDb, + BudgetInvitationDb, + BudgetMemberDb, + UserDb +} from '../db/schemas.js'; +import { sendEmail } from './email.js'; + +export class BudgetAccessError extends Error {} +export class BudgetInvitationInvalidError extends Error {} +export class BudgetMemberError extends Error {} +export class BudgetNotFoundError extends Error {} +export class BudgetPermissionError extends Error {} + +export type BudgetPermission = keyof BudgetPermissions; + +export type BudgetAccess = { + readonly budget: BudgetDb; + readonly member: BudgetMemberDb; + readonly permissions: BudgetPermissions; +}; + +const invitationTtlMs = 7 * 24 * 60 * 60 * 1000; +const budgetInvitationMessage = + 'If that email belongs to an xpenser user, a budget invitation has been sent.'; + +export const adminBudgetPermissions: BudgetPermissions = { + canCreateTransactions: true, + canUpdateTransactions: true, + canDeleteTransactions: true, + canManageCategories: true, + canManageVendors: true, + canManageTags: true, + canManageMembers: true +}; + +export const defaultMemberBudgetPermissions: BudgetPermissions = { + canCreateTransactions: true, + canUpdateTransactions: false, + canDeleteTransactions: false, + canManageCategories: false, + canManageVendors: false, + canManageTags: false, + canManageMembers: false +}; + +function normalizedBudgetName(value: string): string { + return value.trim().replace(/\s+/g, ' '); +} + +function normalizedEmail(value: string): string { + return value.trim().toLowerCase(); +} + +function normalizeCountryCode(value: string | undefined): string { + const countryCode = (value ?? 'US').trim().toUpperCase(); + return /^[A-Z]{2}$/.test(countryCode) ? countryCode : 'US'; +} + +function normalizeCurrency( + value: string | undefined, + fallback: string +): string { + const currency = (value ?? fallback).trim().toUpperCase(); + return /^[A-Z]{3}$/.test(currency) ? currency : fallback; +} + +function permissionsForRole( + role: BudgetRole, + permissions?: Partial +): BudgetPermissions { + if (role === 'admin') { + return adminBudgetPermissions; + } + + return { + ...defaultMemberBudgetPermissions, + ...Object.fromEntries( + Object.entries(permissions ?? {}).filter( + ([, value]) => typeof value === 'boolean' + ) + ) + } as BudgetPermissions; +} + +function memberPermissions(member: BudgetMemberDb): BudgetPermissions { + if (member.role === 'admin') { + return adminBudgetPermissions; + } + + return { + canCreateTransactions: member.canCreateTransactions, + canUpdateTransactions: member.canUpdateTransactions, + canDeleteTransactions: member.canDeleteTransactions, + canManageCategories: member.canManageCategories, + canManageVendors: member.canManageVendors, + canManageTags: member.canManageTags, + canManageMembers: member.canManageMembers + }; +} + +function invitationPermissions( + invitation: BudgetInvitationDb +): BudgetPermissions { + if (invitation.role === 'admin') { + return adminBudgetPermissions; + } + + return { + canCreateTransactions: invitation.canCreateTransactions, + canUpdateTransactions: invitation.canUpdateTransactions, + canDeleteTransactions: invitation.canDeleteTransactions, + canManageCategories: invitation.canManageCategories, + canManageVendors: invitation.canManageVendors, + canManageTags: invitation.canManageTags, + canManageMembers: invitation.canManageMembers + }; +} + +function budgetMemberValues( + role: BudgetRole, + permissions?: Partial +) { + const resolved = permissionsForRole(role, permissions); + return { + role, + canCreateTransactions: resolved.canCreateTransactions, + canUpdateTransactions: resolved.canUpdateTransactions, + canDeleteTransactions: resolved.canDeleteTransactions, + canManageCategories: resolved.canManageCategories, + canManageVendors: resolved.canManageVendors, + canManageTags: resolved.canManageTags, + canManageMembers: resolved.canManageMembers + }; +} + +function mapBudget( + budget: BudgetDb, + member: BudgetMemberDb, + mainBudgetId: number | null | undefined +): Budget { + return { + id: budget.id, + name: budget.name, + defaultCurrency: budget.defaultCurrency, + countryCode: normalizeCountryCode(budget.countryCode), + role: member.role === 'admin' ? 'admin' : 'member', + permissions: memberPermissions(member), + isMain: budget.id === mainBudgetId, + createdAt: budget.createdAt, + updatedAt: budget.updatedAt + }; +} + +function mapBudgetMember( + row: BudgetMemberDb & { readonly email: string } +): BudgetMember { + return { + budgetId: row.budgetId, + userId: row.userId, + email: row.email, + role: row.role === 'admin' ? 'admin' : 'member', + permissions: memberPermissions(row), + createdAt: row.createdAt, + updatedAt: row.updatedAt + }; +} + +export function hashBudgetInvitationToken(token: string): string { + return createHash('sha256').update(token).digest('hex'); +} + +export function createBudgetInvitationToken(): string { + return randomBytes(32).toString('base64url'); +} + +export async function ensureMainBudget( + db: AppDb, + userId: number +): Promise { + const user = (await db.users.find(userId)) as UserDb | undefined; + if (!user) { + throw new BudgetNotFoundError('User was not found.'); + } + + if (user.mainBudgetId) { + const [budget, member] = await Promise.all([ + db.budgets.find(user.mainBudgetId), + db.budgetMembers + .where(row => row.budgetId, user.mainBudgetId) + .where(row => row.userId, userId) + .first() + ]); + if (budget && member) { + return budget as BudgetDb; + } + } + + return db.transaction(async trx => { + const created = (await trx.budgets.insert({ + name: 'Main', + defaultCurrency: user.defaultCurrency, + countryCode: normalizeCountryCode(user.countryCode), + createdByUserId: user.id + })) as BudgetDb; + + await trx.budgetMembers.insert({ + budgetId: created.id, + userId, + ...budgetMemberValues('admin') + }); + await trx.users + .where(row => row.id, userId) + .update({ mainBudgetId: created.id, updatedAt: new Date() }); + + return created; + }); +} + +export async function createMainBudgetForUser( + db: AppDb, + user: Pick +): Promise { + const budget = (await db.budgets.insert({ + name: 'Main', + defaultCurrency: user.defaultCurrency, + countryCode: normalizeCountryCode(user.countryCode), + createdByUserId: user.id + })) as BudgetDb; + + await db.budgetMembers.insert({ + budgetId: budget.id, + userId: user.id, + ...budgetMemberValues('admin') + }); + await db.users + .where(row => row.id, user.id) + .update({ mainBudgetId: budget.id, updatedAt: new Date() }); + + return budget; +} + +export async function resolveBudgetAccess( + db: AppDb, + userId: number, + budgetId?: number | null +): Promise { + const selectedBudgetId = + budgetId ?? (await ensureMainBudget(db, userId)).id; + const [budget, member] = await Promise.all([ + db.budgets.find(selectedBudgetId), + db.budgetMembers + .where(row => row.budgetId, selectedBudgetId) + .where(row => row.userId, userId) + .first() + ]); + + if (!budget || !member) { + throw new BudgetAccessError('Budget was not found.'); + } + + return { + budget: budget as BudgetDb, + member: member as BudgetMemberDb, + permissions: memberPermissions(member as BudgetMemberDb) + }; +} + +export function requireBudgetPermission( + access: BudgetAccess, + permission: BudgetPermission +): void { + if (!access.permissions[permission]) { + throw new BudgetPermissionError( + 'You do not have permission to perform this budget action.' + ); + } +} + +export async function listBudgets( + db: AppDb, + userId: number +): Promise { + await ensureMainBudget(db, userId); + const user = (await db.users.find(userId)) as UserDb | undefined; + const rows = (await db + .knex('budget_members as member') + .join('budgets as budget', 'budget.id', 'member.budget_id') + .where('member.user_id', userId) + .orderByRaw('case when budget.id = ? then 0 else 1 end', [ + user?.mainBudgetId ?? 0 + ]) + .orderBy('budget.name', 'asc') + .select({ + budgetId: 'budget.id', + name: 'budget.name', + defaultCurrency: 'budget.default_currency', + countryCode: 'budget.country_code', + createdByUserId: 'budget.created_by_user_id', + budgetCreatedAt: 'budget.created_at', + budgetUpdatedAt: 'budget.updated_at', + userId: 'member.user_id', + role: 'member.role', + canCreateTransactions: 'member.can_create_transactions', + canUpdateTransactions: 'member.can_update_transactions', + canDeleteTransactions: 'member.can_delete_transactions', + canManageCategories: 'member.can_manage_categories', + canManageVendors: 'member.can_manage_vendors', + canManageTags: 'member.can_manage_tags', + canManageMembers: 'member.can_manage_members', + memberCreatedAt: 'member.created_at', + memberUpdatedAt: 'member.updated_at' + })) as Array<{ + readonly budgetId: number; + readonly name: string; + readonly defaultCurrency: string; + readonly countryCode: string; + readonly createdByUserId: number | null; + readonly budgetCreatedAt: Date; + readonly budgetUpdatedAt: Date; + readonly userId: number; + readonly role: string; + readonly canCreateTransactions: boolean; + readonly canUpdateTransactions: boolean; + readonly canDeleteTransactions: boolean; + readonly canManageCategories: boolean; + readonly canManageVendors: boolean; + readonly canManageTags: boolean; + readonly canManageMembers: boolean; + readonly memberCreatedAt: Date; + readonly memberUpdatedAt: Date; + }>; + + return rows.map(row => + mapBudget( + { + id: Number(row.budgetId), + name: row.name, + defaultCurrency: row.defaultCurrency, + countryCode: row.countryCode, + createdByUserId: row.createdByUserId ?? undefined, + createdAt: row.budgetCreatedAt, + updatedAt: row.budgetUpdatedAt + }, + { + budgetId: Number(row.budgetId), + userId: row.userId, + role: row.role, + canCreateTransactions: row.canCreateTransactions, + canUpdateTransactions: row.canUpdateTransactions, + canDeleteTransactions: row.canDeleteTransactions, + canManageCategories: row.canManageCategories, + canManageVendors: row.canManageVendors, + canManageTags: row.canManageTags, + canManageMembers: row.canManageMembers, + createdAt: row.memberCreatedAt, + updatedAt: row.memberUpdatedAt + }, + user?.mainBudgetId + ) + ); +} + +export async function createBudget( + db: AppDb, + userId: number, + body: CreateBudgetBody +): Promise { + const user = (await db.users.find(userId)) as UserDb | undefined; + if (!user) { + throw new BudgetNotFoundError('User was not found.'); + } + const name = normalizedBudgetName(body.name); + if (!name) { + throw new BudgetMemberError('Budget name is required.'); + } + + const budget = await db.transaction(async trx => { + const created = (await trx.budgets.insert({ + name, + defaultCurrency: normalizeCurrency( + body.defaultCurrency, + user.defaultCurrency + ), + countryCode: normalizeCountryCode( + body.countryCode ?? user.countryCode + ), + createdByUserId: userId + })) as BudgetDb; + await trx.budgetMembers.insert({ + budgetId: created.id, + userId, + ...budgetMemberValues('admin') + }); + return created; + }); + + const access = await resolveBudgetAccess(db, userId, budget.id); + return mapBudget(budget, access.member, user.mainBudgetId); +} + +export async function updateBudget( + db: AppDb, + userId: number, + budgetId: number, + body: UpdateBudgetBody +): Promise { + const access = await resolveBudgetAccess(db, userId, budgetId); + requireBudgetPermission(access, 'canManageMembers'); + + const update: { + countryCode?: string; + defaultCurrency?: string; + name?: string; + updatedAt: Date; + } = { updatedAt: new Date() }; + if (body.name !== undefined) { + const name = normalizedBudgetName(body.name); + if (!name) { + throw new BudgetMemberError('Budget name is required.'); + } + update.name = name; + } + if (body.defaultCurrency !== undefined) { + update.defaultCurrency = normalizeCurrency( + body.defaultCurrency, + access.budget.defaultCurrency + ); + } + if (body.countryCode !== undefined) { + update.countryCode = normalizeCountryCode(body.countryCode); + } + + const [updated] = (await db.budgets + .where(row => row.id, budgetId) + .update(update as never)) as BudgetDb[]; + if (!updated) { + throw new BudgetNotFoundError('Budget was not found.'); + } + + const user = (await db.users.find(userId)) as UserDb | undefined; + return mapBudget(updated, access.member, user?.mainBudgetId); +} + +export async function listBudgetMembers( + db: AppDb, + userId: number, + budgetId: number +): Promise { + const access = await resolveBudgetAccess(db, userId, budgetId); + requireBudgetPermission(access, 'canManageMembers'); + + const rows = (await db + .knex('budget_members as member') + .join('users as user', 'user.id', 'member.user_id') + .where('member.budget_id', budgetId) + .orderBy('user.email', 'asc') + .select({ + budgetId: 'member.budget_id', + userId: 'member.user_id', + email: 'user.email', + role: 'member.role', + canCreateTransactions: 'member.can_create_transactions', + canUpdateTransactions: 'member.can_update_transactions', + canDeleteTransactions: 'member.can_delete_transactions', + canManageCategories: 'member.can_manage_categories', + canManageVendors: 'member.can_manage_vendors', + canManageTags: 'member.can_manage_tags', + canManageMembers: 'member.can_manage_members', + createdAt: 'member.created_at', + updatedAt: 'member.updated_at' + })) as Array; + + return rows.map(mapBudgetMember); +} + +async function adminCount(db: AppDb, budgetId: number): Promise { + const [row] = (await db + .knex('budget_members') + .where({ budget_id: budgetId, role: 'admin' }) + .count<{ readonly count: number | string }[]>({ + count: '*' + })) as Array<{ + readonly count: number | string; + }>; + return Number(row?.count ?? 0); +} + +async function ensureCanChangeMember( + db: AppDb, + budgetId: number, + targetUserId: number, + nextRole?: BudgetRole +): Promise { + const current = (await db.budgetMembers + .where(row => row.budgetId, budgetId) + .where(row => row.userId, targetUserId) + .first()) as BudgetMemberDb | undefined; + if (!current) { + throw new BudgetNotFoundError('Budget member was not found.'); + } + if (current.role === 'admin' && nextRole !== 'admin') { + const count = await adminCount(db, budgetId); + if (count <= 1) { + throw new BudgetMemberError( + 'A budget must have at least one admin.' + ); + } + } +} + +export async function updateBudgetMember( + db: AppDb, + userId: number, + budgetId: number, + targetUserId: number, + body: UpdateBudgetMemberBody +): Promise { + const access = await resolveBudgetAccess(db, userId, budgetId); + requireBudgetPermission(access, 'canManageMembers'); + await ensureCanChangeMember(db, budgetId, targetUserId, body.role); + + const values = { + ...budgetMemberValues(body.role, body.permissions), + updatedAt: new Date() + }; + const [updated] = (await db.budgetMembers + .where(row => row.budgetId, budgetId) + .where(row => row.userId, targetUserId) + .update(values as never)) as BudgetMemberDb[]; + if (!updated) { + throw new BudgetNotFoundError('Budget member was not found.'); + } + + const user = (await db.users.find(targetUserId)) as UserDb | undefined; + if (!user) { + throw new BudgetNotFoundError('Budget member was not found.'); + } + return mapBudgetMember({ ...updated, email: user.email }); +} + +export async function removeBudgetMember( + db: AppDb, + userId: number, + budgetId: number, + targetUserId: number +): Promise { + const access = await resolveBudgetAccess(db, userId, budgetId); + requireBudgetPermission(access, 'canManageMembers'); + await ensureCanChangeMember(db, budgetId, targetUserId, undefined); + + const deleted = await db.budgetMembers + .where(row => row.budgetId, budgetId) + .where(row => row.userId, targetUserId) + .delete(); + if (deleted === 0) { + throw new BudgetNotFoundError('Budget member was not found.'); + } +} + +function invitationLink(config: Config, token: string): string { + const url = new URL('/budgets/invitations/accept', config.app.url); + url.searchParams.set('token', token); + return url.toString(); +} + +function htmlEscape(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +async function sendBudgetInvitationEmail( + config: Config, + email: string, + budget: BudgetDb, + token: string +): Promise { + const url = invitationLink(config, token); + const safeBudgetName = htmlEscape(budget.name); + const safeUrl = htmlEscape(url); + await sendEmail(config, { + to: email, + subject: `Join ${budget.name} on xpenser`, + text: `Open this magic link to join ${budget.name} on xpenser: ${url}`, + html: ` +
+

Join ${safeBudgetName} on xpenser

+

Open this magic link to join the shared budget.

+

+ + Join budget + +

+

If the button does not work, paste this link into your browser:

+

${safeUrl}

+
+ ` + }); +} + +export async function inviteBudgetMember( + db: AppDb, + config: Config, + userId: number, + budgetId: number, + body: InviteBudgetMemberBody +): Promise<{ readonly message: string }> { + const access = await resolveBudgetAccess(db, userId, budgetId); + requireBudgetPermission(access, 'canManageMembers'); + + const email = normalizedEmail(body.email); + const target = (await db.users.where(row => row.email, email).first()) as + | UserDb + | undefined; + if (!target) { + return { message: budgetInvitationMessage }; + } + + const existingMember = await db.budgetMembers + .where(row => row.budgetId, budgetId) + .where(row => row.userId, target.id) + .first(); + if (existingMember) { + return { message: budgetInvitationMessage }; + } + + const token = createBudgetInvitationToken(); + const role = body.role === 'admin' ? 'admin' : 'member'; + const values = budgetMemberValues(role, body.permissions); + + await db.budgetInvitations.insert({ + budgetId, + invitedByUserId: userId, + email, + ...values, + tokenHash: hashBudgetInvitationToken(token), + expiresAt: new Date(Date.now() + invitationTtlMs), + consumedAt: undefined + }); + await sendBudgetInvitationEmail(config, email, access.budget, token); + + return { message: budgetInvitationMessage }; +} + +export async function acceptBudgetInvitation( + db: AppDb, + userId: number, + token: string +): Promise { + const tokenHash = hashBudgetInvitationToken(token); + const user = (await db.users.find(userId)) as UserDb | undefined; + if (!user) { + throw new BudgetInvitationInvalidError( + 'Budget invitation is invalid or expired.' + ); + } + + const now = new Date(); + const invitation = (await db.budgetInvitations + .where(row => row.tokenHash, tokenHash) + .first()) as BudgetInvitationDb | undefined; + if ( + !invitation || + invitation.consumedAt || + invitation.expiresAt.getTime() <= now.getTime() || + normalizedEmail(invitation.email) !== normalizedEmail(user.email) + ) { + throw new BudgetInvitationInvalidError( + 'Budget invitation is invalid or expired.' + ); + } + + await db.transaction(async trx => { + await trx + .knex('budget_members') + .insert({ + budget_id: invitation.budgetId, + user_id: userId, + ...Object.fromEntries( + Object.entries({ + role: invitation.role, + can_create_transactions: + invitationPermissions(invitation) + .canCreateTransactions, + can_update_transactions: + invitationPermissions(invitation) + .canUpdateTransactions, + can_delete_transactions: + invitationPermissions(invitation) + .canDeleteTransactions, + can_manage_categories: + invitationPermissions(invitation) + .canManageCategories, + can_manage_vendors: + invitationPermissions(invitation).canManageVendors, + can_manage_tags: + invitationPermissions(invitation).canManageTags, + can_manage_members: + invitationPermissions(invitation).canManageMembers + }) + ) + }) + .onConflict(['budget_id', 'user_id']) + .merge(); + await trx.budgetInvitations + .where(row => row.id, invitation.id) + .update({ consumedAt: now, updatedAt: now }); + }); + + const access = await resolveBudgetAccess(db, userId, invitation.budgetId); + return mapBudget(access.budget, access.member, user.mainBudgetId); +} diff --git a/apps/api/src/application/categories.test.ts b/apps/api/src/application/categories.test.ts index 5f2ef80..54380a4 100644 --- a/apps/api/src/application/categories.test.ts +++ b/apps/api/src/application/categories.test.ts @@ -40,6 +40,7 @@ describe('category transaction availability', () => { const car: CategoryDb = { id: 1, userId: 1, + budgetId: 1, name: 'Car', type: 'expense', parentId: null, @@ -117,6 +118,7 @@ describe('category popularity ordering', () => { type TestTransaction = { id: number; userId: number; + budgetId: number; categoryId: number; type: 'expense' | 'income'; updatedAt: Date; @@ -180,6 +182,7 @@ function categoryRow( return { id, userId: 1, + budgetId: 1, name, type: 'expense', parentId: null, @@ -192,6 +195,12 @@ function categoryRow( } type TestDb = { + budgetMembers: { + where(): { where(): { first(): Promise } }; + }; + budgets: { + find(id: number): Promise; + }; categories: { where( selector: (row: CategoryDb) => TValue, @@ -211,7 +220,40 @@ function testDb( categories: CategoryDb[], transactions: TestTransaction[] ): TestDb { + const budget = { + id: 1, + name: 'Main', + defaultCurrency: 'USD', + countryCode: 'US', + createdByUserId: 1, + createdAt: new Date('2026-05-10T12:30:00.000Z'), + updatedAt: new Date('2026-05-10T12:30:00.000Z') + }; + const member = { + budgetId: 1, + userId: 1, + role: 'admin', + canCreateTransactions: true, + canUpdateTransactions: true, + canDeleteTransactions: true, + canManageCategories: true, + canManageVendors: true, + canManageTags: true, + canManageMembers: true, + createdAt: budget.createdAt, + updatedAt: budget.updatedAt + }; return { + budgets: { + find: async () => budget + }, + budgetMembers: { + where: () => ({ + where: () => ({ + first: async () => member + }) + }) + }, categories: { where( selector: (row: CategoryDb) => TValue, @@ -260,6 +302,7 @@ describe('move and delete category', () => { { id: 1, userId: 1, + budgetId: 1, categoryId: source.id, type: 'income' as const, updatedAt: source.updatedAt @@ -267,6 +310,7 @@ describe('move and delete category', () => { { id: 2, userId: 2, + budgetId: 2, categoryId: source.id, type: 'expense' as const, updatedAt: source.updatedAt @@ -301,6 +345,7 @@ describe('move and delete category', () => { { id: 1, userId: 1, + budgetId: 1, categoryId: source.id, type: 'expense' as const, updatedAt: source.updatedAt diff --git a/apps/api/src/application/categories.ts b/apps/api/src/application/categories.ts index 2cecd06..cdbe703 100644 --- a/apps/api/src/application/categories.ts +++ b/apps/api/src/application/categories.ts @@ -6,6 +6,7 @@ import type { UpdateCategoryBodySchema } from '@xpenser/contracts'; import type { AppDb, CategoryDb, TransactionDb } from '../db/schemas.js'; +import { requireBudgetPermission, resolveBudgetAccess } from './budgets.js'; export class CategoryHierarchyError extends Error {} export class CategoryInUseError extends Error {} @@ -76,6 +77,7 @@ function mapCategory( return { id: row.id, + budgetId: row.budgetId, name: row.name, type: row.type, kind: normalizeCategoryKind(row.kind), @@ -110,11 +112,11 @@ function compareCategories( async function usedCategoryIds( db: AppDb, - userId: number + budgetId: number ): Promise> { const transactions = await db.transactions.where( - transaction => transaction.userId, - userId + transaction => transaction.budgetId, + budgetId ); return new Set(transactions.map(transaction => transaction.categoryId)); @@ -122,13 +124,13 @@ async function usedCategoryIds( async function recentCategoryTransactions( db: AppDb, - userId: number + budgetId: number ): Promise { const now = new Date(); const from = new Date(now.getTime() - recentCategoryWindowMs); return (await db.transactions - .where(transaction => transaction.userId, userId) + .where(transaction => transaction.budgetId, budgetId) .where(transaction => transaction.occurredAt, '>=', from) .where( transaction => transaction.occurredAt, @@ -175,7 +177,22 @@ async function getCategory( ): Promise { const category = await db.categories .where(candidate => candidate.id, categoryId) - .where(candidate => candidate.userId, userId) + .first(); + if (!category) { + throw new CategoryNotFoundError('Category was not found.'); + } + await resolveBudgetAccess(db, userId, (category as CategoryDb).budgetId); + return category as CategoryDb; +} + +async function getCategoryInBudget( + db: AppDb, + budgetId: number, + categoryId: number +): Promise { + const category = await db.categories + .where(candidate => candidate.id, categoryId) + .where(candidate => candidate.budgetId, budgetId) .first(); if (!category) { throw new CategoryNotFoundError('Category was not found.'); @@ -185,11 +202,11 @@ async function getCategory( async function categoryHasChildren( db: AppDb, - userId: number, + budgetId: number, categoryId: number ): Promise { const children = await db.categories - .where(candidate => candidate.userId, userId) + .where(candidate => candidate.budgetId, budgetId) .where(candidate => candidate.parentId, categoryId) .limit(1); return children.length > 0; @@ -197,7 +214,7 @@ async function categoryHasChildren( async function validateCategoryStructure( db: AppDb, - userId: number, + budgetId: number, body: { readonly type: 'expense' | 'income'; readonly parentId?: number | null; @@ -223,7 +240,7 @@ async function validateCategoryStructure( ); } - const parent = await getCategory(db, userId, parentId); + const parent = await getCategoryInBudget(db, budgetId, parentId); if (parent.parentId) { throw new CategoryHierarchyError( 'Only one level of category nesting is supported.' @@ -254,11 +271,12 @@ export async function listCategories( userId: number, query: CategoryListQuery = {} ): Promise { + const access = await resolveBudgetAccess(db, userId, query.budgetId); const [categories, inUse, recentTransactions] = await Promise.all([ - db.categories.where(category => category.userId, userId), - usedCategoryIds(db, userId), + db.categories.where(category => category.budgetId, access.budget.id), + usedCategoryIds(db, access.budget.id), query.sort === 'recent-transaction-count' - ? recentCategoryTransactions(db, userId) + ? recentCategoryTransactions(db, access.budget.id) : Promise.resolve([]) ]); const categoryRows = categories as CategoryDb[]; @@ -300,15 +318,18 @@ export async function createCategory( userId: number, body: CreateCategoryBody ): Promise { + const access = await resolveBudgetAccess(db, userId, body.budgetId); + requireBudgetPermission(access, 'canManageCategories'); const parentId = body.parentId ?? null; const kind = body.kind ?? 'normal'; - await validateCategoryStructure(db, userId, { + await validateCategoryStructure(db, access.budget.id, { type: body.type, parentId, kind }); const created = await db.categories.insert({ + budgetId: access.budget.id, userId, parentId: parentId ?? undefined, name: body.name.trim(), @@ -318,8 +339,8 @@ export async function createCategory( }); const categories = (await db.categories.where( - category => category.userId, - userId + category => category.budgetId, + access.budget.id )) as CategoryDb[]; const categoriesById = new Map( categories.map(category => [category.id, category] as const) @@ -335,8 +356,14 @@ export async function updateCategory( body: UpdateCategoryBody ): Promise { const current = await getCategory(db, userId, categoryId); - const inUse = await usedCategoryIds(db, userId); - const hasChildren = await categoryHasChildren(db, userId, categoryId); + const access = await resolveBudgetAccess(db, userId, current.budgetId); + requireBudgetPermission(access, 'canManageCategories'); + const inUse = await usedCategoryIds(db, access.budget.id); + const hasChildren = await categoryHasChildren( + db, + access.budget.id, + categoryId + ); const structuralChange = hasStructuralChange(current, body); if (structuralChange && inUse.has(categoryId)) { @@ -359,7 +386,7 @@ export async function updateCategory( kind: body.kind ?? normalizeCategoryKind(current.kind) }; - await validateCategoryStructure(db, userId, next, categoryId); + await validateCategoryStructure(db, access.budget.id, next, categoryId); const update: { name?: string; @@ -387,7 +414,7 @@ export async function updateCategory( const [updated] = await db.categories .where(category => category.id, categoryId) - .where(category => category.userId, userId) + .where(category => category.budgetId, access.budget.id) .update(update as never) .then(rows => rows as CategoryDb[]); @@ -396,8 +423,8 @@ export async function updateCategory( } const categories = (await db.categories.where( - category => category.userId, - userId + category => category.budgetId, + access.budget.id )) as CategoryDb[]; const categoriesById = new Map( categories.map(category => [category.id, category] as const) @@ -422,10 +449,12 @@ export async function deleteCategory( categoryId: number ): Promise { const source = await getCategory(db, userId, categoryId); + const access = await resolveBudgetAccess(db, userId, source.budgetId); + requireBudgetPermission(access, 'canManageCategories'); const categories = (await db.categories.where( - candidate => candidate.userId, - userId + candidate => candidate.budgetId, + access.budget.id )) as CategoryDb[]; const categoriesById = new Map( categories.map(category => [category.id, category] as const) @@ -440,7 +469,7 @@ export async function deleteCategory( ); } - if (await categoryHasChildren(db, userId, categoryId)) { + if (await categoryHasChildren(db, access.budget.id, categoryId)) { throw new CategoryHierarchyError( 'Category cannot be deleted while it has subcategories.' ); @@ -457,7 +486,7 @@ export async function deleteCategory( await db.categories .where(candidate => candidate.id, categoryId) - .where(candidate => candidate.userId, userId) + .where(candidate => candidate.budgetId, access.budget.id) .delete(); } @@ -467,10 +496,14 @@ export async function moveAndDeleteCategory( categoryId: number, replacementCategoryId: number ): Promise { - const [source, replacement] = await Promise.all([ - getCategory(db, userId, categoryId), - getCategory(db, userId, replacementCategoryId) - ]); + const source = await getCategory(db, userId, categoryId); + const access = await resolveBudgetAccess(db, userId, source.budgetId); + requireBudgetPermission(access, 'canManageCategories'); + const replacement = await getCategoryInBudget( + db, + access.budget.id, + replacementCategoryId + ); if (source.id === replacement.id) { throw new CategoryHierarchyError( @@ -479,8 +512,8 @@ export async function moveAndDeleteCategory( } const categories = (await db.categories.where( - candidate => candidate.userId, - userId + candidate => candidate.budgetId, + access.budget.id )) as CategoryDb[]; const categoriesById = new Map( categories.map(category => [category.id, category] as const) @@ -501,7 +534,7 @@ export async function moveAndDeleteCategory( ); } - if (await categoryHasChildren(db, userId, categoryId)) { + if (await categoryHasChildren(db, access.budget.id, categoryId)) { throw new CategoryHierarchyError( 'Category cannot be deleted while it has subcategories.' ); @@ -516,7 +549,7 @@ export async function moveAndDeleteCategory( const now = new Date(); await db.transaction(async trx => { await trx.transactions - .where(transaction => transaction.userId, userId) + .where(transaction => transaction.budgetId, access.budget.id) .where(transaction => transaction.categoryId, categoryId) .update({ categoryId: replacement.id, @@ -526,7 +559,7 @@ export async function moveAndDeleteCategory( await trx.categories .where(candidate => candidate.id, categoryId) - .where(candidate => candidate.userId, userId) + .where(candidate => candidate.budgetId, access.budget.id) .delete(); }); } diff --git a/apps/api/src/application/email-reports.test.ts b/apps/api/src/application/email-reports.test.ts index fdeb9bf..942728a 100644 --- a/apps/api/src/application/email-reports.test.ts +++ b/apps/api/src/application/email-reports.test.ts @@ -11,6 +11,7 @@ const timestamp = new Date('2026-05-01T00:00:00.000Z'); const groceries = { id: 1, userId: 1, + budgetId: 1, parentId: null, name: 'Groceries', type: 'expense', @@ -21,6 +22,7 @@ const groceries = { const walmart = { id: 7, userId: 1, + budgetId: 1, name: 'Walmart', normalizedName: 'walmart', resolvedName: 'Walmart', @@ -53,6 +55,7 @@ function transaction({ return { id, userId: 1, + budgetId: 1, categoryId: groceries.id, category: groceries, type: 'expense', @@ -141,6 +144,8 @@ describe('email report due checks', () => { describe('email report OpenAI payload', () => { it('labels expense-parent offset categories as income returns', () => { const payload = emailReportOpenAiPayload({ + budgetId: 1, + budgetName: 'Main', type: 'weekly', period: { from: new Date('2026-05-25T00:00:00.000Z'), diff --git a/apps/api/src/application/email-reports.ts b/apps/api/src/application/email-reports.ts index 4fc4910..fbacb10 100644 --- a/apps/api/src/application/email-reports.ts +++ b/apps/api/src/application/email-reports.ts @@ -11,6 +11,7 @@ import type { Knex } from 'knex'; import type { Config } from '../config.js'; import type { AppDb, + BudgetDb, CategoryDb, TransactionDb, UserDb, @@ -45,6 +46,7 @@ type ReportUser = Pick< type ReportSendOutcome = { readonly type: EmailReportType; + readonly budgetId: number; readonly status: 'sent' | 'skipped'; readonly from: Date; readonly to: Date; @@ -105,6 +107,8 @@ type NotedTransaction = NotableTransaction & { type ReportAnalytics = { readonly type: EmailReportType; + readonly budgetId: number; + readonly budgetName: string; readonly period: ReportPeriod; readonly periodLabel: string; readonly currency: string; @@ -453,25 +457,25 @@ function vendorSummariesForReport( async function transactionsForPeriod( db: AppDb, - userId: number, + budgetId: number, period: ReportPeriod ): Promise { return (await db.transactions .include(transaction => transaction.category) - .where(transaction => transaction.userId, userId) + .where(transaction => transaction.budgetId, budgetId) .whereBetween( transaction => transaction.occurredAt, [period.from, period.to] )) as TransactionDb[]; } -async function categoriesForUser( +async function categoriesForBudget( db: AppDb, - userId: number + budgetId: number ): Promise> { const categories = (await db.categories.where( - category => category.userId, - userId + category => category.budgetId, + budgetId )) as CategoryDb[]; return new Map( @@ -479,13 +483,13 @@ async function categoriesForUser( ); } -async function vendorsForUser( +async function vendorsForBudget( db: AppDb, - userId: number + budgetId: number ): Promise> { const vendors = (await db.vendors.where( - vendor => vendor.userId, - userId + vendor => vendor.budgetId, + budgetId )) as VendorDb[]; return new Map(vendors.map(vendor => [vendor.id, vendor] as const)); @@ -494,6 +498,7 @@ async function vendorsForUser( async function buildReportAnalytics( db: AppDb, user: ReportUser, + budget: Pick, type: EmailReportType, period: ReportPeriod ): Promise { @@ -503,11 +508,12 @@ async function buildReportAnalytics( date: period.from, groupBy: type === 'weekly' ? 'day' : 'week', period: type === 'weekly' ? 'week' : 'month', - timeframe: 'custom' + timeframe: 'custom', + budgetId: budget.id }), - transactionsForPeriod(db, user.id, period), - categoriesForUser(db, user.id), - vendorsForUser(db, user.id) + transactionsForPeriod(db, budget.id, period), + categoriesForBudget(db, budget.id), + vendorsForBudget(db, budget.id) ]); if (overview.transactionCount === 0) { @@ -552,6 +558,8 @@ async function buildReportAnalytics( return { type, + budgetId: budget.id, + budgetName: budget.name, period, periodLabel: periodLabel(period, user.timezone), currency: overview.currency, @@ -596,6 +604,10 @@ export function emailReportOpenAiPayload(analytics: ReportAnalytics) { type: analytics.type, period: analytics.periodLabel, currency: analytics.currency, + budget: { + id: analytics.budgetId, + name: analytics.budgetName + }, dataSemantics: { totals: 'Income, expenses, category totals, trends, and averages use each category reporting side.', categoryKinds: { @@ -606,7 +618,7 @@ export function emailReportOpenAiPayload(analytics: ReportAnalytics) { 'amount is the positive magnitude; categoryImpact is the positive contribution to the reported income or expense category; netImpact is the signed impact on net position.', notes: 'Transaction notes are user-provided context. Use them when relevant, but do not invent vendors, purposes, or details beyond the note text.', vendors: - 'Vendors include only linked expense transactions in this report period. Unlinked transactions are absent from vendor summaries. Vendor names, domains, and descriptions come from user records and enrichment data when available.' + 'Vendors include only linked expense transactions in this budget and report period. Unlinked transactions are absent from vendor summaries. Vendor names, domains, and descriptions come from budget records and enrichment data when available.' }, totals: { income: analytics.incomeTotal, @@ -715,7 +727,7 @@ async function generateInsights( function reportSubject(analytics: ReportAnalytics, insights: ReportInsights) { const cadence = analytics.type === 'weekly' ? 'Weekly' : 'Monthly'; - return `${cadence} xpenser report: ${insights.headline}`; + return `${cadence} xpenser report for ${analytics.budgetName}: ${insights.headline}`; } function statsUrl( @@ -732,6 +744,7 @@ function statsUrl( 'date', dateToLocalDateParam(analytics.period.from, timeZone) ); + url.searchParams.set('budgetId', String(analytics.budgetId)); return url.toString(); } @@ -749,6 +762,7 @@ function emailText( insights.headline, '', `${analytics.type === 'weekly' ? 'Weekly' : 'Monthly'} report for ${analytics.periodLabel}`, + `Budget: ${analytics.budgetName}`, insights.recap, '', `Income: ${money(analytics.incomeTotal, analytics.currency)}`, @@ -830,6 +844,7 @@ function emailHtml( return `

${analytics.type === 'weekly' ? 'Weekly' : 'Monthly'} report for ${analytics.periodLabel}

+

Budget: ${htmlEscape(analytics.budgetName)}

${htmlEscape(insights.headline)}

${htmlEscape(insights.recap)}

@@ -863,25 +878,28 @@ async function sendReportEmail( function scheduledDeliveryKey( userId: number, + budgetId: number, type: EmailReportType, period: ReportPeriod ): string { - return `${userId}:${type}:${period.from.toISOString()}`; + return `${userId}:${budgetId}:${type}:${period.from.toISOString()}`; } async function claimDelivery( knex: Knex, config: Config, user: ReportUser, + budget: Pick, type: EmailReportType, trigger: ReportTrigger, period: ReportPeriod ): Promise { - const deliveryKey = scheduledDeliveryKey(user.id, type, period); + const deliveryKey = scheduledDeliveryKey(user.id, budget.id, type, period); const result = await knex.raw<{ rows: { id: number }[] }>( ` insert into email_report_deliveries ( user_id, + budget_id, delivery_key, report_type, trigger, @@ -892,7 +910,7 @@ async function claimDelivery( attempts, created_at, updated_at - ) values (?, ?, ?, ?, ?, ?, ?, 'pending', 1, now(), now()) + ) values (?, ?, ?, ?, ?, ?, ?, ?, 'pending', 1, now(), now()) on conflict (delivery_key) do update set status = 'pending', attempts = email_report_deliveries.attempts + 1, @@ -904,6 +922,7 @@ async function claimDelivery( `, [ user.id, + budget.id, deliveryKey, type, trigger, @@ -959,6 +978,7 @@ async function sendEmailReport( knex: Knex, config: Config, user: ReportUser, + budget: Pick, type: EmailReportType, trigger: ReportTrigger, now: Date @@ -969,6 +989,7 @@ async function sendEmailReport( knex, config, user, + budget, type, trigger, period @@ -976,6 +997,7 @@ async function sendEmailReport( if (!deliveryId) { return { type, + budgetId: budget.id, status: 'skipped', from: period.from, to: period.to, @@ -984,11 +1006,18 @@ async function sendEmailReport( } try { - const analytics = await buildReportAnalytics(db, user, type, period); + const analytics = await buildReportAnalytics( + db, + user, + budget, + type, + period + ); if (!analytics) { await markDeliverySkipped(knex, deliveryId); return { type, + budgetId: budget.id, status: 'skipped', from: period.from, to: period.to, @@ -1004,7 +1033,13 @@ async function sendEmailReport( insights ); await markDeliverySent(knex, deliveryId, messageId); - return { type, status: 'sent', from: period.from, to: period.to }; + return { + type, + budgetId: budget.id, + status: 'sent', + from: period.from, + to: period.to + }; } catch (err) { await markDeliveryFailed(knex, deliveryId, err); throw err; @@ -1030,6 +1065,19 @@ async function listReportUsers(knex: Knex): Promise { return rows as ReportUser[]; } +async function listReportBudgets( + knex: Knex, + userId: number +): Promise[]> { + const rows = await knex('budgets') + .join('budget_members', 'budget_members.budget_id', 'budgets.id') + .select('budgets.id', 'budgets.name') + .where('budget_members.user_id', userId) + .orderBy('budgets.name', 'asc'); + + return rows as Pick[]; +} + export async function sendDueEmailReports( db: AppDb, knex: Knex, @@ -1048,23 +1096,28 @@ export async function sendDueEmailReports( now, config.emailReports.deliveryHourLocal ); + const budgets = await listReportBudgets(knex, user.id); for (const type of types) { - try { - await sendEmailReport( - db, - knex, - config, - user, - type, - 'scheduled', - now - ); - } catch (err) { - logger.error('Email report delivery failed', { - Error: err instanceof Error ? err.message : String(err), - ReportType: type, - UserId: user.id - }); + for (const budget of budgets) { + try { + await sendEmailReport( + db, + knex, + config, + user, + budget, + type, + 'scheduled', + now + ); + } catch (err) { + logger.error('Email report delivery failed', { + BudgetId: budget.id, + Error: err instanceof Error ? err.message : String(err), + ReportType: type, + UserId: user.id + }); + } } } } diff --git a/apps/api/src/application/transaction-scans.test.ts b/apps/api/src/application/transaction-scans.test.ts index ff1e559..a2963cd 100644 --- a/apps/api/src/application/transaction-scans.test.ts +++ b/apps/api/src/application/transaction-scans.test.ts @@ -79,6 +79,7 @@ function user(overrides: Partial = {}): UserDb { defaultCurrency: 'USD', countryCode: 'US', timezone: 'UTC', + mainBudgetId: 1, weeklyEmailReportEnabled: true, monthlyEmailReportEnabled: true, createdAt: timestamp, @@ -90,6 +91,7 @@ function user(overrides: Partial = {}): UserDb { function category(overrides: Partial = {}): Category { return { id: 7, + budgetId: 1, name: 'Groceries', type: 'expense', kind: 'normal', @@ -107,6 +109,7 @@ function category(overrides: Partial = {}): Category { function vendor(overrides: Partial = {}): Vendor { return { id: 5, + budgetId: 1, name: 'Walmart', displayName: 'Walmart', domain: 'walmart.com', @@ -120,6 +123,7 @@ function vendor(overrides: Partial = {}): Vendor { function transaction(overrides: Partial = {}): Transaction { return { id: 99, + budgetId: 1, categoryId: 7, vendorId: 5, vendorName: 'Walmart', @@ -146,6 +150,7 @@ function transactionRow(overrides: Partial = {}): TransactionDb { return { id: 99, userId: 1, + budgetId: 1, categoryId: 7, vendorId: 5, type: 'expense', @@ -169,6 +174,7 @@ function scanItem( id: 20, scanId: 10, userId: 1, + budgetId: 1, draftJson: '{}', createdAt: timestamp, updatedAt: timestamp, @@ -180,6 +186,7 @@ function scan(overrides: Partial = {}): TransactionScanDb { return { id: 10, userId: 1, + budgetId: 1, documentKind: 'receipt', imageHash: '6105d6cc76af400325e94d588ce511be5bfdbb73b437dc51eca43917d7a43e3d', @@ -198,6 +205,7 @@ function scanImage( id: 30, scanId: 10, userId: 1, + budgetId: 1, imageHash: '6105d6cc76af400325e94d588ce511be5bfdbb73b437dc51eca43917d7a43e3d', mimeType: 'image/png', @@ -229,6 +237,37 @@ function testDb({ users.find(candidate => candidate.id === id) ) }, + budgets: { + find: vi.fn(async (id: number) => ({ + id, + name: 'Main', + defaultCurrency: 'USD', + countryCode: 'US', + createdByUserId: 1, + createdAt: timestamp, + updatedAt: timestamp + })) + }, + budgetMembers: { + where: vi.fn(() => ({ + where: vi.fn(() => ({ + first: vi.fn(async () => ({ + budgetId: 1, + userId: 1, + role: 'admin', + canCreateTransactions: true, + canUpdateTransactions: true, + canDeleteTransactions: true, + canManageCategories: true, + canManageVendors: true, + canManageTags: true, + canManageMembers: true, + createdAt: timestamp, + updatedAt: timestamp + })) + })) + })) + }, transactionScans: { where: vi.fn( ( diff --git a/apps/api/src/application/transaction-scans.ts b/apps/api/src/application/transaction-scans.ts index 1c95f8e..ace3c0d 100644 --- a/apps/api/src/application/transaction-scans.ts +++ b/apps/api/src/application/transaction-scans.ts @@ -21,6 +21,7 @@ import type { TransactionScanItemDb, UserDb } from '../db/schemas.js'; +import { requireBudgetPermission, resolveBudgetAccess } from './budgets.js'; import { listCategories } from './categories.js'; import { generateStructuredJsonFromContent } from './openai.js'; import { listTransactions } from './transactions.js'; @@ -157,7 +158,10 @@ type TransactionScanItemQuery = Promise & { type TransactionScanItemTable = { readonly insert: ( - value: Pick + value: Pick< + TransactionScanItemDb, + 'budgetId' | 'draftJson' | 'scanId' | 'userId' + > ) => Promise; readonly where: ( selector: (row: TransactionScanItemDb) => TValue, @@ -826,9 +830,9 @@ async function getUser(db: AppDb, userId: number): Promise { async function correctionExamples( db: AppDb, - userId: number + budgetId: number ): Promise { - const rows = await scanItemTable(db).where(item => item.userId, userId); + const rows = await scanItemTable(db).where(item => item.budgetId, budgetId); return rows .filter(row => row.decision) @@ -982,20 +986,29 @@ export async function scanTransactionsFromImage( body: TransactionScanBody, options: ScanProgressOptions = {} ): Promise { + const access = await resolveBudgetAccess(db, userId, body.budgetId); + requireBudgetPermission(access, 'canCreateTransactions'); const buffer = imageBuffer(body); const imageHash = createHash('sha256').update(buffer).digest('hex'); options.onProgress?.('preparing'); const user = await getUser(db, userId); const [categories, vendors, recentTransactions, examples] = await Promise.all([ - listCategories(db, userId, { activeOnly: true }), - listVendors(db, userId, { limit: maxContextVendors }), + listCategories(db, userId, { + activeOnly: true, + budgetId: access.budget.id + }), + listVendors(db, userId, { + budgetId: access.budget.id, + limit: maxContextVendors + }), listTransactions(db, userId, { + budgetId: access.budget.id, direction: 'desc', limit: maxRecentTransactions, page: 1 }).then(response => response.items), - correctionExamples(db, userId) + correctionExamples(db, access.budget.id) ]); const preparedImages = await prepareScanImagesForVision( buffer, @@ -1035,6 +1048,7 @@ export async function scanTransactionsFromImage( ...preparedImages.warnings ].slice(0, 8); const scan = await db.transactionScans.insert({ + budgetId: access.budget.id, userId, documentKind: documentKind(parsed.documentKind), imageHash, @@ -1061,6 +1075,7 @@ export async function scanTransactionsFromImage( } const item = await scanItemTable(db).insert({ + budgetId: access.budget.id, scanId: scan.id, userId, draftJson: JSON.stringify(sanitized) @@ -1076,14 +1091,14 @@ export async function scanTransactionsFromImage( }; } -async function ensureTransactionOwner( +async function ensureTransactionBudget( db: AppDb, - userId: number, + budgetId: number, transactionId: number ): Promise { const transaction = await db.transactions .where(row => row.id, transactionId) - .where(row => row.userId, userId) + .where(row => row.budgetId, budgetId) .first(); if (!transaction) { throw new TransactionScanInputError('Transaction was not found.'); @@ -1092,11 +1107,13 @@ async function ensureTransactionOwner( async function storeScanAttachment({ attachment, + budgetId, db, scan, userId }: { readonly attachment: NonNullable; + readonly budgetId: number; readonly db: AppDb; readonly scan: TransactionScanDb; readonly userId: number; @@ -1112,7 +1129,7 @@ async function storeScanAttachment({ const existing = await db.transactionScanImages .where(row => row.scanId, scan.id) - .where(row => row.userId, userId) + .where(row => row.budgetId, budgetId) .first(); const values = { imageHash, @@ -1126,12 +1143,13 @@ async function storeScanAttachment({ if (existing) { await db.transactionScanImages .where(row => row.id, existing.id) - .where(row => row.userId, userId) + .where(row => row.budgetId, budgetId) .update(values as never); return; } await db.transactionScanImages.insert({ + budgetId, scanId: scan.id, userId, ...values @@ -1148,14 +1166,15 @@ export async function recordTransactionScanDecision( const item = await scanItemTable(db) .where(row => row.id, itemId) .where(row => row.scanId, scanId) - .where(row => row.userId, userId) .first(); if (!item) { throw new TransactionScanNotFoundError('Scan item was not found.'); } + const access = await resolveBudgetAccess(db, userId, item.budgetId); + requireBudgetPermission(access, 'canCreateTransactions'); const scan = await db.transactionScans .where(row => row.id, scanId) - .where(row => row.userId, userId) + .where(row => row.budgetId, access.budget.id) .first(); if (!scan) { throw new TransactionScanNotFoundError('Scan was not found.'); @@ -1167,10 +1186,11 @@ export async function recordTransactionScanDecision( 'Confirmed scan items require a transaction and corrected values.' ); } - await ensureTransactionOwner(db, userId, body.transactionId); + await ensureTransactionBudget(db, access.budget.id, body.transactionId); if (body.attachment) { await storeScanAttachment({ attachment: body.attachment, + budgetId: access.budget.id, db, scan, userId @@ -1180,7 +1200,7 @@ export async function recordTransactionScanDecision( await scanItemTable(db) .where(row => row.id, itemId) - .where(row => row.userId, userId) + .where(row => row.budgetId, access.budget.id) .update({ decision: body.decision, correctedJson: body.correctedTransaction diff --git a/apps/api/src/application/transaction-tags.test.ts b/apps/api/src/application/transaction-tags.test.ts index 176548d..9b0170d 100644 --- a/apps/api/src/application/transaction-tags.test.ts +++ b/apps/api/src/application/transaction-tags.test.ts @@ -67,7 +67,7 @@ describe('transaction tags', () => { ormMocks.query.mockReturnValue({ onConflict }); await expect( - getOrCreateTransactionTag({} as Knex, 7, { + getOrCreateTransactionTag({} as Knex, 7, 1, { name: 'Travel', normalizedName: 'travel' }) @@ -79,6 +79,7 @@ describe('transaction tags', () => { ); expect(merge).toHaveBeenCalledWith( { + budgetId: 1, userId: 7, name: 'Travel', normalizedName: 'travel' @@ -118,7 +119,7 @@ describe('transaction tags', () => { }); await expect( - getOrCreateTransactionTag({} as Knex, 7, { + getOrCreateTransactionTag({} as Knex, 7, 1, { name: 'Travel', normalizedName: 'travel' }) diff --git a/apps/api/src/application/transaction-tags.ts b/apps/api/src/application/transaction-tags.ts index 9b918b5..4110dca 100644 --- a/apps/api/src/application/transaction-tags.ts +++ b/apps/api/src/application/transaction-tags.ts @@ -6,13 +6,16 @@ import type { import { FieldLimits, TransactionTagLimits } from '@xpenser/contracts'; import type { Knex } from 'knex'; import { + type AppDb, type TransactionTagDb, TransactionTagDbSchema } from '../db/schemas.js'; +import { resolveBudgetAccess } from './budgets.js'; export class TransactionTagError extends Error {} type TransactionTagRow = { + readonly budgetId: number; readonly id: number; readonly name: string; readonly transactionCount: number | string; @@ -71,6 +74,7 @@ export function normalizeTransactionTagAssignments( function mapTransactionTag(row: TransactionTagRow): TransactionTag { return { id: Number(row.id), + budgetId: Number(row.budgetId), name: row.name, transactionCount: Number(row.transactionCount), createdAt: row.createdAt, @@ -79,19 +83,22 @@ function mapTransactionTag(row: TransactionTagRow): TransactionTag { } export async function listTransactionTags( - knex: Knex, + db: AppDb, userId: number, query: TransactionTagListQuery ): Promise { + const access = await resolveBudgetAccess(db, userId, query.budgetId); const search = query.search?.trim().toLowerCase(); const limit = Math.min(100, Math.max(1, query.limit ?? 25)); - const builder = knex('transaction_tags as tag') + const builder = db + .knex('transaction_tags as tag') .leftJoin('transaction_tag_links as link', 'link.tag_id', 'tag.id') - .where('tag.user_id', userId) + .where('tag.budget_id', access.budget.id) .groupBy('tag.id') .orderBy('tag.name', 'asc') .limit(limit) .select({ + budgetId: 'tag.budget_id', id: 'tag.id', name: 'tag.name', createdAt: 'tag.created_at', @@ -110,16 +117,18 @@ export async function listTransactionTags( export async function getOrCreateTransactionTag( knex: Knex, userId: number, + budgetId: number, assignment: TransactionTagAssignment ): Promise { const tableName = getTableName(TransactionTagDbSchema); const tag = await query(knex, TransactionTagDbSchema) .onConflict( - tag => tag.userId, + tag => tag.budgetId, tag => tag.normalizedName ) .merge( { + budgetId, userId, name: assignment.name, normalizedName: assignment.normalizedName @@ -139,10 +148,10 @@ export async function getOrCreateTransactionTag( export async function pruneUnusedTransactionTags( knex: Knex, - userId: number + budgetId: number ): Promise { await knex('transaction_tags as tag') - .where('tag.user_id', userId) + .where('tag.budget_id', budgetId) .whereNotExists(function () { this.select(1) .from('transaction_tag_links as link') @@ -154,6 +163,7 @@ export async function pruneUnusedTransactionTags( export async function replaceTransactionTags( knex: Knex, userId: number, + budgetId: number, transactionId: number, tags: readonly string[] ): Promise { @@ -165,7 +175,7 @@ export async function replaceTransactionTags( if (assignments.length > 0) { const tagIds = await Promise.all( assignments.map(assignment => - getOrCreateTransactionTag(knex, userId, assignment) + getOrCreateTransactionTag(knex, userId, budgetId, assignment) ) ); await knex('transaction_tag_links').insert( @@ -176,5 +186,5 @@ export async function replaceTransactionTags( ); } - await pruneUnusedTransactionTags(knex, userId); + await pruneUnusedTransactionTags(knex, budgetId); } diff --git a/apps/api/src/application/transactions.test.ts b/apps/api/src/application/transactions.test.ts index 8b29089..2b738cb 100644 --- a/apps/api/src/application/transactions.test.ts +++ b/apps/api/src/application/transactions.test.ts @@ -28,6 +28,46 @@ import { transactionSignedDefaultAmount } from './transactions.js'; +function budgetAccessTables() { + const timestamp = new Date('2026-06-01T12:00:00.000Z'); + const budget = { + id: 1, + name: 'Main', + defaultCurrency: 'USD', + countryCode: 'US', + createdByUserId: 1, + createdAt: timestamp, + updatedAt: timestamp + }; + const member = { + budgetId: 1, + userId: 1, + role: 'admin', + canCreateTransactions: true, + canUpdateTransactions: true, + canDeleteTransactions: true, + canManageCategories: true, + canManageVendors: true, + canManageTags: true, + canManageMembers: true, + createdAt: timestamp, + updatedAt: timestamp + }; + + return { + budgets: { + find: vi.fn(async () => budget) + }, + budgetMembers: { + where: vi.fn(() => ({ + where: vi.fn(() => ({ + first: vi.fn(async () => member) + })) + })) + } + }; +} + describe('transaction domain errors', () => { it('has explicit errors for not-found and invalid category cases', () => { expect(new TransactionNotFoundError('missing')).toBeInstanceOf(Error); @@ -89,6 +129,7 @@ describe('transaction category signs', () => { const car = { id: 1, userId: 1, + budgetId: 1, name: 'Car', type: 'expense', parentId: null, @@ -107,6 +148,7 @@ describe('transaction category signs', () => { ({ id, userId: 1, + budgetId: 1, categoryId, type: 'expense', amount, @@ -167,6 +209,7 @@ describe('transaction category signs', () => { const groceries = { id: 1, userId: 1, + budgetId: 1, name: 'Groceries', type: 'expense', parentId: null, @@ -189,6 +232,7 @@ describe('transaction category signs', () => { ({ id, userId: 1, + budgetId: 1, categoryId, type: categoryId === salary.id ? 'income' : 'expense', amount, @@ -241,6 +285,7 @@ describe('transaction category signs', () => { const salary = { id: 1, userId: 1, + budgetId: 1, name: 'Salary', type: 'income', parentId: null, @@ -252,6 +297,7 @@ describe('transaction category signs', () => { ({ id, userId: 1, + budgetId: 1, categoryId: salary.id, type: 'income', amount, @@ -295,6 +341,7 @@ describe('transaction category signs', () => { const salary = { id: 1, userId: 1, + budgetId: 1, name: 'Salary', type: 'income', parentId: null, @@ -317,6 +364,7 @@ describe('transaction category signs', () => { ({ id, userId: 1, + budgetId: 1, categoryId, type: 'income', amount, @@ -444,10 +492,12 @@ describe('transaction category signs', () => { first: vi.fn(async () => ({ rate: 2, rateDate: '2026-05-10' })) }; const db = { + ...budgetAccessTables(), users: { find: vi.fn(async () => ({ id: 1, defaultCurrency: 'USD', + mainBudgetId: 1, timezone: 'UTC' })) }, @@ -488,6 +538,7 @@ describe('transaction category signs', () => { const car = { id: 1, userId: 1, + budgetId: 1, name: 'Car', type: 'expense', parentId: null, @@ -510,6 +561,7 @@ describe('transaction category signs', () => { const row = { id: 1, userId: 1, + budgetId: 1, categoryId: returns.id, category: staleJoinedReturns, type: 'expense', @@ -573,6 +625,7 @@ describe('transaction category signs', () => { const groceries = { id: 1, userId: 1, + budgetId: 1, name: 'Groceries', type: 'expense', parentId: null, @@ -594,6 +647,7 @@ describe('transaction category signs', () => { ({ id, userId: 1, + budgetId: 1, name, normalizedName: name.toLowerCase(), createdAt: timestamp, @@ -609,6 +663,7 @@ describe('transaction category signs', () => { ({ id, userId: 1, + budgetId: 1, categoryId, vendorId, type: categoryId === salary.id ? 'income' : 'expense', @@ -843,11 +898,16 @@ describe('transaction category signs', () => { }); describe('transaction scan images', () => { + function scanImageBudgetDb(): AppDb { + return budgetAccessTables() as unknown as AppDb; + } + it('returns the latest confirmed scan image for a transaction', async () => { const scanTimestamp = new Date('2026-06-01T12:00:00.000Z'); const row = { scanId: 10, scanItemId: 20, + budgetId: 1, fileName: 'receipt.png', mimeType: 'image/png', sizeBytes: '5', @@ -864,7 +924,7 @@ describe('transaction scan images', () => { const knex = vi.fn(() => query); await expect( - getTransactionScanImage(knex as never, 1, 42) + getTransactionScanImage(scanImageBudgetDb(), knex as never, 1, 42) ).resolves.toEqual({ scanId: 10, scanItemId: 20, @@ -875,7 +935,6 @@ describe('transaction scan images', () => { imageBase64: Buffer.from('image').toString('base64') }); expect(knex).toHaveBeenCalledWith('transaction_scan_items as item'); - expect(query.where).toHaveBeenCalledWith('item.user_id', 1); expect(query.where).toHaveBeenCalledWith('item.transaction_id', 42); expect(query.where).toHaveBeenCalledWith('item.decision', 'confirmed'); }); @@ -890,7 +949,12 @@ describe('transaction scan images', () => { }; await expect( - getTransactionScanImage(vi.fn(() => query) as never, 1, 42) + getTransactionScanImage( + scanImageBudgetDb(), + vi.fn(() => query) as never, + 1, + 42 + ) ).rejects.toBeInstanceOf(TransactionNotFoundError); }); }); @@ -900,6 +964,7 @@ describe('transaction CSV export', () => { const category = { id: 1, userId: 1, + budgetId: 1, name: 'Meals', type: 'expense', parentId: null, @@ -910,6 +975,7 @@ describe('transaction CSV export', () => { const vendor = { id: 3, userId: 1, + budgetId: 1, name: 'Cafe', normalizedName: 'cafe', domain: null, @@ -941,6 +1007,7 @@ describe('transaction CSV export', () => { const transaction = { id: 1, userId: 1, + budgetId: 1, categoryId: category.id, vendorId: vendor.id, type: 'expense', @@ -988,10 +1055,12 @@ describe('transaction CSV export', () => { }; return { + ...budgetAccessTables(), users: { find: vi.fn(async () => ({ id: 1, defaultCurrency: 'USD', + mainBudgetId: 1, timezone: 'UTC' })) }, @@ -1227,6 +1296,7 @@ describe('category trend ranges and summaries', () => { const category = { id: 7, userId: 1, + budgetId: 1, name: 'Groceries', type: 'expense', parentId: null, @@ -1244,6 +1314,7 @@ describe('category trend ranges and summaries', () => { return { id: overrides.id, userId: 1, + budgetId: 1, categoryId: overrides.categoryId ?? category.id, type: 'expense', amount: overrides.amount, @@ -1532,6 +1603,7 @@ describe('stats tag reports', () => { { id: 1, userId: 1, + budgetId: 1, name: 'Groceries', type: 'expense', parentId: null, @@ -1542,6 +1614,7 @@ describe('stats tag reports', () => { { id: 2, userId: 1, + budgetId: 1, name: 'Rent', type: 'expense', parentId: null, @@ -1552,6 +1625,7 @@ describe('stats tag reports', () => { { id: 3, userId: 1, + budgetId: 1, name: 'Salary', type: 'income', parentId: null, @@ -1564,6 +1638,7 @@ describe('stats tag reports', () => { { id: 1, userId: 1, + budgetId: 1, name: 'Market', normalizedName: 'market', domain: null, @@ -1575,6 +1650,7 @@ describe('stats tag reports', () => { { id: 2, userId: 1, + budgetId: 1, name: 'Landlord', normalizedName: 'landlord', domain: null, @@ -1616,6 +1692,7 @@ describe('stats tag reports', () => { return { id, userId: 1, + budgetId: 1, categoryId, vendorId, type: categoryId === 3 ? 'income' : 'expense', @@ -1720,11 +1797,13 @@ describe('stats tag reports', () => { }); return { + ...budgetAccessTables(), knex, users: { find: async () => ({ id: 1, defaultCurrency: 'USD', + mainBudgetId: 1, timezone: 'UTC' }) }, diff --git a/apps/api/src/application/transactions.ts b/apps/api/src/application/transactions.ts index d4113a9..65168d9 100644 --- a/apps/api/src/application/transactions.ts +++ b/apps/api/src/application/transactions.ts @@ -47,6 +47,7 @@ import type { UserDb, VendorDb } from '../db/schemas.js'; +import { requireBudgetPermission, resolveBudgetAccess } from './budgets.js'; import { categoryAvailableForTransactions, categoryDisplayName, @@ -77,6 +78,7 @@ type DashboardCategoryVendor = DashboardSummary['categoryVendorBreakdown'][number]; type TransactionScanAttachment = NonNullable; type TransactionScanAttachmentRow = { + readonly budgetId: number; readonly createdAt: Date; readonly fileName: string | null; readonly mimeType: 'image/jpeg' | 'image/png' | 'image/webp'; @@ -91,6 +93,7 @@ type TransactionScanImageRow = TransactionScanAttachmentRow & { }; type TransactionTagRow = { + readonly budgetId: number; readonly createdAt: Date; readonly id: number; readonly name: string; @@ -126,6 +129,7 @@ type StatsRange = { type TransactionFilterQuery = Pick< TransactionListQuery, + | 'budgetId' | 'categoryId' | 'direction' | 'from' @@ -139,6 +143,7 @@ type TransactionFilterQuery = Pick< >; type FilteredTransactionRows = { + readonly budgetId: number; readonly categoriesById: ReadonlyMap; readonly rows: readonly TransactionDb[]; readonly tagsByTransaction: ReadonlyMap; @@ -155,6 +160,7 @@ type StatsRanges = { }; type PeriodWindowQuery = { + readonly budgetId?: number; readonly period?: DashboardPeriod; readonly date?: Date; readonly currency?: string; @@ -275,11 +281,11 @@ async function dashboardReportingAmounts( async function loadCategoriesById( db: AppDb, - userId: number + budgetId: number ): Promise> { const categories = (await db.categories.where( - category => category.userId, - userId + category => category.budgetId, + budgetId )) as CategoryDb[]; return new Map( @@ -289,11 +295,11 @@ async function loadCategoriesById( async function loadVendorsById( db: AppDb, - userId: number + budgetId: number ): Promise> { const vendors = (await db.vendors.where( - vendor => vendor.userId, - userId + vendor => vendor.budgetId, + budgetId )) as VendorDb[]; return new Map(vendors.map(vendor => [vendor.id, vendor] as const)); @@ -417,6 +423,7 @@ function mapTransaction( return { id: row.id, + budgetId: row.budgetId, categoryId: fields.categoryId, vendorId: vendor?.id ?? row.vendorId ?? null, vendorName: vendor?.name, @@ -481,7 +488,7 @@ async function transactionTagCounts( async function transactionTagsByTransaction( knex: Knex | undefined, - userId: number, + budgetId: number, transactionIds: readonly number[] ): Promise> { const uniqueIds = [...new Set(transactionIds)]; @@ -491,11 +498,12 @@ async function transactionTagsByTransaction( const rows = (await knex('transaction_tag_links as link') .join('transaction_tags as tag', 'tag.id', 'link.tag_id') - .where('tag.user_id', userId) + .where('tag.budget_id', budgetId) .whereIn('link.transaction_id', uniqueIds) .orderBy('tag.name', 'asc') .select({ transactionId: 'link.transaction_id', + budgetId: 'tag.budget_id', id: 'tag.id', name: 'tag.name', createdAt: 'tag.created_at', @@ -511,6 +519,7 @@ async function transactionTagsByTransaction( const current = tags.get(row.transactionId) ?? []; current.push({ id: Number(row.id), + budgetId: Number(row.budgetId), name: row.name, transactionCount: counts.get(Number(row.id)) ?? 0, createdAt: row.createdAt, @@ -537,7 +546,7 @@ function scanAttachmentFromRow( async function scanAttachmentsByTransaction( knex: Knex | undefined, - userId: number, + budgetId: number, transactionIds: readonly number[] ): Promise> { const uniqueIds = [...new Set(transactionIds)]; @@ -551,13 +560,14 @@ async function scanAttachmentsByTransaction( 'image.scan_id', 'item.scan_id' ) - .where('item.user_id', userId) - .where('image.user_id', userId) + .where('item.budget_id', budgetId) + .where('image.budget_id', budgetId) .where('item.decision', 'confirmed') .whereIn('item.transaction_id', uniqueIds) .orderBy('item.decided_at', 'desc') .select({ transactionId: 'item.transaction_id', + budgetId: 'item.budget_id', scanId: 'item.scan_id', scanItemId: 'item.id', fileName: 'image.file_name', @@ -605,12 +615,12 @@ async function getUser(db: AppDb, userId: number): Promise { async function getCategory( db: AppDb, - userId: number, + budgetId: number, categoryId: number ): Promise { const category = await db.categories .where(candidate => candidate.id, categoryId) - .where(candidate => candidate.userId, userId) + .where(candidate => candidate.budgetId, budgetId) .first(); if (!category) { throw new TransactionCategoryError('Category was not found.'); @@ -621,10 +631,14 @@ async function getCategory( async function validateVendor( db: AppDb, userId: number, + budgetId: number, vendorId: number ): Promise { try { - await getVendor(db, userId, vendorId); + const vendor = await getVendor(db, userId, vendorId); + if (vendor.budgetId !== budgetId) { + throw new TransactionCategoryError('Vendor was not found.'); + } } catch (err) { if (err instanceof VendorNotFoundError) { throw new TransactionCategoryError(err.message); @@ -640,9 +654,10 @@ async function filteredTransactionRows( knex: Knex | undefined, options: { readonly includeTagsForAllRows?: boolean } = {} ): Promise { + const access = await resolveBudgetAccess(db, userId, query.budgetId); let builder = db.transactions .include(transaction => transaction.category) - .where(transaction => transaction.userId, userId); + .where(transaction => transaction.budgetId, access.budget.id); if (query.categoryId) { builder = builder.where( @@ -670,8 +685,8 @@ async function filteredTransactionRows( builder .orderBy(transaction => transaction.occurredAt, direction) .orderBy(transaction => transaction.id, direction), - loadCategoriesById(db, userId), - loadVendorsById(db, userId) + loadCategoriesById(db, access.budget.id), + loadVendorsById(db, access.budget.id) ]); const sortedRows = [...rows].sort( direction === 'asc' @@ -689,7 +704,7 @@ async function filteredTransactionRows( const allTagsByTransaction = needsAllTagData ? await transactionTagsByTransaction( knex ?? db.knex, - userId, + access.budget.id, sortedRows.map(transaction => transaction.id) ) : new Map(); @@ -778,6 +793,7 @@ async function filteredTransactionRows( }); return { + budgetId: access.budget.id, categoriesById, rows: filtered, tagsByTransaction: allTagsByTransaction, @@ -795,6 +811,7 @@ export async function listTransactions( const page = Math.max(1, query.page ?? 1); const limit = Math.min(100, Math.max(1, query.limit ?? 50)); const { + budgetId, categoriesById, rows: filtered, tagsByTransaction, @@ -805,14 +822,14 @@ export async function listTransactions( const pageRows = filtered.slice(offset, offset + limit) as TransactionDb[]; const scanAttachments = await scanAttachmentsByTransaction( knex, - userId, + budgetId, pageRows.map(transaction => transaction.id) ); const pageTagsByTransaction = tagsLoadedForAllRows ? tagsByTransaction : await transactionTagsByTransaction( knex ?? db.knex, - userId, + budgetId, pageRows.map(transaction => transaction.id) ); @@ -1058,12 +1075,13 @@ export async function exportTransactionsCsv( query: TransactionExportQuery, knex?: Knex ): Promise<{ readonly csv: string; readonly fileName: string }> { - const [user, favorites] = await Promise.all([ + const [user, favorites, access] = await Promise.all([ getUser(db, userId), - loadFavoriteCurrencies(db, userId) + loadFavoriteCurrencies(db, userId), + resolveBudgetAccess(db, userId, query.budgetId) ]); const allowedCurrencies = new Set( - [user.defaultCurrency, ...favorites].map(currency => + [access.budget.defaultCurrency, ...favorites].map(currency => currency.trim().toUpperCase() ) ); @@ -1083,13 +1101,13 @@ export async function exportTransactionsCsv( ); } - const { categoriesById, rows, tagsByTransaction, vendorsById } = + const { budgetId, categoriesById, rows, tagsByTransaction, vendorsById } = await filteredTransactionRows(db, userId, query, knex, { includeTagsForAllRows: true }); const scanAttachments = await scanAttachmentsByTransaction( knex, - userId, + budgetId, rows.map(transaction => transaction.id) ); const transactions = rows.map(transaction => @@ -1122,9 +1140,14 @@ export async function createTransaction( body: CreateTransactionBody ): Promise { return db.transaction(async trx => { + const access = await resolveBudgetAccess(trx, userId, body.budgetId); + requireBudgetPermission(access, 'canCreateTransactions'); + if ((body.tags?.length ?? 0) > 0) { + requireBudgetPermission(access, 'canManageTags'); + } const [user, categoriesById] = await Promise.all([ getUser(trx, userId), - loadCategoriesById(trx, userId) + loadCategoriesById(trx, access.budget.id) ]); const category = categoriesById.get(body.categoryId); if (!category) { @@ -1136,18 +1159,19 @@ export async function createTransaction( ); } if (body.vendorId !== undefined && body.vendorId !== null) { - await validateVendor(trx, userId, body.vendorId); + await validateVendor(trx, userId, access.budget.id, body.vendorId); } const date = transactionDate(body.occurredAt, user.timezone); const exchange = await getExchangeRate( trx, config, body.currency, - user.defaultCurrency, + access.budget.defaultCurrency, date ); const created = await trx.transactions.insert({ + budgetId: access.budget.id, userId, categoryId: body.categoryId, vendorId: body.vendorId ?? undefined, @@ -1155,7 +1179,7 @@ export async function createTransaction( amount: body.amount, currency: body.currency, defaultCurrencyAmount: convertAmount(body.amount, exchange.rate), - defaultCurrency: user.defaultCurrency, + defaultCurrency: access.budget.defaultCurrency, exchangeRate: exchange.rate, exchangeRateDate: exchange.rateDate, occurredAt: body.occurredAt, @@ -1165,6 +1189,7 @@ export async function createTransaction( await replaceTransactionTags( trx.knex, userId, + access.budget.id, created.id, body.tags ?? [] ); @@ -1178,22 +1203,21 @@ export async function getTransaction( userId: number, transactionId: number ): Promise { - const [row, categoriesById, vendorsById, tagsByTransaction] = - await Promise.all([ - db.transactions - .include(transaction => transaction.category) - .where(transaction => transaction.id, transactionId) - .where(transaction => transaction.userId, userId) - .first(), - loadCategoriesById(db, userId), - loadVendorsById(db, userId), - transactionTagsByTransaction(db.knex, userId, [transactionId]) - ]); + const row = (await db.transactions + .include(transaction => transaction.category) + .where(transaction => transaction.id, transactionId) + .first()) as TransactionDb | undefined; if (!row) { throw new TransactionNotFoundError('Transaction was not found.'); } + const access = await resolveBudgetAccess(db, userId, row.budgetId); + const [categoriesById, vendorsById, tagsByTransaction] = await Promise.all([ + loadCategoriesById(db, access.budget.id), + loadVendorsById(db, access.budget.id), + transactionTagsByTransaction(db.knex, access.budget.id, [transactionId]) + ]); return mapTransaction( - row as TransactionDb, + row, categoriesById, vendorsById, new Map(), @@ -1202,6 +1226,7 @@ export async function getTransaction( } export async function getTransactionScanImage( + db: AppDb, knex: Knex, userId: number, transactionId: number @@ -1212,14 +1237,13 @@ export async function getTransactionScanImage( 'image.scan_id', 'item.scan_id' ) - .where('item.user_id', userId) - .where('image.user_id', userId) .where('item.transaction_id', transactionId) .where('item.decision', 'confirmed') .orderBy('item.decided_at', 'desc') .select({ scanId: 'item.scan_id', scanItemId: 'item.id', + budgetId: 'item.budget_id', fileName: 'image.file_name', mimeType: 'image.mime_type', sizeBytes: 'image.size_bytes', @@ -1231,6 +1255,7 @@ export async function getTransactionScanImage( if (!row) { throw new TransactionNotFoundError('Scanned image was not found.'); } + await resolveBudgetAccess(db, userId, Number(row.budgetId)); return { ...scanAttachmentFromRow({ ...row, transactionId }), @@ -1247,6 +1272,11 @@ export async function updateTransaction( ): Promise { return db.transaction(async trx => { const current = await getTransaction(trx, userId, transactionId); + const access = await resolveBudgetAccess(trx, userId, current.budgetId); + requireBudgetPermission(access, 'canUpdateTransactions'); + if (body.tags !== undefined) { + requireBudgetPermission(access, 'canManageTags'); + } const next = { categoryId: body.categoryId ?? current.categoryId, vendorId: @@ -1259,7 +1289,7 @@ export async function updateTransaction( const [user, categoriesById] = await Promise.all([ getUser(trx, userId), - loadCategoriesById(trx, userId) + loadCategoriesById(trx, access.budget.id) ]); const category = categoriesById.get(next.categoryId); if (!category) { @@ -1277,19 +1307,19 @@ export async function updateTransaction( ); } if (next.vendorId !== undefined && next.vendorId !== null) { - await validateVendor(trx, userId, next.vendorId); + await validateVendor(trx, userId, access.budget.id, next.vendorId); } const exchange = await getExchangeRate( trx, config, next.currency, - user.defaultCurrency, + access.budget.defaultCurrency, transactionDate(next.occurredAt, user.timezone) ); await trx.transactions .where(transaction => transaction.id, transactionId) - .where(transaction => transaction.userId, userId) + .where(transaction => transaction.budgetId, access.budget.id) .update({ categoryId: next.categoryId, vendorId: (next.vendorId ?? null) as never, @@ -1300,7 +1330,7 @@ export async function updateTransaction( next.amount, exchange.rate ), - defaultCurrency: user.defaultCurrency, + defaultCurrency: access.budget.defaultCurrency, exchangeRate: exchange.rate, exchangeRateDate: exchange.rateDate, occurredAt: next.occurredAt, @@ -1312,6 +1342,7 @@ export async function updateTransaction( await replaceTransactionTags( trx.knex, userId, + access.budget.id, transactionId, body.tags ); @@ -1327,14 +1358,26 @@ export async function deleteTransaction( transactionId: number ): Promise { await db.transaction(async trx => { + const current = await trx.transactions + .where(transaction => transaction.id, transactionId) + .first(); + if (!current) { + throw new TransactionNotFoundError('Transaction was not found.'); + } + const access = await resolveBudgetAccess( + trx, + userId, + (current as TransactionDb).budgetId + ); + requireBudgetPermission(access, 'canDeleteTransactions'); const deleted = await trx.transactions .where(transaction => transaction.id, transactionId) - .where(transaction => transaction.userId, userId) + .where(transaction => transaction.budgetId, access.budget.id) .delete(); if (deleted === 0) { throw new TransactionNotFoundError('Transaction was not found.'); } - await pruneUnusedTransactionTags(trx.knex, userId); + await pruneUnusedTransactionTags(trx.knex, access.budget.id); }); } @@ -2377,12 +2420,12 @@ function rollUpParentDashboardCategories( async function transactionsForRange( db: AppDb, - userId: number, + budgetId: number, range: StatsRange ) { return (await db.transactions .include(transaction => transaction.category) - .where(transaction => transaction.userId, userId) + .where(transaction => transaction.budgetId, budgetId) .whereBetween( transaction => transaction.occurredAt, [range.from, range.to] @@ -2391,11 +2434,11 @@ async function transactionsForRange( async function transactionsForCategory( db: AppDb, - userId: number, + budgetId: number, categoryId: number ) { return (await db.transactions - .where(transaction => transaction.userId, userId) + .where(transaction => transaction.budgetId, budgetId) .where(transaction => transaction.categoryId, categoryId) .orderBy(transaction => transaction.occurredAt, 'asc') .orderBy(transaction => transaction.id, 'asc')) as TransactionDb[]; @@ -2407,13 +2450,14 @@ export async function categoryTrend( categoryId: number, query: CategoryTrendQuery ): Promise { - const [user, category] = await Promise.all([ + const [user, access] = await Promise.all([ getUser(db, userId), - getCategory(db, userId, categoryId) + resolveBudgetAccess(db, userId, query.budgetId) ]); + const category = await getCategory(db, access.budget.id, categoryId); const [rows, categoriesById] = await Promise.all([ - transactionsForCategory(db, userId, category.id), - loadCategoriesById(db, userId) + transactionsForCategory(db, access.budget.id, category.id), + loadCategoriesById(db, access.budget.id) ]); const range = resolveCategoryTrendRange(query, { categoryCreatedAt: category.createdAt, @@ -2424,7 +2468,7 @@ export async function categoryTrend( return summarizeCategoryTrendRows({ category, categoriesById, - currency: user.defaultCurrency, + currency: access.budget.defaultCurrency, groupBy: categoryTrendGroupBy(query.groupBy), range, rows, @@ -2636,9 +2680,17 @@ export async function dashboardSummary( period: DashboardPeriod, date?: Date, vendorLimit = dashboardVendorLimit, - currency?: string + currency?: string, + budgetId?: number ): Promise { - const user = await getUser(db, userId); + const [user, access] = await Promise.all([ + getUser(db, userId), + resolveBudgetAccess(db, userId, budgetId) + ]); + const reportUser = { + ...user, + defaultCurrency: access.budget.defaultCurrency + }; const range = resolveDashboardRange( period, date, @@ -2652,22 +2704,22 @@ export async function dashboardSummary( ); const [rows, previousRows, categoriesById, vendorsById] = await Promise.all( [ - transactionsForRange(db, userId, range), - transactionsForRange(db, userId, comparisonRange), - loadCategoriesById(db, userId), - loadVendorsById(db, userId) + transactionsForRange(db, access.budget.id, range), + transactionsForRange(db, access.budget.id, comparisonRange), + loadCategoriesById(db, access.budget.id), + loadVendorsById(db, access.budget.id) ] ); const reportingAmounts = await dashboardReportingAmounts( db, config, - user, + reportUser, [...rows, ...previousRows], currency ); return summarizeDashboardRows( - user, + reportUser, period, range, rows, @@ -2685,7 +2737,14 @@ export async function dashboardWindow( userId: number, query: PeriodWindowQuery ): Promise { - const user = await getUser(db, userId); + const [user, access] = await Promise.all([ + getUser(db, userId), + resolveBudgetAccess(db, userId, query.budgetId) + ]); + const reportUser = { + ...user, + defaultCurrency: access.budget.defaultCurrency + }; const now = new Date(); const period = query.period ?? 'day'; const dates = resolveDashboardPeriodWindow( @@ -2711,18 +2770,18 @@ export async function dashboardWindow( const [allRows, categoriesById, vendorsById] = await Promise.all([ transactionsForRange( db, - userId, + access.budget.id, encompassingRange( plans.flatMap(plan => [plan.range, plan.previousRange]) ) ), - loadCategoriesById(db, userId), - loadVendorsById(db, userId) + loadCategoriesById(db, access.budget.id), + loadVendorsById(db, access.budget.id) ]); const reportingAmounts = await dashboardReportingAmounts( db, config, - user, + reportUser, allRows, query.currency ); @@ -2731,7 +2790,7 @@ export async function dashboardWindow( items: plans.map(plan => ({ date: plan.date, summary: summarizeDashboardRows( - user, + reportUser, period, plan.range, rowsInRange(allRows, plan.range), @@ -2860,7 +2919,14 @@ export async function statsOverview( userId: number, query: StatsQuery ): Promise { - const user = await getUser(db, userId); + const [user, access] = await Promise.all([ + getUser(db, userId), + resolveBudgetAccess(db, userId, query.budgetId) + ]); + const reportUser = { + ...user, + defaultCurrency: access.budget.defaultCurrency + }; const groupBy = (query.groupBy ?? 'day') as StatsGroupBy; const timeframe = ( query.period ? 'custom' : (query.timeframe ?? 'this-month') @@ -2872,14 +2938,14 @@ export async function statsOverview( ); const [selectedRows, previousPeriodRows, previousYearRows, categoriesById] = await Promise.all([ - transactionsForRange(db, userId, ranges.selected), - transactionsForRange(db, userId, ranges.previousPeriod), - transactionsForRange(db, userId, ranges.previousYear), - loadCategoriesById(db, userId) + transactionsForRange(db, access.budget.id, ranges.selected), + transactionsForRange(db, access.budget.id, ranges.previousPeriod), + transactionsForRange(db, access.budget.id, ranges.previousYear), + loadCategoriesById(db, access.budget.id) ]); return summarizeStatsRows( - user, + reportUser, groupBy, timeframe, ranges, @@ -2892,12 +2958,12 @@ export async function statsOverview( async function transactionTagNameById( db: AppDb, - userId: number, + budgetId: number, tagId: number ): Promise { const row = (await db .knex('transaction_tags') - .where('user_id', userId) + .where('budget_id', budgetId) .where('id', tagId) .select({ name: 'name' }) .first()) as { readonly name: string } | undefined; @@ -2910,7 +2976,14 @@ export async function statsTagReport( userId: number, query: StatsTagReportQuery ): Promise { - const user = await getUser(db, userId); + const [user, access] = await Promise.all([ + getUser(db, userId), + resolveBudgetAccess(db, userId, query.budgetId) + ]); + const reportUser = { + ...user, + defaultCurrency: access.budget.defaultCurrency + }; const now = new Date(); const period = query.period ?? 'day'; const range = resolveDashboardRange( @@ -2920,13 +2993,13 @@ export async function statsTagReport( user.timezone ); const [rows, categoriesById, vendorsById] = await Promise.all([ - transactionsForRange(db, userId, range), - loadCategoriesById(db, userId), - loadVendorsById(db, userId) + transactionsForRange(db, access.budget.id, range), + loadCategoriesById(db, access.budget.id), + loadVendorsById(db, access.budget.id) ]); const tagsByTransaction = await transactionTagsByTransaction( db.knex, - userId, + access.budget.id, rows.map(transaction => transaction.id) ); const tagTotals = new Map< @@ -3011,7 +3084,7 @@ export async function statsTagReport( } else if (typeof query.tag === 'number') { const tagName = tagTotals.get(query.tag)?.tagName ?? - (await transactionTagNameById(db, userId, query.tag)); + (await transactionTagNameById(db, access.budget.id, query.tag)); if (tagName) { selectedTag = summarizeStatsTagDetail({ categoriesById, @@ -3036,7 +3109,7 @@ export async function statsTagReport( period, from: range.from, to: range.to, - currency: user.defaultCurrency, + currency: reportUser.defaultCurrency, expenseTotal, expenseCount: expenseRows.length, untaggedCount: untaggedRows.length, @@ -3050,7 +3123,14 @@ export async function statsWindow( userId: number, query: PeriodWindowQuery ): Promise { - const user = await getUser(db, userId); + const [user, access] = await Promise.all([ + getUser(db, userId), + resolveBudgetAccess(db, userId, query.budgetId) + ]); + const reportUser = { + ...user, + defaultCurrency: access.budget.defaultCurrency + }; const now = new Date(); const period = query.period ?? 'day'; const groupBy = dashboardStatsGroupBy(period); @@ -3085,16 +3165,16 @@ export async function statsWindow( ); const [selectedAndPreviousRows, previousYearRows, categoriesById] = await Promise.all([ - transactionsForRange(db, userId, selectedRowsRange), - transactionsForRange(db, userId, previousYearRowsRange), - loadCategoriesById(db, userId) + transactionsForRange(db, access.budget.id, selectedRowsRange), + transactionsForRange(db, access.budget.id, previousYearRowsRange), + loadCategoriesById(db, access.budget.id) ]); return { items: plans.map(plan => ({ date: plan.date, overview: summarizeStatsRows( - user, + reportUser, groupBy, timeframe, plan.ranges, diff --git a/apps/api/src/application/users.test.ts b/apps/api/src/application/users.test.ts index 2c64458..fa82ec2 100644 --- a/apps/api/src/application/users.test.ts +++ b/apps/api/src/application/users.test.ts @@ -30,8 +30,49 @@ const config = { } } as Config; +function budgetAccessTables() { + const timestamp = new Date('2026-06-01T00:00:00.000Z'); + const budget = { + id: 1, + name: 'Main', + defaultCurrency: 'USD', + countryCode: 'US', + createdByUserId: 12, + createdAt: timestamp, + updatedAt: timestamp + }; + const member = { + budgetId: 1, + userId: 12, + role: 'admin', + canCreateTransactions: true, + canUpdateTransactions: true, + canDeleteTransactions: true, + canManageCategories: true, + canManageVendors: true, + canManageTags: true, + canManageMembers: true, + createdAt: timestamp, + updatedAt: timestamp + }; + + return { + budgets: { + find: vi.fn(async () => budget) + }, + budgetMembers: { + where: vi.fn(() => ({ + where: vi.fn(() => ({ + first: vi.fn(async () => member) + })) + })) + } + }; +} + function mockDbWithUser(): AppDb { return { + ...budgetAccessTables(), users: { find: vi.fn(async () => ({ id: 12, @@ -41,7 +82,8 @@ function mockDbWithUser(): AppDb { role: 'user', defaultCurrency: 'USD', countryCode: 'US', - timezone: 'UTC' + timezone: 'UTC', + mainBudgetId: 1 })) }, categories: { @@ -182,7 +224,8 @@ describe('email confirmation', () => { role: 'user', defaultCurrency: 'USD', countryCode: 'US', - timezone: 'UTC' + timezone: 'UTC', + mainBudgetId: 1 }; let updateValues: object | undefined; @@ -190,6 +233,7 @@ describe('email confirmation', () => { vi.setSystemTime(new Date('2026-06-01T00:00:00.000Z')); const db = { + ...budgetAccessTables(), users: { projected: vi.fn(() => ({ where: vi.fn(() => ({ diff --git a/apps/api/src/application/users.ts b/apps/api/src/application/users.ts index 49dad6a..bbeed04 100644 --- a/apps/api/src/application/users.ts +++ b/apps/api/src/application/users.ts @@ -14,6 +14,11 @@ import type { Config } from '../config.js'; import type { AppDb, TransactionDb, UserDb } from '../db/schemas.js'; import { hashPassword, verifyPassword } from '../security/password.js'; import { issueToken, tokenExpiresAt } from '../security/token.js'; +import { + createMainBudgetForUser, + ensureMainBudget, + listBudgets +} from './budgets.js'; import { sendEmail } from './email.js'; export class DuplicateEmailError extends Error {} @@ -138,20 +143,29 @@ async function favoriteCurrencies( return rows.map(row => row.currency); } -async function hasCategories(db: AppDb, userId: number): Promise { +async function hasCategories( + db: AppDb, + userId: number, + budgetId?: number +): Promise { + const selectedBudgetId = + budgetId ?? (await ensureMainBudget(db, userId)).id; const rows = await db.categories - .where(category => category.userId, userId) + .where(category => category.budgetId, selectedBudgetId) .limit(1); return rows.length > 0; } async function recentCurrencyTransactions( db: AppDb, - userId: number + userId: number, + budgetId?: number ): Promise { + const selectedBudgetId = + budgetId ?? (await ensureMainBudget(db, userId)).id; const rows = (await db.transactions.where( - transaction => transaction.userId, - userId + transaction => transaction.budgetId, + selectedBudgetId )) as TransactionDb[]; return rows @@ -255,6 +269,7 @@ function toTokenResponse( | 'role' | 'countryCode' | 'timezone' + | 'mainBudgetId' | 'weeklyEmailReportEnabled' | 'monthlyEmailReportEnabled' >, @@ -278,6 +293,7 @@ function toTokenResponse( defaultCurrency: user.defaultCurrency, countryCode: normalizeCountryCode(user.countryCode), timezone: normalizeTimeZone(user.timezone), + mainBudgetId: user.mainBudgetId ?? null, hasCategories: categories } }; @@ -319,10 +335,11 @@ export async function issueUserToken( return undefined; } + const mainBudget = await ensureMainBudget(db, userId); return toTokenResponse( config, - user, - await hasCategories(db, userId), + { ...user, mainBudgetId: user.mainBudgetId ?? mainBudget.id }, + await hasCategories(db, userId, mainBudget.id), expiresInSeconds ); } @@ -420,6 +437,7 @@ export async function registerUser( body.favoriteCurrencies, body.defaultCurrency ); + await createMainBudgetForUser(trx, user as UserDb); return emailConfirmationPendingResponse(user.email); }); @@ -452,7 +470,12 @@ export async function loginUser( ); } - return toTokenResponse(config, user, await hasCategories(db, user.id)); + const mainBudget = await ensureMainBudget(db, user.id); + return toTokenResponse( + config, + { ...user, mainBudgetId: user.mainBudgetId ?? mainBudget.id }, + await hasCategories(db, user.id, mainBudget.id) + ); } export async function confirmEmail( @@ -659,11 +682,14 @@ export async function getUserPreference( return undefined; } - const [favorites, recentTransactions, categories] = await Promise.all([ - favoriteCurrencies(db, userId), - recentCurrencyTransactions(db, userId), - hasCategories(db, userId) - ]); + const mainBudget = await ensureMainBudget(db, userId); + const [favorites, recentTransactions, categories, budgets] = + await Promise.all([ + favoriteCurrencies(db, userId), + recentCurrencyTransactions(db, userId, mainBudget.id), + hasCategories(db, userId, mainBudget.id), + listBudgets(db, userId) + ]); const transactionCurrencies = transactionCurrenciesByRecentPopularity( [user.defaultCurrency, ...favorites], recentTransactions @@ -677,6 +703,8 @@ export async function getUserPreference( favoriteCurrencies: favorites, transactionCurrencies, timezone: normalizeTimeZone(user.timezone), + mainBudgetId: user.mainBudgetId ?? mainBudget.id, + budgets, hasCategories: categories, weeklyEmailReportEnabled: user.weeklyEmailReportEnabled, monthlyEmailReportEnabled: user.monthlyEmailReportEnabled diff --git a/apps/api/src/application/vendors.test.ts b/apps/api/src/application/vendors.test.ts index fbc0fbc..41cf2c0 100644 --- a/apps/api/src/application/vendors.test.ts +++ b/apps/api/src/application/vendors.test.ts @@ -53,6 +53,7 @@ function user(overrides: Partial = {}): UserDb { defaultCurrency: 'USD', countryCode: 'UA', timezone: 'UTC', + mainBudgetId: 1, weeklyEmailReportEnabled: true, monthlyEmailReportEnabled: true, createdAt: timestamp, @@ -65,6 +66,7 @@ function category(overrides: Partial = {}): CategoryDb { return { id: 1, userId: 1, + budgetId: 1, parentId: null, name: 'Groceries', type: 'expense', @@ -80,6 +82,7 @@ function vendor(overrides: Partial = {}): VendorDb { return { id: 1, userId: 1, + budgetId: 1, name: 'Silpo', normalizedName: 'silpo', createdAt: timestamp, @@ -92,6 +95,7 @@ function transaction(overrides: Partial = {}): TransactionDb { return { id: 1, userId: 1, + budgetId: 1, categoryId: 1, vendorId: 1, type: 'expense', @@ -119,12 +123,45 @@ function testDb({ readonly transactions?: TransactionDb[]; readonly users?: UserDb[]; }): AppDb { + const budget = { + id: 1, + name: 'Main', + defaultCurrency: 'USD', + countryCode: 'UA', + createdByUserId: 1, + createdAt: timestamp, + updatedAt: timestamp + }; + const member = { + budgetId: 1, + userId: 1, + role: 'admin', + canCreateTransactions: true, + canUpdateTransactions: true, + canDeleteTransactions: true, + canManageCategories: true, + canManageVendors: true, + canManageTags: true, + canManageMembers: true, + createdAt: timestamp, + updatedAt: timestamp + }; return { users: { find: vi.fn(async (id: number) => users.find(candidate => candidate.id === id) ) }, + budgets: { + find: vi.fn(async () => budget) + }, + budgetMembers: { + where: vi.fn(() => ({ + where: vi.fn(() => ({ + first: vi.fn(async () => member) + })) + })) + }, categories: { where: vi.fn( ( diff --git a/apps/api/src/application/vendors.ts b/apps/api/src/application/vendors.ts index 2e3651d..d5df442 100644 --- a/apps/api/src/application/vendors.ts +++ b/apps/api/src/application/vendors.ts @@ -11,11 +11,13 @@ import { FieldLimits } from '@xpenser/contracts'; import type { Config } from '../config.js'; import type { AppDb, + BudgetDb, CategoryDb, TransactionDb, UserDb, VendorDb } from '../db/schemas.js'; +import { requireBudgetPermission, resolveBudgetAccess } from './budgets.js'; import { categoryAvailableForTransactions, categoryDisplayName @@ -390,6 +392,7 @@ async function enrichVendor( db: AppDb, config: Config, user: UserDb, + budget: BudgetDb, vendor: VendorDb, options: { readonly force?: boolean } = {} ): Promise { @@ -397,7 +400,7 @@ async function enrichVendor( if (!config.vendorEnrichment.enabled || !config.brandfetch.apiKey) { await db.vendors .where(candidate => candidate.id, vendor.id) - .where(candidate => candidate.userId, user.id) + .where(candidate => candidate.budgetId, budget.id) .update({ enrichedAt: now, enrichmentProvider: config.brandfetch.apiKey @@ -415,11 +418,11 @@ async function enrichVendor( try { const json = await brandfetchTransaction(config, { transactionLabel: vendor.name, - countryCode: user.countryCode + countryCode: budget.countryCode || user.countryCode }); await db.vendors .where(candidate => candidate.id, vendor.id) - .where(candidate => candidate.userId, user.id) + .where(candidate => candidate.budgetId, budget.id) .update({ ...(json ? brandfetchUpdate(json) : {}), enrichedAt: now, @@ -430,7 +433,7 @@ async function enrichVendor( } catch { await db.vendors .where(candidate => candidate.id, vendor.id) - .where(candidate => candidate.userId, user.id) + .where(candidate => candidate.budgetId, budget.id) .update({ enrichedAt: now, enrichmentProvider: 'brandfetch', @@ -442,11 +445,11 @@ async function enrichVendor( async function loadCategoriesById( db: AppDb, - userId: number + budgetId: number ): Promise> { const categories = (await db.categories.where( - category => category.userId, - userId + category => category.budgetId, + budgetId )) as CategoryDb[]; return new Map( @@ -456,11 +459,11 @@ async function loadCategoriesById( async function loadVendorTransactions( db: AppDb, - userId: number + budgetId: number ): Promise { return (await db.transactions.where( - transaction => transaction.userId, - userId + transaction => transaction.budgetId, + budgetId )) as TransactionDb[]; } @@ -548,6 +551,7 @@ function mapVendor( ): Vendor { return { id: vendor.id, + budgetId: vendor.budgetId, name: vendor.name, displayName: vendor.name, resolvedName: vendor.resolvedName ?? undefined, @@ -568,10 +572,10 @@ function mapVendor( }; } -async function vendorReadContext(db: AppDb, userId: number) { +async function vendorReadContext(db: AppDb, budgetId: number) { const [transactions, categoriesById] = await Promise.all([ - loadVendorTransactions(db, userId), - loadCategoriesById(db, userId) + loadVendorTransactions(db, budgetId), + loadCategoriesById(db, budgetId) ]); return { stats: vendorStats(transactions), @@ -584,11 +588,12 @@ export async function listVendors( userId: number, query: Partial = {} ): Promise { + const access = await resolveBudgetAccess(db, userId, query.budgetId); const limit = Math.min(100, Math.max(1, query.limit ?? 25)); const search = query.search?.trim().toLowerCase(); const [rows, context] = await Promise.all([ - db.vendors.where(vendor => vendor.userId, userId), - vendorReadContext(db, userId) + db.vendors.where(vendor => vendor.budgetId, access.budget.id), + vendorReadContext(db, access.budget.id) ]); return (rows as VendorDb[]) @@ -633,7 +638,8 @@ async function vendorView( userId: number, vendor: VendorDb ): Promise { - const context = await vendorReadContext(db, userId); + await resolveBudgetAccess(db, userId, vendor.budgetId); + const context = await vendorReadContext(db, vendor.budgetId); return mapVendor( vendor, context.stats.get(vendor.id), @@ -723,6 +729,8 @@ export async function createVendor( userId: number, body: CreateVendorBody ): Promise { + const access = await resolveBudgetAccess(db, userId, body.budgetId); + requireBudgetPermission(access, 'canManageVendors'); const name = normalizeVendorName(body.name); if (!name) { throw new VendorNameError('Vendor name is required.'); @@ -734,13 +742,14 @@ export async function createVendor( const selectedUpdate = await selectedBrandUpdate(config, body); const matchingVendors = (await db.vendors - .where(vendor => vendor.userId, userId) + .where(vendor => vendor.budgetId, access.budget.id) .where(vendor => vendor.normalizedName, normalizedName)) as VendorDb[]; const existing = findReusableVendor(matchingVendors, selected.domain); const vendor = existing ?? ((await db.vendors.insert({ + budgetId: access.budget.id, userId, name, normalizedName, @@ -758,7 +767,7 @@ export async function createVendor( if (existing && Object.keys(selectedUpdate).length > 0) { await db.vendors .where(candidate => candidate.id, existing.id) - .where(candidate => candidate.userId, userId) + .where(candidate => candidate.budgetId, access.budget.id) .update({ ...selectedUpdate, updatedAt: new Date() @@ -766,15 +775,15 @@ export async function createVendor( } if (Object.keys(selectedUpdate).length === 0) { - await enrichVendor(db, config, user, vendor); + await enrichVendor(db, config, user, access.budget, vendor); } const [updated, context] = await Promise.all([ db.vendors .where(candidate => candidate.id, vendor.id) - .where(candidate => candidate.userId, userId) + .where(candidate => candidate.budgetId, access.budget.id) .first(), - vendorReadContext(db, userId) + vendorReadContext(db, access.budget.id) ]); return mapVendor( @@ -791,11 +800,11 @@ export async function getVendor( ): Promise { const vendor = (await db.vendors .where(candidate => candidate.id, vendorId) - .where(candidate => candidate.userId, userId) .first()) as VendorDb | undefined; if (!vendor) { throw new VendorNotFoundError('Vendor was not found.'); } + await resolveBudgetAccess(db, userId, vendor.budgetId); return vendor; } @@ -862,6 +871,8 @@ export async function updateVendor( body: UpdateVendorBody ): Promise { const current = await getVendor(db, userId, vendorId); + const access = await resolveBudgetAccess(db, userId, current.budgetId); + requireBudgetPermission(access, 'canManageVendors'); const name = body.name === undefined ? current.name : normalizeVendorName(body.name); if (!name) { @@ -871,7 +882,7 @@ export async function updateVendor( const normalizedName = vendorNormalizedName(name); if (normalizedName !== current.normalizedName) { const existing = (await db.vendors - .where(vendor => vendor.userId, userId) + .where(vendor => vendor.budgetId, access.budget.id) .where(vendor => vendor.normalizedName, normalizedName) .first()) as VendorDb | undefined; if (existing && existing.id !== current.id) { @@ -914,7 +925,7 @@ export async function updateVendor( await db.vendors .where(candidate => candidate.id, current.id) - .where(candidate => candidate.userId, userId) + .where(candidate => candidate.budgetId, access.budget.id) .update(update as never); return getVendorDetails(db, userId, vendorId); @@ -930,7 +941,11 @@ export async function retryVendorEnrichment( getUser(db, userId), getVendor(db, userId, vendorId) ]); + const access = await resolveBudgetAccess(db, userId, vendor.budgetId); + requireBudgetPermission(access, 'canManageVendors'); - await enrichVendor(db, config, user, vendor, { force: true }); + await enrichVendor(db, config, user, access.budget, vendor, { + force: true + }); return getVendorDetails(db, userId, vendorId); } diff --git a/apps/api/src/db/migrations/019_budgets.ts b/apps/api/src/db/migrations/019_budgets.ts new file mode 100644 index 0000000..ba0adaa --- /dev/null +++ b/apps/api/src/db/migrations/019_budgets.ts @@ -0,0 +1,354 @@ +import type { Knex } from 'knex'; + +const budgetScopedTables = [ + 'categories', + 'vendors', + 'transactions', + 'transaction_tags', + 'transaction_scans', + 'transaction_scan_items', + 'transaction_scan_images' +] as const; + +async function addBudgetColumn(knex: Knex, tableName: string): Promise { + await knex.schema.alterTable(tableName, table => { + table + .integer('budget_id') + .nullable() + .references('id') + .inTable('budgets') + .onDelete('CASCADE'); + table.index(['budget_id'], `idx_${tableName}_budget_id`); + }); +} + +async function requireBudgetColumn( + knex: Knex, + tableName: string +): Promise { + await knex.raw(`alter table ?? alter column budget_id set not null`, [ + tableName + ]); +} + +export async function up(knex: Knex): Promise { + await knex.schema.createTable('budgets', table => { + table.increments('id').primary(); + table.string('name', 120).notNullable(); + table.string('default_currency', 3).notNullable(); + table.string('country_code', 2).notNullable().defaultTo('US'); + table + .integer('created_by_user_id') + .nullable() + .references('id') + .inTable('users') + .onDelete('SET NULL'); + table + .timestamp('created_at', { useTz: true }) + .notNullable() + .defaultTo(knex.fn.now()); + table + .timestamp('updated_at', { useTz: true }) + .notNullable() + .defaultTo(knex.fn.now()); + table.index(['created_by_user_id'], 'idx_budgets_created_by_user_id'); + }); + + await knex.schema.alterTable('users', table => { + table + .integer('main_budget_id') + .nullable() + .references('id') + .inTable('budgets') + .onDelete('SET NULL'); + table.index(['main_budget_id'], 'idx_users_main_budget_id'); + }); + + await knex.schema.createTable('budget_members', table => { + table + .integer('budget_id') + .notNullable() + .references('id') + .inTable('budgets') + .onDelete('CASCADE'); + table + .integer('user_id') + .notNullable() + .references('id') + .inTable('users') + .onDelete('CASCADE'); + table.string('role', 32).notNullable(); + table.boolean('can_create_transactions').notNullable().defaultTo(true); + table.boolean('can_update_transactions').notNullable().defaultTo(false); + table.boolean('can_delete_transactions').notNullable().defaultTo(false); + table.boolean('can_manage_categories').notNullable().defaultTo(false); + table.boolean('can_manage_vendors').notNullable().defaultTo(false); + table.boolean('can_manage_tags').notNullable().defaultTo(false); + table.boolean('can_manage_members').notNullable().defaultTo(false); + table + .timestamp('created_at', { useTz: true }) + .notNullable() + .defaultTo(knex.fn.now()); + table + .timestamp('updated_at', { useTz: true }) + .notNullable() + .defaultTo(knex.fn.now()); + table.primary(['budget_id', 'user_id'], { + constraintName: 'pk_budget_members' + }); + table.index(['user_id'], 'idx_budget_members_user_id'); + }); + + await knex.schema.createTable('budget_invitations', table => { + table.increments('id').primary(); + table + .integer('budget_id') + .notNullable() + .references('id') + .inTable('budgets') + .onDelete('CASCADE'); + table + .integer('invited_by_user_id') + .notNullable() + .references('id') + .inTable('users') + .onDelete('CASCADE'); + table.string('email', 255).notNullable(); + table.string('role', 32).notNullable(); + table.boolean('can_create_transactions').notNullable().defaultTo(true); + table.boolean('can_update_transactions').notNullable().defaultTo(false); + table.boolean('can_delete_transactions').notNullable().defaultTo(false); + table.boolean('can_manage_categories').notNullable().defaultTo(false); + table.boolean('can_manage_vendors').notNullable().defaultTo(false); + table.boolean('can_manage_tags').notNullable().defaultTo(false); + table.boolean('can_manage_members').notNullable().defaultTo(false); + table.string('token_hash', 128).notNullable().unique(); + table.timestamp('expires_at', { useTz: true }).notNullable(); + table.timestamp('consumed_at', { useTz: true }).nullable(); + table + .timestamp('created_at', { useTz: true }) + .notNullable() + .defaultTo(knex.fn.now()); + table + .timestamp('updated_at', { useTz: true }) + .notNullable() + .defaultTo(knex.fn.now()); + table.index(['budget_id'], 'idx_budget_invitations_budget_id'); + table.index(['email'], 'idx_budget_invitations_email'); + table.index(['token_hash'], 'idx_budget_invitations_token_hash'); + }); + + await knex.raw(` + insert into budgets (name, default_currency, country_code, created_by_user_id) + select 'Main', default_currency, coalesce(country_code, 'US'), id + from users + `); + + await knex.raw(` + update users u + set main_budget_id = b.id + from budgets b + where b.created_by_user_id = u.id + and b.name = 'Main' + `); + + await knex.raw(` + insert into budget_members ( + budget_id, + user_id, + role, + can_create_transactions, + can_update_transactions, + can_delete_transactions, + can_manage_categories, + can_manage_vendors, + can_manage_tags, + can_manage_members + ) + select + main_budget_id, + id, + 'admin', + true, + true, + true, + true, + true, + true, + true + from users + where main_budget_id is not null + `); + + for (const tableName of budgetScopedTables) { + await addBudgetColumn(knex, tableName); + } + + for (const tableName of budgetScopedTables) { + await knex.raw( + ` + update ?? as scoped + set budget_id = u.main_budget_id + from users u + where scoped.user_id = u.id + `, + [tableName] + ); + await requireBudgetColumn(knex, tableName); + } + + await knex.schema.alterTable('email_report_deliveries', table => { + table + .integer('budget_id') + .nullable() + .references('id') + .inTable('budgets') + .onDelete('CASCADE'); + table.index(['budget_id'], 'idx_email_reports_budget_id'); + table.index( + ['user_id', 'budget_id', 'report_type'], + 'idx_email_reports_user_budget_type' + ); + }); + await knex.raw(` + update email_report_deliveries as delivery + set budget_id = u.main_budget_id + from users u + where delivery.user_id = u.id + `); + await knex.raw(` + update email_report_deliveries + set delivery_key = concat( + user_id, + ':', + budget_id, + ':', + report_type, + ':', + to_char( + period_start at time zone 'UTC', + 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"' + ) + ) + where budget_id is not null + `); + await knex.raw( + 'alter table email_report_deliveries alter column budget_id set not null' + ); + + await knex.raw('drop index if exists uq_categories_user_type_parent_name'); + await knex.raw('drop index if exists uq_categories_user_type_top_name'); + await knex.raw(` + create unique index uq_categories_budget_type_top_name + on categories (budget_id, type, name) + where parent_id is null + `); + await knex.raw(` + create unique index uq_categories_budget_type_parent_name + on categories (budget_id, type, parent_id, name) + where parent_id is not null + `); + + await knex.raw( + 'drop index if exists uq_vendors_user_normalized_name_domain' + ); + await knex.raw( + 'drop index if exists uq_vendors_user_normalized_name_without_domain' + ); + await knex.raw(` + create unique index uq_vendors_budget_normalized_name_without_domain + on vendors (budget_id, normalized_name) + where domain is null + `); + await knex.raw(` + create unique index uq_vendors_budget_normalized_name_domain + on vendors (budget_id, normalized_name, domain) + where domain is not null + `); + + await knex.raw( + 'alter table transaction_tags drop constraint if exists uq_transaction_tags_user_normalized_name' + ); + await knex.raw( + 'drop index if exists uq_transaction_tags_user_normalized_name' + ); + await knex.raw(` + create unique index uq_transaction_tags_budget_normalized_name + on transaction_tags (budget_id, normalized_name) + `); + + await knex.schema.alterTable('transactions', table => { + table.index( + ['budget_id', 'occurred_at'], + 'idx_transactions_budget_occurred' + ); + }); +} + +export async function down(knex: Knex): Promise { + await knex.raw('drop index if exists idx_transactions_budget_occurred'); + await knex.raw( + 'drop index if exists uq_transaction_tags_budget_normalized_name' + ); + await knex.raw( + 'drop index if exists uq_vendors_budget_normalized_name_domain' + ); + await knex.raw( + 'drop index if exists uq_vendors_budget_normalized_name_without_domain' + ); + await knex.raw( + 'drop index if exists uq_categories_budget_type_parent_name' + ); + await knex.raw('drop index if exists uq_categories_budget_type_top_name'); + + await knex.raw(` + create unique index if not exists uq_categories_user_type_top_name + on categories (user_id, type, name) + where parent_id is null + `); + await knex.raw(` + create unique index if not exists uq_categories_user_type_parent_name + on categories (user_id, type, parent_id, name) + where parent_id is not null + `); + await knex.raw(` + create unique index if not exists uq_vendors_user_normalized_name_without_domain + on vendors (user_id, normalized_name) + where domain is null + `); + await knex.raw(` + create unique index if not exists uq_vendors_user_normalized_name_domain + on vendors (user_id, normalized_name, domain) + where domain is not null + `); + await knex.raw(` + create unique index if not exists uq_transaction_tags_user_normalized_name + on transaction_tags (user_id, normalized_name) + `); + + for (const tableName of [...budgetScopedTables].reverse()) { + await knex.schema.alterTable(tableName, table => { + table.dropIndex(['budget_id'], `idx_${tableName}_budget_id`); + table.dropColumn('budget_id'); + }); + } + + await knex.schema.alterTable('email_report_deliveries', table => { + table.dropIndex( + ['user_id', 'budget_id', 'report_type'], + 'idx_email_reports_user_budget_type' + ); + table.dropIndex(['budget_id'], 'idx_email_reports_budget_id'); + table.dropColumn('budget_id'); + }); + + await knex.schema.dropTableIfExists('budget_invitations'); + await knex.schema.dropTableIfExists('budget_members'); + + await knex.schema.alterTable('users', table => { + table.dropIndex(['main_budget_id'], 'idx_users_main_budget_id'); + table.dropColumn('main_budget_id'); + }); + + await knex.schema.dropTableIfExists('budgets'); +} diff --git a/apps/api/src/db/schemas.ts b/apps/api/src/db/schemas.ts index 754c226..490deed 100644 --- a/apps/api/src/db/schemas.ts +++ b/apps/api/src/db/schemas.ts @@ -25,6 +25,11 @@ export const UserDbSchema = object({ defaultCurrency: string().hasColumnName('default_currency'), countryCode: string().hasColumnName('country_code').defaultTo('US'), timezone: string().defaultTo('UTC'), + mainBudgetId: number() + .hasColumnName('main_budget_id') + .references('budgets', 'id') + .onDelete('SET NULL') + .optional(), weeklyEmailReportEnabled: boolean() .hasColumnName('weekly_email_report_enabled') .defaultTo(true), @@ -45,6 +50,7 @@ export const UserDbSchema = object({ 'defaultCurrency', 'countryCode', 'timezone', + 'mainBudgetId', 'weeklyEmailReportEnabled', 'monthlyEmailReportEnabled', 'createdAt', @@ -63,6 +69,7 @@ export const UserDbSchema = object({ 'defaultCurrency', 'countryCode', 'timezone', + 'mainBudgetId', 'weeklyEmailReportEnabled', 'monthlyEmailReportEnabled' ); @@ -104,6 +111,97 @@ export const TelegramLinkTokenDbSchema = object({ createdAt: date().hasColumnName('created_at').defaultTo('now') }).hasTableName('telegram_link_tokens'); +export const BudgetDbSchema = object({ + id: number().primaryKey(), + name: string(), + defaultCurrency: string().hasColumnName('default_currency'), + countryCode: string().hasColumnName('country_code').defaultTo('US'), + createdByUserId: number() + .hasColumnName('created_by_user_id') + .references('users', 'id') + .onDelete('SET NULL') + .optional(), + createdAt: date().hasColumnName('created_at').defaultTo('now'), + updatedAt: date().hasColumnName('updated_at').defaultTo('now') +}).hasTableName('budgets'); + +export const BudgetMemberDbSchema = object({ + budgetId: number() + .hasColumnName('budget_id') + .references('budgets', 'id') + .onDelete('CASCADE'), + userId: number() + .hasColumnName('user_id') + .references('users', 'id') + .onDelete('CASCADE') + .index('idx_budget_members_user_id'), + role: string(), + canCreateTransactions: boolean() + .hasColumnName('can_create_transactions') + .defaultTo(true), + canUpdateTransactions: boolean() + .hasColumnName('can_update_transactions') + .defaultTo(false), + canDeleteTransactions: boolean() + .hasColumnName('can_delete_transactions') + .defaultTo(false), + canManageCategories: boolean() + .hasColumnName('can_manage_categories') + .defaultTo(false), + canManageVendors: boolean() + .hasColumnName('can_manage_vendors') + .defaultTo(false), + canManageTags: boolean().hasColumnName('can_manage_tags').defaultTo(false), + canManageMembers: boolean() + .hasColumnName('can_manage_members') + .defaultTo(false), + createdAt: date().hasColumnName('created_at').defaultTo('now'), + updatedAt: date().hasColumnName('updated_at').defaultTo('now') +}) + .hasTableName('budget_members') + .hasPrimaryKey(['budgetId', 'userId'] as const); + +export const BudgetInvitationDbSchema = object({ + id: number().primaryKey(), + budgetId: number() + .hasColumnName('budget_id') + .references('budgets', 'id') + .onDelete('CASCADE') + .index('idx_budget_invitations_budget_id'), + invitedByUserId: number() + .hasColumnName('invited_by_user_id') + .references('users', 'id') + .onDelete('CASCADE'), + email: string(), + role: string(), + canCreateTransactions: boolean() + .hasColumnName('can_create_transactions') + .defaultTo(true), + canUpdateTransactions: boolean() + .hasColumnName('can_update_transactions') + .defaultTo(false), + canDeleteTransactions: boolean() + .hasColumnName('can_delete_transactions') + .defaultTo(false), + canManageCategories: boolean() + .hasColumnName('can_manage_categories') + .defaultTo(false), + canManageVendors: boolean() + .hasColumnName('can_manage_vendors') + .defaultTo(false), + canManageTags: boolean().hasColumnName('can_manage_tags').defaultTo(false), + canManageMembers: boolean() + .hasColumnName('can_manage_members') + .defaultTo(false), + tokenHash: string() + .hasColumnName('token_hash') + .index('idx_budget_invitations_token_hash'), + expiresAt: date().hasColumnName('expires_at'), + consumedAt: date().optional().hasColumnName('consumed_at'), + createdAt: date().hasColumnName('created_at').defaultTo('now'), + updatedAt: date().hasColumnName('updated_at').defaultTo('now') +}).hasTableName('budget_invitations'); + export const ApiKeyDbSchema = object({ id: number().primaryKey(), userId: number() @@ -201,6 +299,11 @@ export const McpOAuthRefreshTokenDbSchema = object({ export const CategoryDbSchema = object({ id: number().primaryKey(), + budgetId: number() + .hasColumnName('budget_id') + .references('budgets', 'id') + .onDelete('CASCADE') + .index('idx_categories_budget_id'), userId: number() .hasColumnName('user_id') .references('users', 'id') @@ -222,6 +325,11 @@ export const CategoryDbSchema = object({ export const VendorDbSchema = object({ id: number().primaryKey(), + budgetId: number() + .hasColumnName('budget_id') + .references('budgets', 'id') + .onDelete('CASCADE') + .index('idx_vendors_budget_id'), userId: number() .hasColumnName('user_id') .references('users', 'id') @@ -247,6 +355,11 @@ export const VendorDbSchema = object({ export const TransactionDbSchema = object({ id: number().primaryKey(), + budgetId: number() + .hasColumnName('budget_id') + .references('budgets', 'id') + .onDelete('CASCADE') + .index('idx_transactions_budget_id'), userId: number() .hasColumnName('user_id') .references('users', 'id') @@ -279,6 +392,11 @@ export const TransactionDbSchema = object({ export const TransactionTagDbSchema = object({ id: number().primaryKey(), + budgetId: number() + .hasColumnName('budget_id') + .references('budgets', 'id') + .onDelete('CASCADE') + .index('idx_transaction_tags_budget_id'), userId: number() .hasColumnName('user_id') .references('users', 'id') @@ -306,6 +424,11 @@ export const TransactionTagLinkDbSchema = object({ export const TransactionScanDbSchema = object({ id: number().primaryKey(), + budgetId: number() + .hasColumnName('budget_id') + .references('budgets', 'id') + .onDelete('CASCADE') + .index('idx_transaction_scans_budget_id'), userId: number() .hasColumnName('user_id') .references('users', 'id') @@ -321,6 +444,11 @@ export const TransactionScanDbSchema = object({ export const TransactionScanItemDbSchema = object({ id: number().primaryKey(), + budgetId: number() + .hasColumnName('budget_id') + .references('budgets', 'id') + .onDelete('CASCADE') + .index('idx_transaction_scan_items_budget_id'), scanId: number() .hasColumnName('scan_id') .references('transaction_scans', 'id') @@ -356,6 +484,11 @@ export const TransactionScanItemDbSchema = object({ export const TransactionScanImageDbSchema = object({ id: number().primaryKey(), + budgetId: number() + .hasColumnName('budget_id') + .references('budgets', 'id') + .onDelete('CASCADE') + .index('idx_transaction_scan_images_budget_id'), scanId: number() .hasColumnName('scan_id') .references('transaction_scans', 'id') @@ -403,6 +536,9 @@ export const ExternalIdentityDbSchema = object({ export const ExternalIdentityEntity = defineEntity(ExternalIdentityDbSchema); export const TelegramAccountEntity = defineEntity(TelegramAccountDbSchema); export const TelegramLinkTokenEntity = defineEntity(TelegramLinkTokenDbSchema); +export const BudgetEntity = defineEntity(BudgetDbSchema); +export const BudgetMemberEntity = defineEntity(BudgetMemberDbSchema); +export const BudgetInvitationEntity = defineEntity(BudgetInvitationDbSchema); export const ApiKeyEntity = defineEntity(ApiKeyDbSchema); export const McpOAuthClientEntity = defineEntity(McpOAuthClientDbSchema); export const McpOAuthGrantEntity = defineEntity(McpOAuthGrantDbSchema); @@ -438,6 +574,9 @@ export const entityMap = { externalIdentities: ExternalIdentityEntity, telegramAccounts: TelegramAccountEntity, telegramLinkTokens: TelegramLinkTokenEntity, + budgets: BudgetEntity, + budgetMembers: BudgetMemberEntity, + budgetInvitations: BudgetInvitationEntity, apiKeys: ApiKeyEntity, mcpOAuthClients: McpOAuthClientEntity, mcpOAuthGrants: McpOAuthGrantEntity, @@ -463,6 +602,9 @@ type DbRow[0]> = Readonly< export type UserDb = DbRow; export type ExternalIdentityDb = DbRow; +export type BudgetDb = DbRow; +export type BudgetMemberDb = DbRow; +export type BudgetInvitationDb = DbRow; export type VendorDb = DbRow; export type TelegramAccountDb = DbRow; export type TelegramLinkTokenDb = DbRow; diff --git a/apps/api/src/mcp/tools.test.ts b/apps/api/src/mcp/tools.test.ts index 7c02921..8be571f 100644 --- a/apps/api/src/mcp/tools.test.ts +++ b/apps/api/src/mcp/tools.test.ts @@ -1,5 +1,6 @@ import { ErrorCode, type McpError } from '@modelcontextprotocol/sdk/types.js'; import type { + Budget, Category, DashboardSummary, StatsOverview, @@ -48,6 +49,7 @@ const principal: McpPrincipal = { function category(overrides: Partial = {}): Category { return { id: 1, + budgetId: 1, name: 'Food', type: 'expense', parentId: null, @@ -65,6 +67,7 @@ function category(overrides: Partial = {}): Category { function vendor(overrides: Partial = {}): Vendor { return { id: 5, + budgetId: 1, name: 'Store', displayName: 'Store', domain: 'store.example', @@ -90,6 +93,7 @@ function vendorCandidate( function transaction(overrides: Partial = {}): Transaction { return { id: 100, + budgetId: 1, categoryId: 1, vendorId: null, categoryName: 'Food', @@ -118,6 +122,7 @@ function transactionTag( ): TransactionTag { return { id: 9, + budgetId: 1, name: 'wife', transactionCount: 2, createdAt: new Date('2026-05-06T00:00:00.000Z'), @@ -126,6 +131,29 @@ function transactionTag( }; } +function budget(overrides: Partial = {}): Budget { + return { + id: 1, + name: 'Main', + defaultCurrency: 'USD', + countryCode: 'US', + role: 'admin', + permissions: { + canCreateTransactions: true, + canUpdateTransactions: true, + canDeleteTransactions: true, + canManageCategories: true, + canManageVendors: true, + canManageTags: true, + canManageMembers: true + }, + isMain: true, + createdAt: new Date('2026-05-01T00:00:00.000Z'), + updatedAt: new Date('2026-05-01T00:00:00.000Z'), + ...overrides + }; +} + function statsOverview(): StatsOverview { return { groupBy: 'day', @@ -212,9 +240,12 @@ describe('MCP tool helpers', () => { transactionCurrencies: ['USD', 'EUR'], timezone: 'UTC', hasCategories: true, + mainBudgetId: 1, + budgets: [budget()], weeklyEmailReportEnabled: true, monthlyEmailReportEnabled: true })), + listBudgets: vi.fn(async () => [budget()]), listCategories: vi.fn(async () => [category()]), createCategory: vi.fn(async (_userId, body) => category({ @@ -325,6 +356,7 @@ describe('MCP tool helpers', () => { expect(tools.map(tool => tool.name)).toEqual([ 'xpenser_get_current_user', + 'xpenser_list_budgets', 'xpenser_list_categories', 'xpenser_create_category', 'xpenser_update_category', @@ -390,6 +422,7 @@ describe('MCP tool helpers', () => { limit: 500 }) ).toEqual({ + budgetId: undefined, search: 'wife', limit: 100 }); @@ -434,6 +467,7 @@ describe('MCP tool helpers', () => { }); expect(data.listCategories).toHaveBeenCalledWith(7, { + budgetId: undefined, activeOnly: true, sort: 'recent-transaction-count' }); @@ -550,6 +584,7 @@ describe('MCP tool helpers', () => { }); expect(data.listTransactionTags).toHaveBeenCalledWith(7, { + budgetId: undefined, search: 'wife', limit: 10 }); @@ -645,7 +680,8 @@ describe('MCP tool helpers', () => { expect(data.getDashboardSummary).toHaveBeenCalledWith( 7, 'month', - new Date('2026-05-14T00:00:00.000Z') + new Date('2026-05-14T00:00:00.000Z'), + undefined ); expect(result.structuredContent).toMatchObject({ dashboard: { from: '2026-05-01T00:00:00.000Z' } diff --git a/apps/api/src/mcp/tools.ts b/apps/api/src/mcp/tools.ts index f7d4a7b..79ee970 100644 --- a/apps/api/src/mcp/tools.ts +++ b/apps/api/src/mcp/tools.ts @@ -21,6 +21,7 @@ import { type Tool } from '@modelcontextprotocol/sdk/types.js'; import { + type Budget, type Category, type CategoryListQuery, type CreateCategoryBody, @@ -44,6 +45,12 @@ import { type VendorListQuery } from '@xpenser/contracts'; import type { Knex } from 'knex'; +import { + BudgetAccessError, + BudgetNotFoundError, + BudgetPermissionError, + listBudgets as listUserBudgets +} from '../application/budgets.js'; import { CategoryHierarchyError, CategoryInUseError, @@ -131,6 +138,7 @@ export type XpenserMcpDataAccess = { readonly getCurrentUser: ( userId: number ) => Promise; + readonly listBudgets: (userId: number) => Promise; readonly listCategories: ( userId: number, query: CategoryListQuery @@ -201,7 +209,8 @@ export type XpenserMcpDataAccess = { readonly getDashboardSummary: ( userId: number, period: DashboardSummary['period'], - date?: Date + date?: Date, + budgetId?: number ) => Promise; readonly getStatsOverview: ( userId: number, @@ -285,6 +294,12 @@ function id(description: string) { return number().isInteger().positive().describe(description); } +function optionalBudgetId() { + return id('Optional budget identifier. Omit to use the Main budget.') + .optional() + .describe('Optional budget identifier. Omit to use the Main budget.'); +} + function optionalText(description: string, maxLength: number) { return string() .trim() @@ -303,6 +318,7 @@ function nullableText(description: string, maxLength: number) { } const CategoryListInputSchema = object({ + budgetId: optionalBudgetId(), sort: enumOf('recent-transaction-count') .optional() .describe('Optional category ordering mode.'), @@ -314,6 +330,7 @@ const CategoryListInputSchema = object({ }); const CreateCategoryInputSchema = object({ + budgetId: optionalBudgetId(), name: string() .trim() .nonempty() @@ -366,6 +383,7 @@ const MoveAndDeleteCategoryInputSchema = object({ }); const VendorListInputSchema = object({ + budgetId: optionalBudgetId(), search: optionalText( 'Text search applied to vendor names, domains, and descriptions.', FieldLimits.vendorSearch @@ -417,6 +435,7 @@ const VendorCandidateDetailsInputSchema = object({ }); const CreateVendorInputSchema = object({ + budgetId: optionalBudgetId(), name: string() .trim() .nonempty() @@ -469,6 +488,7 @@ const UpdateVendorInputSchema = object({ }); const TransactionListInputSchema = object({ + budgetId: optionalBudgetId(), search: string() .trim() .optional() @@ -503,6 +523,7 @@ const TransactionListInputSchema = object({ }); const TransactionTagListInputSchema = object({ + budgetId: optionalBudgetId(), search: optionalText( 'Text search applied to tag names.', FieldLimits.transactionTagSearch @@ -525,6 +546,7 @@ const transactionTagNames = array( .describe('Tag names to assign to the transaction.'); const CreateTransactionInputSchema = object({ + budgetId: optionalBudgetId(), categoryId: id('Category identifier selected for the transaction.'), vendorId: id('Optional vendor identifier selected for the transaction.') .nullable() @@ -566,6 +588,7 @@ const TransactionIdInputSchema = object({ }); const DashboardInputSchema = object({ + budgetId: optionalBudgetId(), period: enumOf('day', 'week', 'month', 'quarter', 'year') .optional() .describe('Reporting period. Defaults to day.'), @@ -575,6 +598,7 @@ const DashboardInputSchema = object({ }); const StatsInputSchema = object({ + budgetId: optionalBudgetId(), groupBy: enumOf('hour', 'day', 'week', 'month') .optional() .describe('Stats trend grouping. Defaults to day.'), @@ -634,6 +658,7 @@ export function createXpenserMcpDataAccess( ): XpenserMcpDataAccess { return { getCurrentUser: userId => getUserPreference(db, userId), + listBudgets: userId => listUserBudgets(db, userId), listCategories: (userId, query) => listUserCategories(db, userId, query), createCategory: (userId, body) => createUserCategory(db, userId, body), @@ -662,15 +687,24 @@ export function createXpenserMcpDataAccess( listTransactions: (userId, query) => listTransactions(db, userId, query, knex), listTransactionTags: (userId, query) => - listTransactionTags(knex, userId, query), + listTransactionTags(db, userId, query), createTransaction: (userId, body) => createUserTransaction(db, config, userId, body), updateTransaction: (userId, transactionId, body) => updateUserTransaction(db, config, userId, transactionId, body), deleteTransaction: (userId, transactionId) => deleteUserTransaction(db, userId, transactionId), - getDashboardSummary: (userId, period, date) => - dashboardSummary(db, config, userId, period, date), + getDashboardSummary: (userId, period, date, budgetId) => + dashboardSummary( + db, + config, + userId, + period, + date, + undefined, + undefined, + budgetId + ), getStatsOverview: (userId, query) => statsOverview(db, userId, query) }; } @@ -705,6 +739,7 @@ export function normalizeCategoryListInput( input: CategoryListInput ): CategoryListQuery { return { + budgetId: input.budgetId, sort: input.sort, activeOnly: input.activeOnly }; @@ -714,6 +749,7 @@ export function normalizeVendorListInput( input: VendorListInput ): VendorListQuery { return { + budgetId: input.budgetId, search: nonempty(input.search), limit: input.limit ?? 25 }; @@ -735,6 +771,7 @@ export function normalizeTransactionListInput( const limit = Math.min(100, Math.max(1, input.limit ?? 50)); return { + budgetId: input.budgetId, search: nonempty(input.search), type: input.type, categoryId: input.categoryId, @@ -756,6 +793,7 @@ export function normalizeTransactionTagListInput( input: TransactionTagListInput ): TransactionTagListQuery { return { + budgetId: input.budgetId, search: nonempty(input.search), limit: Math.min(100, Math.max(1, input.limit ?? 25)) }; @@ -765,6 +803,7 @@ export function normalizeCreateTransactionInput( input: CreateTransactionInput ): CreateTransactionBody { return { + budgetId: input.budgetId, categoryId: input.categoryId, vendorId: input.vendorId, amount: input.amount, @@ -791,6 +830,7 @@ export function normalizeUpdateTransactionInput( export function normalizeStatsInput(input: StatsInput): StatsQuery { return { + budgetId: input.budgetId, groupBy: input.groupBy ?? 'day', timeframe: input.timeframe ?? 'this-month', from: parseOptionalDate(input.from, 'from'), @@ -909,6 +949,9 @@ function mapExpectedError(err: unknown): never { err instanceof CategoryHierarchyError || err instanceof CategoryInUseError || err instanceof CategoryNotFoundError || + err instanceof BudgetAccessError || + err instanceof BudgetNotFoundError || + err instanceof BudgetPermissionError || err instanceof LastCategoryError || err instanceof TransactionCategoryError || err instanceof TransactionNotFoundError || @@ -936,6 +979,16 @@ export async function handleGetCurrentUser( return toolResult({ user }); } +export async function handleListBudgets( + context: XpenserMcpToolContext +): Promise { + const toolName = 'xpenser_list_budgets'; + logToolCall(context, toolName); + return toolResult({ + budgets: await context.data.listBudgets(context.principal.userId) + }); +} + export async function handleListCategories( context: XpenserMcpToolContext, input: CategoryListInput = {} @@ -1242,7 +1295,8 @@ export async function handleGetDashboardSummary( dashboard: await context.data.getDashboardSummary( context.principal.userId, input.period ?? 'day', - parseOptionalDate(input.date, 'date') + parseOptionalDate(input.date, 'date'), + input.budgetId ) }); } @@ -1274,11 +1328,20 @@ export function createXpenserMcpTools( annotations: readOnlyAnnotations, handler: () => handleGetCurrentUser(context) }, + { + name: 'xpenser_list_budgets', + title: 'List xpenser budgets', + description: + 'Return budgets available to the authenticated xpenser user, including role and permission flags.', + inputSchema: EmptyInputSchema, + annotations: readOnlyAnnotations, + handler: () => handleListBudgets(context) + }, { name: 'xpenser_list_categories', title: 'List xpenser categories', description: - 'Return income and expense categories for the authenticated xpenser user.', + 'Return income and expense categories for the selected xpenser budget.', inputSchema: CategoryListInputSchema, annotations: readOnlyAnnotations, handler: input => @@ -1288,7 +1351,7 @@ export function createXpenserMcpTools( name: 'xpenser_create_category', title: 'Create xpenser category', description: - 'Create an income or expense category for the authenticated xpenser user.', + 'Create an income or expense category in the selected xpenser budget.', inputSchema: CreateCategoryInputSchema, annotations: additiveWriteAnnotations, handler: input => @@ -1331,7 +1394,7 @@ export function createXpenserMcpTools( name: 'xpenser_list_vendors', title: 'List xpenser vendors', description: - 'Return vendors owned by the authenticated xpenser user, with transaction counts and category suggestions.', + 'Return vendors in the selected xpenser budget, with transaction counts and category suggestions.', inputSchema: VendorListInputSchema, annotations: readOnlyAnnotations, handler: input => @@ -1376,7 +1439,7 @@ export function createXpenserMcpTools( name: 'xpenser_create_vendor', title: 'Create xpenser vendor', description: - 'Create or reuse a vendor owned by the authenticated xpenser user. Selected candidate metadata may be enriched through Brandfetch.', + 'Create or reuse a vendor in the selected xpenser budget. Selected candidate metadata may be enriched through Brandfetch.', inputSchema: CreateVendorInputSchema, annotations: openWorldAdditiveWriteAnnotations, handler: input => @@ -1406,7 +1469,7 @@ export function createXpenserMcpTools( name: 'xpenser_list_transactions', title: 'List xpenser transactions', description: - 'Return paginated transactions for the authenticated xpenser user. Amount is the original transaction amount; defaultCurrencyAmount is converted to the user default currency.', + 'Return paginated transactions for the selected xpenser budget. Amount is the original transaction amount; defaultCurrencyAmount is converted to the budget default currency.', inputSchema: TransactionListInputSchema, annotations: readOnlyAnnotations, handler: input => @@ -1416,7 +1479,7 @@ export function createXpenserMcpTools( name: 'xpenser_list_transaction_tags', title: 'List xpenser transaction tags', description: - 'Return transaction tags owned by the authenticated xpenser user, including usage counts.', + 'Return transaction tags in the selected xpenser budget, including usage counts.', inputSchema: TransactionTagListInputSchema, annotations: readOnlyAnnotations, handler: input => @@ -1429,7 +1492,7 @@ export function createXpenserMcpTools( name: 'xpenser_create_transaction', title: 'Create xpenser transaction', description: - 'Create a transaction and store its historical exchange rate for the authenticated xpenser user.', + 'Create a transaction in the selected xpenser budget and store its historical exchange rate.', inputSchema: CreateTransactionInputSchema, annotations: openWorldAdditiveWriteAnnotations, handler: input => @@ -1465,7 +1528,7 @@ export function createXpenserMcpTools( name: 'xpenser_get_dashboard_summary', title: 'Get xpenser dashboard summary', description: - 'Return period totals and category distributions in the user default currency.', + 'Return selected-budget period totals and category distributions in the budget default currency.', inputSchema: DashboardInputSchema, annotations: readOnlyAnnotations, handler: input => @@ -1475,7 +1538,7 @@ export function createXpenserMcpTools( name: 'xpenser_get_stats_overview', title: 'Get xpenser stats overview', description: - 'Return income, expense, net, savings, trend, category, and comparison statistics in the user default currency.', + 'Return selected-budget income, expense, net, savings, trend, category, and comparison statistics in the budget default currency.', inputSchema: StatsInputSchema, annotations: readOnlyAnnotations, handler: input => diff --git a/apps/telegram-bot/src/bot.test.ts b/apps/telegram-bot/src/bot.test.ts index e8fcdde..de306b1 100644 --- a/apps/telegram-bot/src/bot.test.ts +++ b/apps/telegram-bot/src/bot.test.ts @@ -94,10 +94,32 @@ const me = { favoriteCurrencies: [], hasCategories: true, id: 1, + mainBudgetId: 1, monthlyEmailReportEnabled: false, timezone: 'UTC', transactionCurrencies: ['USD'], - weeklyEmailReportEnabled: false + weeklyEmailReportEnabled: false, + budgets: [ + { + id: 1, + name: 'Main', + defaultCurrency: 'USD', + countryCode: 'US', + role: 'admin', + permissions: { + canCreateTransactions: true, + canUpdateTransactions: true, + canDeleteTransactions: true, + canManageCategories: true, + canManageVendors: true, + canManageTags: true, + canManageMembers: true + }, + isMain: true, + createdAt: new Date('2026-05-01T00:00:00.000Z'), + updatedAt: new Date('2026-05-01T00:00:00.000Z') + } + ] } as UserPreference; const categories = [ @@ -231,6 +253,7 @@ describe('XpenserTelegramBot transaction flows', () => { expect(mocks.userClient.transactions.create).toHaveBeenCalledWith({ body: expect.objectContaining({ amount: 12.5, + budgetId: 1, categoryId: 1, currency: 'USD', vendorId: 9 @@ -324,6 +347,7 @@ describe('XpenserTelegramBot transaction flows', () => { expect(telegram.getFileStream).toHaveBeenCalledWith('large'); expect(mocks.userClient.transactionScans.start).toHaveBeenCalledWith({ body: { + budgetId: 1, fileName: 'telegram-photo-10.jpg', imageBase64: Buffer.from('receipt bytes').toString('base64'), mimeType: 'image/jpeg' @@ -332,6 +356,7 @@ describe('XpenserTelegramBot transaction flows', () => { expect(mocks.userClient.transactions.create).toHaveBeenCalledWith({ body: expect.objectContaining({ amount: 12.5, + budgetId: 1, categoryId: 1, currency: 'USD', vendorId: 9 @@ -340,12 +365,19 @@ describe('XpenserTelegramBot transaction flows', () => { expect(mocks.userClient.transactionScans.decide).toHaveBeenCalledWith({ params: { itemId: 50, scanId: 40 }, body: expect.objectContaining({ - attachment: { + attachment: expect.objectContaining({ + budgetId: 1, fileName: 'telegram-photo-10.jpg', imageBase64: Buffer.from('receipt bytes').toString('base64'), mimeType: 'image/jpeg' - }, + }), + correctedTransaction: expect.objectContaining({ + amount: 12.5, + categoryId: 1, + currency: 'USD', + vendorId: 9 + }), decision: 'confirmed', transactionId: 77 }) diff --git a/apps/telegram-bot/src/bot.ts b/apps/telegram-bot/src/bot.ts index ef8d761..42a4b2b 100644 --- a/apps/telegram-bot/src/bot.ts +++ b/apps/telegram-bot/src/bot.ts @@ -2,6 +2,7 @@ import { Readable } from 'node:stream'; import type { Logger } from '@cleverbrush/log'; import { createXpenserClient, type XpenserClient } from '@xpenser/client'; import type { + Budget, Category, Currency, Transaction, @@ -22,6 +23,8 @@ import TelegramBot from 'node-telegram-bot-api'; import type { BotConfig } from './config.js'; import { addCommand, + budgetCommand, + budgetSelectCallbackPrefix, cancelCallback, categoriesByRecentUse, categoriesWithPreferredFirst, @@ -70,16 +73,21 @@ type TelegramUserBody = { readonly telegramLastName?: string; }; +type BudgetContext = { + readonly budgetId: number; + readonly budgetName: string; +}; + type ManualDraft = - | { + | (BudgetContext & { readonly kind: 'manual'; readonly step: 'amount'; readonly me: UserPreference; readonly categories: readonly Category[]; readonly currencies: readonly Currency[]; readonly vendors: readonly Vendor[]; - } - | { + }) + | (BudgetContext & { readonly kind: 'manual'; readonly step: 'currency'; readonly me: UserPreference; @@ -87,8 +95,8 @@ type ManualDraft = readonly currencies: readonly Currency[]; readonly vendors: readonly Vendor[]; readonly amount: number; - } - | { + }) + | (BudgetContext & { readonly kind: 'manual'; readonly step: 'vendor'; readonly me: UserPreference; @@ -99,8 +107,8 @@ type ManualDraft = readonly currency: string; readonly page: number; readonly query?: string; - } - | { + }) + | (BudgetContext & { readonly kind: 'manual'; readonly step: 'vendor-search'; readonly me: UserPreference; @@ -109,8 +117,8 @@ type ManualDraft = readonly vendors: readonly Vendor[]; readonly amount: number; readonly currency: string; - } - | { + }) + | (BudgetContext & { readonly kind: 'manual'; readonly step: 'category'; readonly me: UserPreference; @@ -120,23 +128,23 @@ type ManualDraft = readonly currency: string; readonly vendorId: number | null; readonly page: number; - } - | { + }) + | (BudgetContext & { readonly kind: 'manual'; readonly step: 'note-choice'; readonly category: Category; readonly currency: string; readonly amount: number; readonly vendorId: number | null; - } - | { + }) + | (BudgetContext & { readonly kind: 'manual'; readonly step: 'note-text'; readonly category: Category; readonly currency: string; readonly amount: number; readonly vendorId: number | null; - }; + }); type ScanEditStep = | 'edit-amount' @@ -159,7 +167,7 @@ type ScanDraftValues = { type ScanDraftDecision = 'confirmed' | 'discarded'; -type ScanDraftSession = { +type ScanDraftSession = BudgetContext & { readonly kind: 'scan'; readonly step: 'review' | ScanEditStep; readonly me: UserPreference; @@ -502,6 +510,28 @@ function savedTransactionSummary(transaction: Transaction): string { return `${transaction.type}: ${vendor}${transaction.categoryDisplayName}, ${formatTelegramAmount(transaction.amount, transaction.currency)} (${formatTelegramAmount(transaction.defaultCurrencyAmount, transaction.defaultCurrency)}).`; } +function activeBudget( + me: UserPreference, + selectedBudgetId: number | undefined +): Budget | undefined { + return ( + me.budgets.find(budget => budget.id === selectedBudgetId) ?? + me.budgets.find(budget => budget.id === me.mainBudgetId) ?? + me.budgets[0] + ); +} + +function budgetKeyboard(me: UserPreference, selectedBudgetId: number) { + return { + inline_keyboard: me.budgets.map(budget => [ + { + text: `${budget.id === selectedBudgetId ? 'Current: ' : ''}${budget.name}`, + callback_data: `${budgetSelectCallbackPrefix}${budget.id}` + } + ]) + }; +} + function fileSizeLabel(size: number): string { if (size >= 1024 * 1024) { return `${(size / (1024 * 1024)).toFixed(1)} MB`; @@ -513,6 +543,7 @@ export class XpenserTelegramBot { readonly #bot: TelegramBot; readonly #serviceClient: XpenserClient; readonly #sessions = new Map(); + readonly #selectedBudgets = new Map(); readonly #logger: Logger; #lastPollingErrorMessage: string | undefined; #lastPollingErrorLoggedAt = 0; @@ -557,6 +588,17 @@ export class XpenserTelegramBot { () => this.beginAdd(msg) ); }); + this.#bot.onText(/^\/budget(?:@\S+)?$/, msg => { + void traceTelegramUpdate( + { + updateType: 'command', + command: telegramCommand(msg.text), + chatType: msg.chat.type, + messageId: msg.message_id + }, + () => this.handleBudget(msg) + ); + }); this.#bot.onText(/^\/cancel(?:@\S+)?$/, msg => { void traceTelegramUpdate( { @@ -706,6 +748,66 @@ export class XpenserTelegramBot { } } + async handleBudget(msg: TelegramBot.Message): Promise { + if (!this.isPrivateChat(msg.chat)) { + await this.#bot.sendMessage( + msg.chat.id, + 'Please open a private chat with this bot.', + { + reply_markup: quickAddReplyKeyboard() + } + ); + return; + } + + const user = telegramUser(msg.from); + if (!user) { + await this.#bot.sendMessage( + msg.chat.id, + 'Could not read Telegram user.', + { + reply_markup: quickAddReplyKeyboard() + } + ); + return; + } + + try { + const key = sessionKey(msg.chat.id, user.telegramUserId); + const client = await this.userClient(user); + const me = await client.auth.me(); + const budget = activeBudget(me, this.#selectedBudgets.get(key)); + if (!budget) { + await this.#bot.sendMessage( + msg.chat.id, + 'No budgets are available for this account.', + { + reply_markup: quickAddReplyKeyboard() + } + ); + return; + } + + this.#selectedBudgets.set(key, budget.id); + await this.#bot.sendMessage( + msg.chat.id, + `Active budget: ${budget.name}`, + { + reply_markup: budgetKeyboard(me, budget.id) + } + ); + } catch (err) { + await this.#bot.sendMessage( + msg.chat.id, + apiErrorMessage(err) ?? + 'Telegram is not connected. Connect it from xpenser Preferences.', + { + reply_markup: quickAddReplyKeyboard() + } + ); + } + } + async beginAdd(msg: TelegramBot.Message): Promise { if (!this.isPrivateChat(msg.chat)) { await this.#bot.sendMessage( @@ -732,14 +834,36 @@ export class XpenserTelegramBot { try { const client = await this.userClient(user); - const [me, categories, currencies, vendors, recentTransactions] = + const me = await client.auth.me(); + const key = sessionKey(msg.chat.id, user.telegramUserId); + const budget = activeBudget(me, this.#selectedBudgets.get(key)); + if (!budget) { + await this.#bot.sendMessage( + msg.chat.id, + 'No budgets are available for this account.', + { + reply_markup: quickAddReplyKeyboard() + } + ); + return; + } + + this.#selectedBudgets.set(key, budget.id); + const budgetQuery = { budgetId: budget.id }; + const [categories, currencies, vendors, recentTransactions] = await Promise.all([ - client.auth.me(), - client.categories.list({ query: {} }), + client.categories.list({ query: budgetQuery }), client.currencies.list(), - client.vendors.list({ query: { limit: 100 } }), + client.vendors.list({ + query: { ...budgetQuery, limit: 100 } + }), client.transactions.list({ - query: { direction: 'desc', limit: 100, page: 1 } + query: { + ...budgetQuery, + direction: 'desc', + limit: 100, + page: 1 + } }) ]); @@ -768,6 +892,8 @@ export class XpenserTelegramBot { const draft: Draft = { kind: 'manual', step: 'amount', + budgetId: budget.id, + budgetName: budget.name, me, categories: categoriesByRecentUse( categories, @@ -776,13 +902,10 @@ export class XpenserTelegramBot { currencies, vendors }; - this.#sessions.set( - sessionKey(msg.chat.id, user.telegramUserId), - draft - ); + this.#sessions.set(key, draft); await this.#bot.sendMessage( msg.chat.id, - '💸 How much is the transaction? Send a number like 12.50.' + `Budget: ${budget.name}\n💸 How much is the transaction? Send a number like 12.50.` ); } catch (err) { await this.#bot.sendMessage( @@ -849,11 +972,28 @@ export class XpenserTelegramBot { try { const client = await this.userClient(user); - const [me, categories, currencies, vendors] = await Promise.all([ - client.auth.me(), - client.categories.list({ query: { activeOnly: true } }), + const me = await client.auth.me(); + const key = sessionKey(msg.chat.id, user.telegramUserId); + const budget = activeBudget(me, this.#selectedBudgets.get(key)); + if (!budget) { + await this.updateProgressMessage( + msg.chat.id, + progressMessage.message_id, + 'No budgets are available for this account.' + ); + return; + } + + this.#selectedBudgets.set(key, budget.id); + const budgetQuery = { budgetId: budget.id }; + const [categories, currencies, vendors] = await Promise.all([ + client.categories.list({ + query: { ...budgetQuery, activeOnly: true } + }), client.currencies.list(), - client.vendors.list({ query: { limit: 100 } }) + client.vendors.list({ + query: { ...budgetQuery, limit: 100 } + }) ]); if (categories.length === 0) { @@ -867,6 +1007,7 @@ export class XpenserTelegramBot { const image = await this.downloadTelegramFile(media); const body: TransactionScanBody = { + budgetId: budget.id, fileName: media.fileName, imageBase64: image.toString('base64'), mimeType: media.mimeType @@ -894,10 +1035,11 @@ export class XpenserTelegramBot { return; } - const key = sessionKey(msg.chat.id, user.telegramUserId); const session: ScanDraftSession = { kind: 'scan', step: 'review', + budgetId: budget.id, + budgetName: budget.name, me, categories, currencies, @@ -961,6 +1103,11 @@ export class XpenserTelegramBot { return; } + if (data.startsWith(budgetSelectCallbackPrefix)) { + await this.selectBudget(chatId, user, key, data); + return; + } + const draft = this.#sessions.get(key); if (!draft) { await this.#bot.sendMessage( @@ -981,6 +1128,49 @@ export class XpenserTelegramBot { await this.handleManualCallback(chatId, user, key, draft, data); } + async selectBudget( + chatId: number, + user: TelegramUserBody, + key: string, + data: string + ): Promise { + const budgetId = Number(data.slice(budgetSelectCallbackPrefix.length)); + if (!Number.isSafeInteger(budgetId) || budgetId <= 0) { + await this.#bot.sendMessage( + chatId, + 'Budget is no longer available.' + ); + return; + } + + try { + const me = await (await this.userClient(user)).auth.me(); + const budget = me.budgets.find(item => item.id === budgetId); + if (!budget) { + await this.#bot.sendMessage( + chatId, + 'Budget is no longer available.' + ); + return; + } + + this.#selectedBudgets.set(key, budget.id); + this.#sessions.delete(key); + await this.#bot.sendMessage( + chatId, + `Active budget: ${budget.name}. Tap Add or send an image to continue.`, + { + reply_markup: quickAddReplyKeyboard() + } + ); + } catch (err) { + await this.#bot.sendMessage( + chatId, + apiErrorMessage(err) ?? 'Could not change budget.' + ); + } + } + async handleManualCallback( chatId: number, user: TelegramUserBody, @@ -1055,6 +1245,8 @@ export class XpenserTelegramBot { this.#sessions.set(key, { kind: 'manual', step: 'note-choice', + budgetId: draft.budgetId, + budgetName: draft.budgetName, amount: draft.amount, currency: draft.currency, vendorId: draft.vendorId, @@ -1454,6 +1646,8 @@ export class XpenserTelegramBot { const next: ManualDraft = { kind: 'manual', step: 'currency', + budgetId: draft.budgetId, + budgetName: draft.budgetName, me: draft.me, categories: draft.categories, currencies: draft.currencies, @@ -1686,6 +1880,8 @@ export class XpenserTelegramBot { { kind: 'manual', step: 'vendor', + budgetId: draft.budgetId, + budgetName: draft.budgetName, me: draft.me, categories: draft.categories, currencies: draft.currencies, @@ -1702,6 +1898,8 @@ export class XpenserTelegramBot { const next: ManualDraft = { kind: 'manual', step: 'vendor', + budgetId: draft.budgetId, + budgetName: draft.budgetName, me: draft.me, categories: draft.categories, currencies: draft.currencies, @@ -1734,6 +1932,8 @@ export class XpenserTelegramBot { const next: ManualDraft = { kind: 'manual', step: 'category', + budgetId: draft.budgetId, + budgetName: draft.budgetName, me: draft.me, categories: categoriesWithPreferredFirst( draft.categories, @@ -1763,6 +1963,7 @@ export class XpenserTelegramBot { const client = await this.userClient(user); const transaction = await client.transactions.create({ body: { + budgetId: draft.budgetId, categoryId: draft.category.id, vendorId: draft.vendorId, amount: draft.amount, @@ -1798,6 +1999,10 @@ export class XpenserTelegramBot { command: addCommand.slice(1), description: 'Add a transaction' }, + { + command: budgetCommand.slice(1), + description: 'Choose the active budget' + }, { command: 'cancel', description: 'Cancel the current transaction' @@ -1873,6 +2078,7 @@ export class XpenserTelegramBot { : undefined; const lines = [ `Transaction ${session.index + 1} of ${session.scan.drafts.length}`, + `Budget: ${session.budgetName}`, `Source: ${session.scan.documentKind.replace('_', ' ')}`, `Status: ${session.decisions[draft.id] ?? 'pending'}`, '', @@ -1947,6 +2153,7 @@ export class XpenserTelegramBot { const client = await this.userClient(user); const transaction = await client.transactions.create({ body: { + budgetId: session.budgetId, amount: values.amount, categoryId: values.categoryId, currency: values.currency, diff --git a/apps/telegram-bot/src/flow.ts b/apps/telegram-bot/src/flow.ts index be0d06c..988eae7 100644 --- a/apps/telegram-bot/src/flow.ts +++ b/apps/telegram-bot/src/flow.ts @@ -27,6 +27,8 @@ export const scanEditDateCallback = 'scan:edit:date'; export const scanEditNoteCallback = 'scan:edit:note'; export const scanEditVendorCallback = 'scan:edit:vendor'; export const addCommand = '/add'; +export const budgetCommand = '/budget'; +export const budgetSelectCallbackPrefix = 'budget:'; export const addButtonText = 'Add'; export const allowedScanImageMimeTypes = [ 'image/jpeg', diff --git a/apps/web/app/(app)/capture/page.tsx b/apps/web/app/(app)/capture/page.tsx index 1f60abb..fcc6f5c 100644 --- a/apps/web/app/(app)/capture/page.tsx +++ b/apps/web/app/(app)/capture/page.tsx @@ -1,11 +1,16 @@ import { redirect } from 'next/navigation'; import { TransactionCaptureWorkspace } from '@/components/transaction-scan-capture'; import { getApiClient } from '@/lib/api'; +import { selectedBudgetForUser, selectedBudgetQuery } from '@/lib/budgets'; import { categoriesByRecentUse } from '@/lib/capture-suggestions'; export default async function CapturePage() { const client = await getApiClient(); const me = await client.auth.me(); + const budgetQuery = await selectedBudgetQuery(me); + const selectedBudget = await selectedBudgetForUser(me); + const defaultCurrency = + selectedBudget?.defaultCurrency ?? me.defaultCurrency; const [ categories, currencies, @@ -13,12 +18,16 @@ export default async function CapturePage() { transactionTags, recentTransactions ] = await Promise.all([ - client.categories.list({ query: { activeOnly: true } }), + client.categories.list({ + query: { ...budgetQuery, activeOnly: true } + }), client.currencies.list(), - client.vendors.list({ query: { limit: 100 } }), - client.transactionTags.list({ query: { limit: 100 } }), + client.vendors.list({ query: { ...budgetQuery, limit: 100 } }), + client.transactionTags.list({ + query: { ...budgetQuery, limit: 100 } + }), client.transactions.list({ - query: { direction: 'desc', limit: 100, page: 1 } + query: { ...budgetQuery, direction: 'desc', limit: 100, page: 1 } }) ]); @@ -34,7 +43,7 @@ export default async function CapturePage() { recentTransactions.items )} currencies={currencies} - defaultCurrency={me.defaultCurrency} + defaultCurrency={defaultCurrency} vendors={vendors} transactionTags={transactionTags} timezone={me.timezone} diff --git a/apps/web/app/(app)/dashboard/page.tsx b/apps/web/app/(app)/dashboard/page.tsx index 057b6d9..e424273 100644 --- a/apps/web/app/(app)/dashboard/page.tsx +++ b/apps/web/app/(app)/dashboard/page.tsx @@ -1,6 +1,7 @@ import { redirect } from 'next/navigation'; import { DashboardExplorer } from '@/components/dashboard-explorer'; import { getApiClient } from '@/lib/api'; +import { selectedBudgetForUser, selectedBudgetQuery } from '@/lib/budgets'; import { selectedDashboardCurrency } from '@/lib/dashboard-currencies'; import { isDashboardPeriod, parseDateParam } from '@/lib/dashboard-periods'; import { initialDashboardWindowDate } from '@/lib/dashboard-window'; @@ -20,9 +21,13 @@ export default async function DashboardPage({ const period = isDashboardPeriod(params.period) ? params.period : 'day'; const client = await getApiClient(); const me = await client.auth.me(); + const budgetQuery = await selectedBudgetQuery(me); + const selectedBudget = await selectedBudgetForUser(me); + const defaultCurrency = + selectedBudget?.defaultCurrency ?? me.defaultCurrency; const displayCurrency = selectedDashboardCurrency( params.currency, - me.defaultCurrency, + defaultCurrency, me.favoriteCurrencies ); const selectedDate = parseDateParam(params.date, me.timezone); @@ -30,16 +35,23 @@ export default async function DashboardPage({ const [categories, currencies, vendors, transactionTags, window] = await Promise.all([ client.categories.list({ - query: { activeOnly: true, sort: 'recent-transaction-count' } + query: { + ...budgetQuery, + activeOnly: true, + sort: 'recent-transaction-count' + } }), client.currencies.list(), - client.vendors.list({ query: { limit: 100 } }), - client.transactionTags.list({ query: { limit: 100 } }), + client.vendors.list({ query: { ...budgetQuery, limit: 100 } }), + client.transactionTags.list({ + query: { ...budgetQuery, limit: 100 } + }), client.dashboard.window({ query: { + ...budgetQuery, after: 2, before: 2, - ...(displayCurrency !== me.defaultCurrency + ...(displayCurrency !== defaultCurrency ? { currency: displayCurrency } : {}), vendorLimit: 0, @@ -57,7 +69,7 @@ export default async function DashboardPage({
- +
{children}
diff --git a/apps/web/app/(app)/settings/categories/page.tsx b/apps/web/app/(app)/settings/categories/page.tsx index 4561fa5..a5aeaae 100644 --- a/apps/web/app/(app)/settings/categories/page.tsx +++ b/apps/web/app/(app)/settings/categories/page.tsx @@ -1,9 +1,13 @@ import { CategoryManager } from '@/components/category-manager'; import { getApiClient } from '@/lib/api'; +import { selectedBudgetQuery } from '@/lib/budgets'; export default async function CategoriesPage() { const client = await getApiClient(); - const categories = await client.categories.list({ query: {} }); + const me = await client.auth.me(); + const categories = await client.categories.list({ + query: await selectedBudgetQuery(me) + }); return (
diff --git a/apps/web/app/(app)/settings/preferences/page.tsx b/apps/web/app/(app)/settings/preferences/page.tsx index 7a515aa..f74eebb 100644 --- a/apps/web/app/(app)/settings/preferences/page.tsx +++ b/apps/web/app/(app)/settings/preferences/page.tsx @@ -10,6 +10,7 @@ import { import { FolderTreeIcon, Send, StoreIcon, Unlink } from 'lucide-react'; import Link from 'next/link'; import { ApiKeysSettings } from '@/components/api-keys-settings'; +import { BudgetSettings } from '@/components/budget-settings'; import { PreferencesForm } from '@/components/forms/preferences-form'; import { createTelegramLinkAction, @@ -28,6 +29,17 @@ export default async function PreferencesPage() { client.users.listApiKeys(), client.users.listMcpOAuthConnections() ]); + const memberEntries = await Promise.all( + me.budgets + .filter(budget => budget.permissions.canManageMembers) + .map(async budget => [ + budget.id, + await client.budgets.members({ + params: { id: budget.id } + }) + ]) + ); + const membersByBudget = Object.fromEntries(memberEntries); const telegramName = telegram.telegramUsername ? `@${telegram.telegramUsername}` : [telegram.telegramFirstName, telegram.telegramLastName] @@ -122,6 +134,14 @@ export default async function PreferencesPage() {
+
diff --git a/apps/web/app/(app)/settings/vendors/[vendorId]/page.tsx b/apps/web/app/(app)/settings/vendors/[vendorId]/page.tsx index 689eab6..ce0519f 100644 --- a/apps/web/app/(app)/settings/vendors/[vendorId]/page.tsx +++ b/apps/web/app/(app)/settings/vendors/[vendorId]/page.tsx @@ -58,7 +58,7 @@ export default async function VendorSettingsPage({ } const client = await getApiClient(); - const [me, vendor, transactions] = await Promise.all([ + const [me, vendor] = await Promise.all([ client.auth.me(), client.vendors .get({ params: { id: selectedVendorId } }) @@ -67,16 +67,17 @@ export default async function VendorSettingsPage({ notFound(); } throw error; - }), - client.transactions.list({ - query: { - direction: 'desc', - limit: 25, - vendorId: selectedVendorId, - page: 1 - } - }) + }) ]); + const transactions = await client.transactions.list({ + query: { + budgetId: vendor.budgetId, + direction: 'desc', + limit: 25, + vendorId: selectedVendorId, + page: 1 + } + }); return (
diff --git a/apps/web/app/(app)/settings/vendors/page.tsx b/apps/web/app/(app)/settings/vendors/page.tsx index 120fa4d..0b67b49 100644 --- a/apps/web/app/(app)/settings/vendors/page.tsx +++ b/apps/web/app/(app)/settings/vendors/page.tsx @@ -1,5 +1,6 @@ import { VendorDirectory } from '@/components/vendor-directory'; import { getApiClient } from '@/lib/api'; +import { selectedBudgetQuery } from '@/lib/budgets'; type VendorSearchParams = { readonly search?: string | readonly string[]; @@ -18,8 +19,10 @@ export default async function VendorsSettingsPage({ const params = await searchParams; const search = readSearch(params.search); const client = await getApiClient(); + const me = await client.auth.me(); + const budgetQuery = await selectedBudgetQuery(me); const vendors = await client.vendors.list({ - query: { limit: 100, search: search || undefined } + query: { ...budgetQuery, limit: 100, search: search || undefined } }); return ( diff --git a/apps/web/app/(app)/stats/categories/[categoryId]/page.tsx b/apps/web/app/(app)/stats/categories/[categoryId]/page.tsx index 188534c..c3c85f9 100644 --- a/apps/web/app/(app)/stats/categories/[categoryId]/page.tsx +++ b/apps/web/app/(app)/stats/categories/[categoryId]/page.tsx @@ -1,6 +1,7 @@ import { notFound } from 'next/navigation'; import { CategoryTrendExplorer } from '@/components/category-trend-explorer'; import { getApiClient } from '@/lib/api'; +import { selectedBudgetQuery } from '@/lib/budgets'; import { type CategoryTrendSearchParams, categoryTrendParamValue, @@ -51,9 +52,13 @@ export default async function CategoryTrendPage({ const client = await getApiClient(); const me = await client.auth.me(); - const query = categoryTrendQuery(rawSearchParams, me.timezone); + const budgetQuery = await selectedBudgetQuery(me); + const query = { + ...categoryTrendQuery(rawSearchParams, me.timezone), + ...budgetQuery + }; const [categories, trend] = await Promise.all([ - client.categories.list({ query: {} }), + client.categories.list({ query: budgetQuery }), client.stats .categoryTrend({ params: { id: selectedCategoryId }, diff --git a/apps/web/app/(app)/stats/page.tsx b/apps/web/app/(app)/stats/page.tsx index f1b3449..c0d50ce 100644 --- a/apps/web/app/(app)/stats/page.tsx +++ b/apps/web/app/(app)/stats/page.tsx @@ -1,6 +1,7 @@ import type { StatsTagReport, StatsWindowResponse } from '@xpenser/contracts'; import { StatsExplorer } from '@/components/stats-explorer'; import { getApiClient } from '@/lib/api'; +import { selectedBudgetForUser, selectedBudgetQuery } from '@/lib/budgets'; import { dateParam, isDashboardPeriod, @@ -52,6 +53,10 @@ export default async function StatsPage({ const period = isDashboardPeriod(params.period) ? params.period : 'day'; const client = await getApiClient(); const me = await client.auth.me(); + const budgetQuery = await selectedBudgetQuery(me); + const selectedBudget = await selectedBudgetForUser(me); + const defaultCurrency = + selectedBudget?.defaultCurrency ?? me.defaultCurrency; const selectedDate = parseDateParam(params.date, me.timezone); const anchorDate = selectedDate ?? new Date(); const initialView = reportView(params.view); @@ -60,6 +65,7 @@ export default async function StatsPage({ client.currencies.list(), client.stats.window({ query: { + ...budgetQuery, after: 2, before: 2, period, @@ -71,6 +77,7 @@ export default async function StatsPage({ initialView === 'tags' ? await client.stats.tags({ query: { + ...budgetQuery, period, ...(selectedDate ? { date: selectedDate } : {}), ...(initialTag ? { tag: initialTag } : {}) @@ -81,7 +88,7 @@ export default async function StatsPage({ return ( ({ auth: vi.fn(), createXpenserClient: vi.fn(), + selectedBudgetIdFromCookie: vi.fn(), transactionScanStart: vi.fn() })); @@ -16,6 +17,10 @@ vi.mock('@/lib/config', () => ({ webConfig: { apiBaseUrl: 'https://api.example.test' } })); +vi.mock('@/lib/budgets', () => ({ + selectedBudgetIdFromCookie: mocks.selectedBudgetIdFromCookie +})); + vi.mock('@xpenser/client', () => ({ createXpenserClient: mocks.createXpenserClient })); @@ -57,6 +62,7 @@ describe('transaction scan route', () => { mocks.createXpenserClient.mockReturnValue({ transactionScans: { start: mocks.transactionScanStart } }); + mocks.selectedBudgetIdFromCookie.mockResolvedValue(undefined); mocks.transactionScanStart.mockResolvedValue({ jobId: '00000000-0000-4000-8000-000000000042', token: 'scan-token' diff --git a/apps/web/app/app-api/transaction-scans/route.ts b/apps/web/app/app-api/transaction-scans/route.ts index d8d7612..52751dd 100644 --- a/apps/web/app/app-api/transaction-scans/route.ts +++ b/apps/web/app/app-api/transaction-scans/route.ts @@ -2,6 +2,7 @@ import { createXpenserClient } from '@xpenser/client'; import type { TransactionScanJobResponse } from '@xpenser/contracts'; import { NextResponse } from 'next/server'; import { getCurrentSession } from '@/lib/api'; +import { selectedBudgetIdFromCookie } from '@/lib/budgets'; import { webConfig } from '@/lib/config'; import { assembleScanUploadChunks, @@ -186,6 +187,7 @@ export async function POST(request: Request) { try { const imageBase64 = image.toString('base64'); + const budgetId = await selectedBudgetIdFromCookie(); const attachment = await storeScanUpload({ buffer: image, fileName: body.fileName, @@ -195,6 +197,7 @@ export async function POST(request: Request) { }); const job = await client.transactionScans.start({ body: { + ...(budgetId ? { budgetId } : {}), imageBase64, mimeType, fileName: body.fileName diff --git a/apps/web/app/app-api/transactions/export.csv/route.ts b/apps/web/app/app-api/transactions/export.csv/route.ts index e86e38b..00254de 100644 --- a/apps/web/app/app-api/transactions/export.csv/route.ts +++ b/apps/web/app/app-api/transactions/export.csv/route.ts @@ -1,5 +1,6 @@ import { type NextRequest, NextResponse } from 'next/server'; import { getCurrentSession } from '@/lib/api'; +import { selectedBudgetIdFromCookie } from '@/lib/budgets'; import { webConfig } from '@/lib/config'; import { buildTransactionListQuery } from '@/lib/transaction-query'; @@ -8,10 +9,14 @@ export const runtime = 'nodejs'; function exportApiQuery( params: URLSearchParams, - timeZone: string + timeZone: string, + budgetId?: number ): URLSearchParams { const query = buildTransactionListQuery(params, {}, timeZone); const exportParams = new URLSearchParams(); + if (budgetId) { + exportParams.set('budgetId', String(budgetId)); + } const currencies = params.get('currencies'); if (currencies) { exportParams.set('currencies', currencies); @@ -55,9 +60,11 @@ export async function GET(request: NextRequest) { return NextResponse.json({ message: 'Unauthorized' }, { status: 401 }); } + const budgetId = await selectedBudgetIdFromCookie(); const query = exportApiQuery( request.nextUrl.searchParams, - session.user.timezone + session.user.timezone, + budgetId ); if (!query.get('currencies')) { return NextResponse.json( diff --git a/apps/web/app/app-api/transactions/route.ts b/apps/web/app/app-api/transactions/route.ts index 55f545a..7eb95c9 100644 --- a/apps/web/app/app-api/transactions/route.ts +++ b/apps/web/app/app-api/transactions/route.ts @@ -1,6 +1,7 @@ import { createXpenserClient } from '@xpenser/client'; import { type NextRequest, NextResponse } from 'next/server'; import { getCurrentSession } from '@/lib/api'; +import { selectedBudgetIdFromCookie } from '@/lib/budgets'; import { webConfig } from '@/lib/config'; import { buildTransactionListQuery, @@ -29,12 +30,16 @@ export async function GET(request: NextRequest) { getToken: () => session.apiToken }); try { + const budgetId = await selectedBudgetIdFromCookie(); const transactions = await client.transactions.list({ - query: buildTransactionListQuery( - request.nextUrl.searchParams, - {}, - session.user.timezone - ) + query: { + ...buildTransactionListQuery( + request.nextUrl.searchParams, + {}, + session.user.timezone + ), + ...(budgetId ? { budgetId } : {}) + } }); return NextResponse.json({ diff --git a/apps/web/app/budgets/invitations/accept/page.tsx b/apps/web/app/budgets/invitations/accept/page.tsx new file mode 100644 index 0000000..92f5cae --- /dev/null +++ b/apps/web/app/budgets/invitations/accept/page.tsx @@ -0,0 +1,64 @@ +import { + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle +} from '@xpenser/ui'; +import Link from 'next/link'; +import { acceptBudgetInvitationAction } from '@/lib/actions'; + +type AcceptInvitationSearchParams = { + readonly token?: string | readonly string[]; +}; + +function readToken(value: AcceptInvitationSearchParams['token']) { + const token = Array.isArray(value) ? value[0] : value; + return typeof token === 'string' ? token : undefined; +} + +export default async function AcceptBudgetInvitationPage({ + searchParams +}: { + readonly searchParams: Promise; +}) { + const token = readToken((await searchParams).token); + + return ( +
+ + + Join budget + + Accept the invitation to add this shared budget to your + xpenser account. + + + + {token ? ( +
+ + + + + ) : ( +
+

+ This invitation link is missing its token. +

+ +
+ )} +
+
+
+ ); +} diff --git a/apps/web/app/metadata-routes.test.ts b/apps/web/app/metadata-routes.test.ts index 6b2da4c..c5c2724 100644 --- a/apps/web/app/metadata-routes.test.ts +++ b/apps/web/app/metadata-routes.test.ts @@ -33,7 +33,7 @@ describe('metadata routes', () => { it('serves only public sitemap URLs', async () => { const urls = (await sitemap()).map(entry => entry.url); - expect(urls).toHaveLength(79); + expect(urls).toHaveLength(80); expect(urls).toEqual( expect.arrayContaining([ 'https://xpenser.cleverbrush.com/', @@ -51,6 +51,7 @@ describe('metadata routes', () => { 'https://xpenser.cleverbrush.com/blog/dashboard-vendor-view-controls', 'https://xpenser.cleverbrush.com/blog/disable-gtm-in-pr-environments', 'https://xpenser.cleverbrush.com/blog/transaction-csv-export', + 'https://xpenser.cleverbrush.com/blog/shared-budgets', 'https://xpenser.cleverbrush.com/blog/hide-amounts-mode', 'https://xpenser.cleverbrush.com/blog/period-aware-transaction-navigation', 'https://xpenser.cleverbrush.com/blog/tag-report-transaction-drilldowns' diff --git a/apps/web/components/app-nav.tsx b/apps/web/components/app-nav.tsx index 95c25bf..787a743 100644 --- a/apps/web/components/app-nav.tsx +++ b/apps/web/components/app-nav.tsx @@ -1,3 +1,4 @@ +import type { Budget } from '@xpenser/contracts'; import { Button } from '@xpenser/ui'; import { CirclePlusIcon, @@ -8,18 +9,33 @@ import { import Link from 'next/link'; import { logoutAction } from '@/lib/actions'; import { webConfig } from '@/lib/config'; +import { BudgetSelector } from './budget-selector'; import { MobileTabBar } from './mobile-tab-bar'; import { PeriodStateLink } from './period-state-link'; import { ThemeToggle } from './theme-toggle'; -export function AppNav({ timezone }: { readonly timezone: string }) { +export function AppNav({ + budgets, + selectedBudgetId, + timezone +}: { + readonly budgets: readonly Budget[]; + readonly selectedBudgetId?: number; + readonly timezone: string; +}) { return ( <>
- - xpenser - +
+ + xpenser + + +