diff --git a/apps/api/src/api/endpoints.ts b/apps/api/src/api/endpoints.ts index 1d1e1f5..5994a5d 100644 --- a/apps/api/src/api/endpoints.ts +++ b/apps/api/src/api/endpoints.ts @@ -81,7 +81,7 @@ export const GetMeEndpoint = api.auth.me .inject({ db: DbToken }) .summary('Current user') .description( - 'Returns preferences and transaction currency ordering for the authenticated user.' + 'Returns preferences, accessible budgets, and derived transaction currency ordering for the authenticated user.' ) .tags('users') .operationId('getCurrentUser'); @@ -91,7 +91,7 @@ export const UpdatePreferencesEndpoint = api.users.updatePreferences .inject({ db: DbToken }) .summary('Update preferences') .description( - 'Updates the current user default currency, favorite currencies, and timezone.' + 'Updates the current user country, timezone, and email report preferences.' ) .tags('users') .operationId('updateUserPreferences'); @@ -122,6 +122,112 @@ export const DisconnectTelegramEndpoint = api.users.disconnectTelegram .tags('users') .operationId('disconnectTelegram'); +export const UpdateUserAvatarEndpoint = api.users.updateAvatar + .authorize(PrincipalSchema) + .inject({ db: DbToken }) + .summary('Update user avatar') + .description('Stores a manually uploaded avatar for the current user.') + .tags('users') + .operationId('updateUserAvatar'); + +export const DeleteUserAvatarEndpoint = api.users.deleteAvatar + .authorize(PrincipalSchema) + .inject({ db: DbToken }) + .summary('Delete user avatar') + .description('Removes the current user manually uploaded avatar.') + .tags('users') + .operationId('deleteUserAvatar'); + +export const UserAvatarImageEndpoint = api.users.avatarImage + .authorize(PrincipalSchema) + .inject({ db: DbToken }) + .summary('User avatar image') + .description('Streams a stored avatar image visible to the current user.') + .tags('users') + .operationId('userAvatarImage'); + +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, defaults, or archive state.') + .tags('budgets') + .operationId('updateBudget'); + +export const DeleteBudgetEndpoint = api.budgets.delete + .authorize(PrincipalSchema) + .inject({ db: DbToken }) + .summary('Delete budget') + .description('Permanently deletes an archived non-main budget.') + .tags('budgets') + .operationId('deleteBudget'); + +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 ListBudgetAccessEndpoint = api.budgets.access + .authorize(PrincipalSchema) + .inject({ db: DbToken }) + .summary('List budget access') + .description( + 'Lists active members and invitation statuses for a budget the current user can manage.' + ) + .tags('budgets') + .operationId('listBudgetAccess'); + +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 }) @@ -214,7 +320,7 @@ export const ConvertCurrencyEndpoint = api.currencies.convert .inject({ db: DbToken, config: ConfigToken }) .summary('Convert currency') .description( - 'Converts an entered amount to the authenticated user default currency.' + 'Converts an entered amount to the selected budget default currency.' ) .tags('currencies') .operationId('convertCurrency'); @@ -357,7 +463,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 +479,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.' @@ -493,12 +599,27 @@ export const endpoints = { telegramStatus: TelegramStatusEndpoint, createTelegramLinkToken: CreateTelegramLinkTokenEndpoint, disconnectTelegram: DisconnectTelegramEndpoint, + updateAvatar: UpdateUserAvatarEndpoint, + deleteAvatar: DeleteUserAvatarEndpoint, + avatarImage: UserAvatarImageEndpoint, listApiKeys: ListApiKeysEndpoint, createApiKey: CreateApiKeyEndpoint, revokeApiKey: RevokeApiKeyEndpoint, listMcpOAuthConnections: ListMcpOAuthConnectionsEndpoint, revokeMcpOAuthConnection: RevokeMcpOAuthConnectionEndpoint }, + budgets: { + list: ListBudgetsEndpoint, + create: CreateBudgetEndpoint, + update: UpdateBudgetEndpoint, + delete: DeleteBudgetEndpoint, + members: ListBudgetMembersEndpoint, + access: ListBudgetAccessEndpoint, + 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/auth.ts b/apps/api/src/api/handlers/auth.ts index b64ef0b..fcc0678 100644 --- a/apps/api/src/api/handlers/auth.ts +++ b/apps/api/src/api/handlers/auth.ts @@ -1,4 +1,10 @@ import { ActionResult, type Handler } from '@cleverbrush/server'; +import { + deleteUserAvatar, + getUserAvatarImage, + UserAvatarError, + updateUserAvatar +} from '../../application/user-avatars.js'; import { confirmEmail, DuplicateEmailError, @@ -29,6 +35,7 @@ import { } from '../../security/passport.js'; import type { ConfirmEmailEndpoint, + DeleteUserAvatarEndpoint, GetMeEndpoint, GoogleSignInEndpoint, LoginEndpoint, @@ -38,7 +45,9 @@ import type { ResendEmailConfirmationEndpoint, SessionTokenEndpoint, SingleUserSessionTokenEndpoint, - UpdatePreferencesEndpoint + UpdatePreferencesEndpoint, + UpdateUserAvatarEndpoint, + UserAvatarImageEndpoint } from '../endpoints.js'; const webServiceSecretHeader = 'x-xpenser-web-secret'; @@ -292,8 +301,6 @@ export const updatePreferencesHandler: Handler< const preference = await updateUserPreference( db, principal.userId, - body.defaultCurrency, - body.favoriteCurrencies, body.countryCode, body.timezone, body.weeklyEmailReportEnabled, @@ -304,3 +311,60 @@ export const updatePreferencesHandler: Handler< } return preference; }; + +export const updateUserAvatarHandler: Handler< + typeof UpdateUserAvatarEndpoint +> = async ({ body, principal }, { db }) => { + try { + const preference = await updateUserAvatar(db, principal.userId, body); + if (!preference) { + return ActionResult.unauthorized({ + message: 'User was not found.' + }); + } + return preference; + } catch (err) { + if (err instanceof UserAvatarError) { + return ActionResult.badRequest({ message: err.message }); + } + throw err; + } +}; + +export const deleteUserAvatarHandler: Handler< + typeof DeleteUserAvatarEndpoint +> = async ({ principal }, { db }) => { + const preference = await deleteUserAvatar(db, principal.userId); + if (!preference) { + return ActionResult.unauthorized({ message: 'User was not found.' }); + } + return preference; +}; + +export const userAvatarImageHandler: Handler< + typeof UserAvatarImageEndpoint +> = async ({ params, principal }, { db }) => { + const image = await getUserAvatarImage(db, principal.userId, params.id); + if (!image) { + return ActionResult.notFound({ + message: 'Avatar image was not found.' + }); + } + + return ActionResult.raw(async (_request, response) => { + const buffer = Buffer.from(image.imageBase64, 'base64'); + response.setHeader('Content-Type', image.mimeType); + response.setHeader('Content-Length', String(buffer.byteLength)); + response.setHeader('Cache-Control', 'private, max-age=300'); + if (image.fileName) { + response.setHeader( + 'Content-Disposition', + `inline; filename="${image.fileName.replaceAll('"', '')}"` + ); + } + if (image.updatedAt) { + response.setHeader('Last-Modified', image.updatedAt.toUTCString()); + } + response.end(buffer); + }); +}; diff --git a/apps/api/src/api/handlers/budgets.ts b/apps/api/src/api/handlers/budgets.ts new file mode 100644 index 0000000..3205903 --- /dev/null +++ b/apps/api/src/api/handlers/budgets.ts @@ -0,0 +1,207 @@ +import { ActionResult, type Handler } from '@cleverbrush/server'; +import { + acceptBudgetInvitation, + BudgetAccessError, + BudgetInvitationInvalidError, + BudgetMemberError, + BudgetNotFoundError, + BudgetPermissionError, + createBudget, + deleteBudget, + inviteBudgetMember, + listBudgetAccess, + listBudgetMembers, + listBudgets, + removeBudgetMember, + updateBudget, + updateBudgetMember +} from '../../application/budgets.js'; +import type { + AcceptBudgetInvitationEndpoint, + CreateBudgetEndpoint, + DeleteBudgetEndpoint, + InviteBudgetMemberEndpoint, + ListBudgetAccessEndpoint, + 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, query }, + { db } +) => { + return listBudgets(db, principal.userId, query.status); +}; + +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 deleteBudgetHandler: Handler = async ( + { params, principal }, + { db } +) => { + try { + await deleteBudget(db, principal.userId, params.id); + return ActionResult.noContent(); + } 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 listBudgetAccessHandler: Handler< + typeof ListBudgetAccessEndpoint +> = async ({ params, principal }, { db }) => { + try { + return await listBudgetAccess(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, + body.name + ); + } 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..b3c7e3e 100644 --- a/apps/api/src/api/handlers/index.ts +++ b/apps/api/src/api/handlers/index.ts @@ -5,6 +5,7 @@ import { } from './api-keys.js'; import { confirmEmailHandler, + deleteUserAvatarHandler, getMeHandler, googleSignInHandler, loginHandler, @@ -14,8 +15,22 @@ import { resendEmailConfirmationHandler, sessionTokenHandler, singleUserSessionTokenHandler, - updatePreferencesHandler + updatePreferencesHandler, + updateUserAvatarHandler, + userAvatarImageHandler } from './auth.js'; +import { + acceptBudgetInvitationHandler, + createBudgetHandler, + deleteBudgetHandler, + inviteBudgetMemberHandler, + listBudgetAccessHandler, + listBudgetMembersHandler, + listBudgetsHandler, + removeBudgetMemberHandler, + updateBudgetHandler, + updateBudgetMemberHandler +} from './budgets.js'; import { createCategoryHandler, deleteCategoryHandler, @@ -87,12 +102,27 @@ export const handlers = { telegramStatus: telegramStatusHandler, createTelegramLinkToken: createTelegramLinkTokenHandler, disconnectTelegram: disconnectTelegramHandler, + updateAvatar: updateUserAvatarHandler, + deleteAvatar: deleteUserAvatarHandler, + avatarImage: userAvatarImageHandler, listApiKeys: listApiKeysHandler, createApiKey: createApiKeyHandler, revokeApiKey: revokeApiKeyHandler, listMcpOAuthConnections: listMcpOAuthConnectionsHandler, revokeMcpOAuthConnection: revokeMcpOAuthConnectionHandler }, + budgets: { + list: listBudgetsHandler, + create: createBudgetHandler, + update: updateBudgetHandler, + delete: deleteBudgetHandler, + members: listBudgetMembersHandler, + access: listBudgetAccessHandler, + 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.test.ts b/apps/api/src/application/budgets.test.ts new file mode 100644 index 0000000..7452e22 --- /dev/null +++ b/apps/api/src/application/budgets.test.ts @@ -0,0 +1,733 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { Config } from '../config.js'; +import type { + AppDb, + BudgetDb, + BudgetInvitationDb, + BudgetMemberDb, + UserDb +} from '../db/schemas.js'; +import { + acceptBudgetInvitation, + adminBudgetPermissions, + BudgetAccessError, + BudgetInvitationInvalidError, + BudgetMemberError, + defaultMemberBudgetPermissions, + deleteBudget, + hashBudgetInvitationToken, + inviteBudgetMember, + listBudgetAccess, + listBudgets, + resolveBudgetAccess, + updateBudget +} from './budgets.js'; + +const sendEmailMock = vi.hoisted(() => vi.fn()); + +vi.mock('./email.js', () => ({ + sendEmail: sendEmailMock +})); + +const timestamp = new Date('2026-06-01T00:00:00.000Z'); +const config = { + app: { + url: 'https://app.example.com' + } +} as Config; + +class TestQuery implements PromiseLike { + constructor( + private readonly source: T[], + private readonly rows: T[] = source, + private readonly onDelete?: (rows: readonly T[]) => void + ) {} + + where(selector: (row: T) => TValue, value: TValue): TestQuery { + return new TestQuery( + this.source, + this.rows.filter(row => selector(row) === value), + this.onDelete + ); + } + + first(): Promise { + return Promise.resolve(this.rows[0]); + } + + update(update: Partial): Promise { + for (const row of this.rows) { + Object.assign(row, update); + } + return Promise.resolve(this.rows); + } + + delete(): Promise { + let deleted = 0; + for (const row of this.rows) { + const index = this.source.indexOf(row); + if (index >= 0) { + this.source.splice(index, 1); + deleted += 1; + } + } + this.onDelete?.(this.rows); + return Promise.resolve(deleted); + } + + // biome-ignore lint/suspicious/noThenProperty: this fake emulates the DB collection promise API. + then( + onfulfilled?: ((value: T[]) => TResult1 | PromiseLike) | null, + onrejected?: + | ((reason: unknown) => TResult2 | PromiseLike) + | null + ): PromiseLike { + return Promise.resolve(this.rows).then(onfulfilled, onrejected); + } +} + +type TestData = { + readonly users: UserDb[]; + readonly budgets: BudgetDb[]; + readonly members: BudgetMemberDb[]; + readonly invitations: BudgetInvitationDb[]; + readonly budgetFavoriteCurrencies: Array<{ + readonly budgetId: number; + readonly currency: string; + }>; + nextBudgetId: number; + nextInvitationId: number; +}; + +class BudgetListQuery { + private userId?: number; + private budgetId?: number; + private archived: 'active' | 'archived' | 'all' = 'all'; + private mainBudgetId = 0; + private displayName?: string; + private excludingBudgetId?: number; + private joinedUsers = false; + + constructor(private readonly data: TestData) {} + + join(table?: string): BudgetListQuery { + if (table === 'users as user') { + this.joinedUsers = true; + } + return this; + } + + where(column: string, value: unknown): BudgetListQuery { + if (column === 'member.user_id') { + this.userId = Number(value); + } + if (column === 'member.budget_id') { + this.budgetId = Number(value); + } + return this; + } + + whereNull(column: string): BudgetListQuery { + if (column === 'budget.archived_at') { + this.archived = 'active'; + } + return this; + } + + whereNotNull(column: string): BudgetListQuery { + if (column === 'budget.archived_at') { + this.archived = 'archived'; + } + return this; + } + + orderByRaw(_sql: string, values: readonly unknown[]): BudgetListQuery { + this.mainBudgetId = Number(values[0] ?? 0); + return this; + } + + orderBy(): BudgetListQuery { + return this; + } + + whereRaw(_sql: string, values: readonly unknown[]): BudgetListQuery { + this.displayName = String(values[0] ?? '').toLowerCase(); + return this; + } + + whereNot(column: string, value: unknown): BudgetListQuery { + if (column === 'member.budget_id') { + this.excludingBudgetId = Number(value); + } + return this; + } + + private rows() { + return this.data.members + .filter(member => { + if ( + this.userId !== undefined && + member.userId !== this.userId + ) { + return false; + } + if ( + this.budgetId !== undefined && + member.budgetId !== this.budgetId + ) { + return false; + } + return true; + }) + .flatMap(member => { + const budget = this.data.budgets.find( + item => item.id === member.budgetId + ); + if (!budget) { + return []; + } + if (this.archived === 'active' && budget.archivedAt) { + return []; + } + if (this.archived === 'archived' && !budget.archivedAt) { + return []; + } + if ( + this.excludingBudgetId !== undefined && + member.budgetId === this.excludingBudgetId + ) { + return []; + } + if ( + this.displayName !== undefined && + member.displayName.toLowerCase() !== this.displayName + ) { + return []; + } + return [{ budget, member }]; + }) + .sort((left, right) => { + if (left.budget.id === this.mainBudgetId) { + return -1; + } + if (right.budget.id === this.mainBudgetId) { + return 1; + } + return left.member.displayName.localeCompare( + right.member.displayName + ); + }) + .map(({ budget, member }) => ({ + budgetId: budget.id, + name: budget.name, + defaultCurrency: budget.defaultCurrency, + countryCode: budget.countryCode, + createdByUserId: budget.createdByUserId ?? null, + archivedAt: budget.archivedAt ?? null, + budgetCreatedAt: budget.createdAt, + budgetUpdatedAt: budget.updatedAt, + userId: member.userId, + displayName: member.displayName, + role: member.role, + canCreateTransactions: member.canCreateTransactions, + canUpdateTransactions: member.canUpdateTransactions, + canDeleteTransactions: member.canDeleteTransactions, + canManageCategories: member.canManageCategories, + canManageVendors: member.canManageVendors, + canManageTags: member.canManageTags, + canManageMembers: member.canManageMembers, + memberCreatedAt: member.createdAt, + memberUpdatedAt: member.updatedAt + })); + } + + private memberRows() { + return this.data.members + .filter(member => { + if ( + this.userId !== undefined && + member.userId !== this.userId + ) { + return false; + } + if ( + this.budgetId !== undefined && + member.budgetId !== this.budgetId + ) { + return false; + } + return true; + }) + .map(member => { + const user = this.data.users.find( + item => item.id === member.userId + ); + return { + budgetId: member.budgetId, + userId: member.userId, + email: user?.email ?? '', + avatarUrl: user?.avatarUrl ?? null, + avatarImageMimeType: user?.avatarImageMimeType ?? null, + avatarImageFileName: user?.avatarImageFileName ?? null, + avatarImageUpdatedAt: user?.avatarImageUpdatedAt ?? null, + displayName: member.displayName, + role: member.role, + canCreateTransactions: member.canCreateTransactions, + canUpdateTransactions: member.canUpdateTransactions, + canDeleteTransactions: member.canDeleteTransactions, + canManageCategories: member.canManageCategories, + canManageVendors: member.canManageVendors, + canManageTags: member.canManageTags, + canManageMembers: member.canManageMembers, + createdAt: member.createdAt, + updatedAt: member.updatedAt + }; + }) + .sort((left, right) => left.email.localeCompare(right.email)); + } + + first(): Promise { + return Promise.resolve(this.rows()[0]); + } + + select(): Promise { + if (this.joinedUsers) { + return Promise.resolve(this.memberRows()); + } + return Promise.resolve(this.rows()); + } +} + +function user( + id: number, + email: string, + overrides: Partial = {} +): UserDb { + return { + id, + email, + passwordHash: 'hash', + emailVerified: true, + emailVerificationTokenHash: undefined, + emailVerificationExpiresAt: undefined, + role: 'user', + authProvider: 'local', + defaultCurrency: 'USD', + countryCode: 'US', + timezone: 'UTC', + mainBudgetId: 1, + weeklyEmailReportEnabled: true, + monthlyEmailReportEnabled: true, + createdAt: timestamp, + updatedAt: timestamp, + ...overrides + } as UserDb; +} + +function budget( + id: number, + name: string, + overrides: Partial = {} +): BudgetDb { + return { + id, + name, + defaultCurrency: 'USD', + countryCode: 'US', + createdByUserId: 1, + archivedAt: null, + createdAt: timestamp, + updatedAt: timestamp, + ...overrides + } as BudgetDb; +} + +function member( + budgetId: number, + userId: number, + role: 'admin' | 'member' = 'admin', + overrides: Partial = {} +): BudgetMemberDb { + const permissions = + role === 'admin' + ? adminBudgetPermissions + : defaultMemberBudgetPermissions; + return { + budgetId, + userId, + displayName: 'Main', + role, + canCreateTransactions: permissions.canCreateTransactions, + canUpdateTransactions: permissions.canUpdateTransactions, + canDeleteTransactions: permissions.canDeleteTransactions, + canManageCategories: permissions.canManageCategories, + canManageVendors: permissions.canManageVendors, + canManageTags: permissions.canManageTags, + canManageMembers: permissions.canManageMembers, + createdAt: timestamp, + updatedAt: timestamp, + ...overrides + }; +} + +function invitation( + budgetId: number, + email: string, + token: string, + overrides: Partial = {} +): BudgetInvitationDb { + return { + id: 1, + budgetId, + invitedByUserId: 1, + email, + role: 'member', + canCreateTransactions: true, + canUpdateTransactions: false, + canDeleteTransactions: false, + canManageCategories: false, + canManageVendors: false, + canManageTags: false, + canManageMembers: false, + tokenHash: hashBudgetInvitationToken(token), + expiresAt: new Date('2026-06-08T00:00:00.000Z'), + consumedAt: undefined, + createdAt: timestamp, + updatedAt: timestamp, + ...overrides + } as BudgetInvitationDb; +} + +function makeDb(overrides: Partial = {}) { + const data: TestData = { + users: [ + user(1, 'owner@example.com'), + user(2, 'member@example.com', { mainBudgetId: undefined }) + ], + budgets: [ + budget(1, 'Main'), + budget(2, 'Travel'), + budget(3, 'Old budget', { + archivedAt: new Date('2026-05-01T00:00:00.000Z') + }) + ], + members: [ + member(1, 1, 'admin', { displayName: 'Main' }), + member(2, 1, 'admin', { displayName: 'Travel' }), + member(3, 1, 'admin', { displayName: 'Old budget' }) + ], + invitations: [], + budgetFavoriteCurrencies: [], + nextBudgetId: 10, + nextInvitationId: 10, + ...overrides + }; + + const db = {} as AppDb; + Object.assign(db, { + users: { + find: async (id: number) => data.users.find(item => item.id === id), + where: (selector: (row: UserDb) => TValue, value: TValue) => + new TestQuery(data.users).where(selector, value) + }, + budgets: { + find: async (id: number) => + data.budgets.find(item => item.id === id), + insert: async (values: Partial) => { + const row = budget( + data.nextBudgetId++, + values.name ?? 'Budget', + { + ...values, + id: data.nextBudgetId - 1 + } + ); + data.budgets.push(row); + return row; + }, + where: ( + selector: (row: BudgetDb) => TValue, + value: TValue + ) => new TestQuery(data.budgets).where(selector, value) + }, + budgetMembers: { + insert: async (values: Partial) => { + const row = member( + values.budgetId ?? 0, + values.userId ?? 0, + values.role === 'member' ? 'member' : 'admin', + values + ); + data.members.push(row); + return row; + }, + where: ( + selector: (row: BudgetMemberDb) => TValue, + value: TValue + ) => new TestQuery(data.members).where(selector, value) + }, + budgetFavoriteCurrencies: { + where: ( + selector: ( + row: TestData['budgetFavoriteCurrencies'][number] + ) => TValue, + value: TValue + ) => + new TestQuery(data.budgetFavoriteCurrencies).where( + selector, + value + ), + insertMany: async ( + values: TestData['budgetFavoriteCurrencies'] + ) => { + data.budgetFavoriteCurrencies.push(...values); + return values; + } + }, + budgetInvitations: { + insert: async (values: Partial) => { + const row = { + ...invitation( + values.budgetId ?? 0, + values.email ?? '', + 'generated-token', + values + ), + id: data.nextInvitationId++ + } as BudgetInvitationDb; + data.invitations.push(row); + return row; + }, + where: ( + selector: (row: BudgetInvitationDb) => TValue, + value: TValue + ) => new TestQuery(data.invitations).where(selector, value) + }, + knex: (table: string) => { + if (table === 'budget_members as member') { + return new BudgetListQuery(data); + } + if (table === 'budget_members') { + return { + insert: (row: Record) => ({ + onConflict: () => ({ + merge: async () => { + const budgetId = Number(row.budget_id); + const userId = Number(row.user_id); + const next = member( + budgetId, + userId, + row.role === 'admin' ? 'admin' : 'member', + { + displayName: String( + row.display_name ?? 'Shared budget' + ), + canCreateTransactions: Boolean( + row.can_create_transactions + ), + canUpdateTransactions: Boolean( + row.can_update_transactions + ), + canDeleteTransactions: Boolean( + row.can_delete_transactions + ), + canManageCategories: Boolean( + row.can_manage_categories + ), + canManageVendors: Boolean( + row.can_manage_vendors + ), + canManageTags: Boolean( + row.can_manage_tags + ), + canManageMembers: Boolean( + row.can_manage_members + ) + } + ); + const existing = data.members.find( + item => + item.budgetId === budgetId && + item.userId === userId + ); + if (existing) { + Object.assign(existing, next); + } else { + data.members.push(next); + } + } + }) + }) + }; + } + if (table === 'transactions') { + const query = { + whereIn: () => query, + orderBy: () => query, + select: async () => [] + }; + return query; + } + throw new Error(`Unexpected table ${table}`); + }, + transaction: async (callback: (trx: AppDb) => Promise) => + callback(db) + }); + + return { data, db }; +} + +afterEach(() => { + vi.useRealTimers(); + sendEmailMock.mockReset(); +}); + +describe('budget lifecycle', () => { + it('lists active, archived, and all budgets separately', async () => { + const { db } = makeDb(); + + await expect(listBudgets(db, 1)).resolves.toMatchObject([ + { name: 'Main', archivedAt: null }, + { name: 'Travel', archivedAt: null } + ]); + await expect(listBudgets(db, 1, 'archived')).resolves.toMatchObject([ + { name: 'Old budget', archivedAt: expect.any(Date) } + ]); + await expect(listBudgets(db, 1, 'all')).resolves.toMatchObject([ + { name: 'Main' }, + { name: 'Old budget' }, + { name: 'Travel' } + ]); + }); + + it('renames, archives, and restores non-main budgets', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-06-02T00:00:00.000Z')); + const { db } = makeDb(); + + const renamed = await updateBudget(db, 1, 2, { + defaultCurrency: 'eur', + name: ' Shared travel ' + }); + + expect(renamed.name).toBe('Shared travel'); + expect(renamed.defaultCurrency).toBe('EUR'); + + const archived = await updateBudget(db, 1, 2, { archived: true }); + + expect(archived.archivedAt).toEqual( + new Date('2026-06-02T00:00:00.000Z') + ); + await expect(resolveBudgetAccess(db, 1, 2)).rejects.toBeInstanceOf( + BudgetAccessError + ); + + const restored = await updateBudget(db, 1, 2, { archived: false }); + + expect(restored.archivedAt).toBeNull(); + await expect(resolveBudgetAccess(db, 1, 2)).resolves.toBeTruthy(); + }); + + it('does not archive the main budget or delete active budgets', async () => { + const { db } = makeDb(); + + await expect( + updateBudget(db, 1, 1, { archived: true }) + ).rejects.toBeInstanceOf(BudgetMemberError); + await expect(deleteBudget(db, 1, 1)).rejects.toBeInstanceOf( + BudgetMemberError + ); + await expect(deleteBudget(db, 1, 2)).rejects.toBeInstanceOf( + BudgetMemberError + ); + }); + + it('deletes archived non-main budgets permanently', async () => { + const { data, db } = makeDb(); + + await deleteBudget(db, 1, 3); + + expect(data.budgets.map(item => item.id)).toEqual([1, 2]); + }); +}); + +describe('budget invitations', () => { + it('lists active members and invitation statuses for admin access management', async () => { + const { db } = makeDb({ + invitations: [ + invitation(2, 'pending@example.com', 'join-token', { + expiresAt: new Date('2026-07-14T00:00:00.000Z'), + id: 7 + }) + ], + members: [ + member(1, 1, 'admin', { displayName: 'Main' }), + member(2, 1, 'admin', { displayName: 'Travel' }), + member(2, 2, 'member', { displayName: 'Shared travel' }) + ] + }); + + const rows = await listBudgetAccess(db, 1, 2); + + expect(rows).toMatchObject([ + { + status: 'active', + email: 'member@example.com', + user: { + displayName: 'Shared travel', + email: 'member@example.com', + userId: 2 + } + }, + { + status: 'active', + email: 'owner@example.com', + role: 'admin', + user: { + displayName: 'Travel', + email: 'owner@example.com', + userId: 1 + } + }, + { + status: 'pending', + email: 'pending@example.com', + invitationId: 7 + } + ]); + }); + + it('invites another user to Main without requiring a shared rename', async () => { + const { data, db } = makeDb(); + + await expect( + inviteBudgetMember(db, config, 1, 1, { + email: 'member@example.com', + role: 'member' + }) + ).resolves.toEqual({ + message: + 'If that email belongs to an xpenser user, a budget invitation has been sent.' + }); + expect(data.invitations).toHaveLength(1); + expect(sendEmailMock).toHaveBeenCalledOnce(); + }); + + it('rejects archived budget invitations before adding membership', async () => { + const { data, db } = makeDb({ + invitations: [ + invitation(3, 'member@example.com', 'join-token', { + id: 7 + }) + ] + }); + + await expect( + acceptBudgetInvitation(db, 2, 'join-token', 'Shared travel') + ).rejects.toBeInstanceOf(BudgetInvitationInvalidError); + expect( + data.members.some(item => item.budgetId === 3 && item.userId === 2) + ).toBe(false); + expect(data.invitations[0]?.consumedAt).toBeUndefined(); + }); +}); diff --git a/apps/api/src/application/budgets.ts b/apps/api/src/application/budgets.ts new file mode 100644 index 0000000..502fe86 --- /dev/null +++ b/apps/api/src/application/budgets.ts @@ -0,0 +1,1212 @@ +import { createHash, randomBytes } from 'node:crypto'; +import type { + Budget, + BudgetAccessRow, + BudgetMember, + BudgetPermissions, + BudgetRole, + CreateBudgetBody, + InviteBudgetMemberBody, + UpdateBudgetBody, + UpdateBudgetMemberBody +} from '@xpenser/contracts'; +import type { Config } from '../config.js'; +import type { + AppDb, + BudgetDb, + BudgetInvitationDb, + BudgetMemberDb, + TransactionDb, + UserDb +} from '../db/schemas.js'; +import { sendEmail } from './email.js'; +import { mapUserAvatarSummary } from './user-avatars.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; +}; + +export type BudgetListStatus = 'active' | 'archived' | 'all'; + +const invitationTtlMs = 7 * 24 * 60 * 60 * 1000; +const budgetInvitationMessage = + 'If that email belongs to an xpenser user, a budget invitation has been sent.'; +const mainBudgetName = 'Main'; + +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 normalizeCurrencyList( + values: readonly string[] | undefined, + defaultCurrency: string +): string[] { + const normalizedDefault = defaultCurrency.trim().toUpperCase(); + const result: string[] = []; + const seen = new Set(); + for (const value of values ?? []) { + const currency = value.trim().toUpperCase(); + if ( + !/^[A-Z]{3}$/.test(currency) || + currency === normalizedDefault || + seen.has(currency) + ) { + continue; + } + seen.add(currency); + result.push(currency); + } + return result; +} + +async function setBudgetFavoriteCurrencies( + db: AppDb, + budgetId: number, + currencies: readonly string[], + defaultCurrency: string +): Promise { + const normalized = normalizeCurrencyList(currencies, defaultCurrency); + await db.budgetFavoriteCurrencies + .where(currency => currency.budgetId, budgetId) + .delete(); + if (normalized.length > 0) { + await db.budgetFavoriteCurrencies.insertMany( + normalized.map(currency => ({ budgetId, currency })) + ); + } +} + +async function loadBudgetFavoriteCurrencies( + db: AppDb, + budgetIds: readonly number[] +): Promise> { + if (budgetIds.length === 0) { + return new Map(); + } + + const byBudget = new Map(); + await Promise.all( + Array.from(new Set(budgetIds)).map(async budgetId => { + const rows = await db.budgetFavoriteCurrencies.where( + currency => currency.budgetId, + budgetId + ); + byBudget.set( + budgetId, + rows + .map(row => row.currency.trim().toUpperCase()) + .sort((left, right) => left.localeCompare(right)) + ); + }) + ); + return byBudget; +} + +async function loadBudgetFavoriteCurrencyList( + db: AppDb, + budgetId: number +): Promise { + const values = await loadBudgetFavoriteCurrencies(db, [budgetId]); + return [...(values.get(budgetId) ?? [])]; +} + +function transactionCurrenciesByRecentPopularity( + currencies: readonly string[], + recentTransactions: readonly Pick[] +): string[] { + const configuredOrder = new Map(); + const configuredCurrencies: string[] = []; + + for (const currency of currencies) { + const normalized = currency.trim().toUpperCase(); + if (!configuredOrder.has(normalized)) { + configuredOrder.set(normalized, configuredCurrencies.length); + configuredCurrencies.push(normalized); + } + } + + const usage = new Map(); + recentTransactions.forEach((transaction, index) => { + const currency = transaction.currency.trim().toUpperCase(); + if (currency === '') { + return; + } + const current = usage.get(currency); + if (current) { + current.count += 1; + } else { + usage.set(currency, { count: 1, latestIndex: index }); + } + }); + + return Array.from(new Set([...usage.keys(), ...configuredCurrencies])).sort( + (left, right) => { + const leftUsage = usage.get(left); + const rightUsage = usage.get(right); + const usageDelta = + (rightUsage?.count ?? 0) - (leftUsage?.count ?? 0); + if (usageDelta !== 0) { + return usageDelta; + } + if ( + leftUsage && + rightUsage && + leftUsage.latestIndex !== rightUsage.latestIndex + ) { + return leftUsage.latestIndex - rightUsage.latestIndex; + } + if (leftUsage && !rightUsage) { + return -1; + } + if (!leftUsage && rightUsage) { + return 1; + } + return ( + (configuredOrder.get(left) ?? configuredCurrencies.length) - + (configuredOrder.get(right) ?? configuredCurrencies.length) + ); + } + ); +} + +async function loadRecentTransactionsByBudget( + db: AppDb, + budgetIds: readonly number[] +): Promise[]>> { + if (budgetIds.length === 0) { + return new Map(); + } + const rows = (await db + .knex('transactions') + .whereIn('budget_id', budgetIds) + .orderBy('occurred_at', 'desc') + .orderBy('id', 'desc') + .select({ + budgetId: 'budget_id', + currency: 'currency' + })) as Array<{ readonly budgetId: number; readonly currency: string }>; + const byBudget = new Map>>(); + for (const row of rows) { + const values = byBudget.get(row.budgetId) ?? []; + if (values.length >= 10) { + continue; + } + values.push({ currency: row.currency }); + byBudget.set(row.budgetId, values); + } + return byBudget; +} + +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 + }; +} + +async function ensureUniqueActiveBudgetDisplayName( + db: AppDb, + userId: number, + name: string, + excludingBudgetId?: number +): Promise { + const query = db + .knex('budget_members as member') + .join('budgets as budget', 'budget.id', 'member.budget_id') + .where('member.user_id', userId) + .whereNull('budget.archived_at') + .whereRaw('lower(member.display_name) = lower(?)', [name]); + if (excludingBudgetId) { + query.whereNot('member.budget_id', excludingBudgetId); + } + const existing = await query.first('member.budget_id'); + if (existing) { + throw new BudgetMemberError( + 'You already have an active budget with this name.' + ); + } +} + +function mapBudget( + budget: BudgetDb, + member: BudgetMemberDb, + mainBudgetId: number | null | undefined, + favoriteCurrencies: readonly string[] = [], + transactionCurrencies: readonly string[] = [] +): Budget { + return { + id: budget.id, + name: member.displayName || budget.name, + defaultCurrency: budget.defaultCurrency, + favoriteCurrencies: [...favoriteCurrencies], + transactionCurrencies: [...transactionCurrencies], + countryCode: normalizeCountryCode(budget.countryCode), + role: member.role === 'admin' ? 'admin' : 'member', + permissions: memberPermissions(member), + isMain: budget.id === mainBudgetId, + archivedAt: budget.archivedAt ?? null, + createdAt: budget.createdAt, + updatedAt: budget.updatedAt + }; +} + +type BudgetMemberRow = BudgetMemberDb & { + readonly avatarImageFileName?: string | null; + readonly avatarImageMimeType?: string | null; + readonly avatarImageUpdatedAt?: Date | null; + readonly avatarUrl?: string | null; + readonly email: string; +}; + +function mapBudgetMember(row: BudgetMemberRow): BudgetMember { + const user = mapUserAvatarSummary( + { + id: row.userId, + email: row.email, + avatarUrl: row.avatarUrl ?? undefined, + avatarImageMimeType: row.avatarImageMimeType ?? undefined, + avatarImageFileName: row.avatarImageFileName ?? undefined, + avatarImageUpdatedAt: row.avatarImageUpdatedAt ?? undefined + }, + row.displayName + ); + return { + budgetId: row.budgetId, + userId: row.userId, + email: row.email, + user, + 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, + archivedAt: null + })) as BudgetDb; + + await trx.budgetMembers.insert({ + budgetId: created.id, + userId, + displayName: mainBudgetName, + ...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, + favoriteCurrencies: readonly string[] = [] +): Promise { + const budget = (await db.budgets.insert({ + name: 'Main', + defaultCurrency: user.defaultCurrency, + countryCode: normalizeCountryCode(user.countryCode), + createdByUserId: user.id, + archivedAt: null + })) as BudgetDb; + + await db.budgetMembers.insert({ + budgetId: budget.id, + userId: user.id, + displayName: mainBudgetName, + ...budgetMemberValues('admin') + }); + await setBudgetFavoriteCurrencies( + db, + budget.id, + favoriteCurrencies, + budget.defaultCurrency + ); + 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, + options: { readonly allowArchived?: boolean } = {} +): 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.'); + } + if ((budget as BudgetDb).archivedAt && !options.allowArchived) { + throw new BudgetAccessError('Budget was archived.'); + } + + 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, + status: BudgetListStatus = 'active' +): Promise { + await ensureMainBudget(db, userId); + const user = (await db.users.find(userId)) as UserDb | undefined; + const query = db + .knex('budget_members as member') + .join('budgets as budget', 'budget.id', 'member.budget_id') + .where('member.user_id', userId); + if (status === 'active') { + query.whereNull('budget.archived_at'); + } else if (status === 'archived') { + query.whereNotNull('budget.archived_at'); + } + const rows = (await query + .orderByRaw('case when budget.id = ? then 0 else 1 end', [ + user?.mainBudgetId ?? 0 + ]) + .orderBy('member.display_name', 'asc') + .select({ + budgetId: 'budget.id', + name: 'budget.name', + defaultCurrency: 'budget.default_currency', + countryCode: 'budget.country_code', + createdByUserId: 'budget.created_by_user_id', + archivedAt: 'budget.archived_at', + budgetCreatedAt: 'budget.created_at', + budgetUpdatedAt: 'budget.updated_at', + userId: 'member.user_id', + displayName: 'member.display_name', + 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 archivedAt: Date | null; + readonly budgetCreatedAt: Date; + readonly budgetUpdatedAt: Date; + readonly userId: number; + readonly displayName: string; + 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; + }>; + + const budgetIds = rows.map(row => Number(row.budgetId)); + const [favoritesByBudget, recentTransactionsByBudget] = await Promise.all([ + loadBudgetFavoriteCurrencies(db, budgetIds), + loadRecentTransactionsByBudget(db, budgetIds) + ]); + + return rows.map(row => { + const budgetId = Number(row.budgetId); + const favorites = favoritesByBudget.get(budgetId) ?? []; + const transactionCurrencies = transactionCurrenciesByRecentPopularity( + [row.defaultCurrency, ...favorites], + recentTransactionsByBudget.get(budgetId) ?? [] + ); + return mapBudget( + { + id: budgetId, + name: row.name, + defaultCurrency: row.defaultCurrency, + countryCode: row.countryCode, + createdByUserId: row.createdByUserId ?? undefined, + archivedAt: row.archivedAt ?? undefined, + createdAt: row.budgetCreatedAt, + updatedAt: row.budgetUpdatedAt + }, + { + budgetId, + userId: row.userId, + displayName: row.displayName, + 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, + favorites, + transactionCurrencies + ); + }); +} + +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.'); + } + await ensureUniqueActiveBudgetDisplayName(db, userId, name); + + 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, + archivedAt: null + })) as BudgetDb; + await trx.budgetMembers.insert({ + budgetId: created.id, + userId, + displayName: name, + ...budgetMemberValues('admin') + }); + await setBudgetFavoriteCurrencies( + trx, + created.id, + body.favoriteCurrencies, + created.defaultCurrency + ); + return created; + }); + + const access = await resolveBudgetAccess(db, userId, budget.id); + const favorites = await loadBudgetFavoriteCurrencyList(db, budget.id); + return mapBudget( + budget, + access.member, + user.mainBudgetId, + favorites, + transactionCurrenciesByRecentPopularity( + [budget.defaultCurrency, ...favorites], + [] + ) + ); +} + +export async function updateBudget( + db: AppDb, + userId: number, + budgetId: number, + body: UpdateBudgetBody +): Promise { + const access = await resolveBudgetAccess(db, userId, budgetId, { + allowArchived: true + }); + const user = (await db.users.find(userId)) as UserDb | undefined; + const isMain = access.budget.id === user?.mainBudgetId; + const sharedSettingsChanged = + body.defaultCurrency !== undefined || + body.favoriteCurrencies !== undefined || + body.countryCode !== undefined || + body.archived !== undefined; + if (sharedSettingsChanged) { + requireBudgetPermission(access, 'canManageMembers'); + } + + const update: { + archivedAt?: Date | null; + countryCode?: string; + defaultCurrency?: string; + updatedAt: Date; + } = { updatedAt: new Date() }; + let nextDisplayName = access.member.displayName || access.budget.name; + if (body.name !== undefined) { + const name = normalizedBudgetName(body.name); + if (!name) { + throw new BudgetMemberError('Budget name is required.'); + } + nextDisplayName = name; + } + if (body.defaultCurrency !== undefined) { + update.defaultCurrency = normalizeCurrency( + body.defaultCurrency, + access.budget.defaultCurrency + ); + } + if (body.countryCode !== undefined) { + update.countryCode = normalizeCountryCode(body.countryCode); + } + if (body.archived !== undefined) { + if (isMain && body.archived) { + throw new BudgetMemberError('Main budget cannot be archived.'); + } + update.archivedAt = body.archived ? new Date() : null; + } + + const willBeArchived = + update.archivedAt !== undefined + ? update.archivedAt + : access.budget.archivedAt; + if ( + !willBeArchived && + (body.name !== undefined || body.archived === false) + ) { + await ensureUniqueActiveBudgetDisplayName( + db, + userId, + nextDisplayName, + budgetId + ); + } + + const updated = await db.transaction(async trx => { + let budget = access.budget; + let member = access.member; + if (body.name !== undefined) { + const [updatedMember] = (await trx.budgetMembers + .where(row => row.budgetId, budgetId) + .where(row => row.userId, userId) + .update({ + displayName: nextDisplayName, + updatedAt: new Date() + } as never)) as BudgetMemberDb[]; + if (!updatedMember) { + throw new BudgetNotFoundError('Budget member was not found.'); + } + member = updatedMember; + } + if (sharedSettingsChanged) { + const [updatedBudget] = (await trx.budgets + .where(row => row.id, budgetId) + .update(update as never)) as BudgetDb[]; + if (!updatedBudget) { + throw new BudgetNotFoundError('Budget was not found.'); + } + budget = updatedBudget; + } + if (body.favoriteCurrencies !== undefined) { + await setBudgetFavoriteCurrencies( + trx, + budgetId, + body.favoriteCurrencies, + budget.defaultCurrency + ); + } else if (body.defaultCurrency !== undefined) { + const favorites = await loadBudgetFavoriteCurrencyList( + trx, + budgetId + ); + await setBudgetFavoriteCurrencies( + trx, + budgetId, + favorites, + budget.defaultCurrency + ); + } + return { budget, member }; + }); + + const [favorites, recentTransactions] = await Promise.all([ + loadBudgetFavoriteCurrencyList(db, budgetId), + loadRecentTransactionsByBudget(db, [budgetId]) + ]); + return mapBudget( + updated.budget, + updated.member, + user?.mainBudgetId, + favorites, + transactionCurrenciesByRecentPopularity( + [updated.budget.defaultCurrency, ...favorites], + recentTransactions.get(budgetId) ?? [] + ) + ); +} + +export async function deleteBudget( + db: AppDb, + userId: number, + budgetId: number +): Promise { + const access = await resolveBudgetAccess(db, userId, budgetId, { + allowArchived: true + }); + requireBudgetPermission(access, 'canManageMembers'); + const user = (await db.users.find(userId)) as UserDb | undefined; + if (access.budget.id === user?.mainBudgetId) { + throw new BudgetMemberError('Main budget cannot be deleted.'); + } + if (!access.budget.archivedAt) { + throw new BudgetMemberError( + 'Archive the budget before deleting it permanently.' + ); + } + + const deleted = await db.budgets.where(row => row.id, budgetId).delete(); + if (deleted === 0) { + throw new BudgetNotFoundError('Budget was not found.'); + } +} + +export async function listBudgetMembers( + db: AppDb, + userId: number, + budgetId: number, + options: { readonly allowArchived?: boolean } = {} +): Promise { + const access = await resolveBudgetAccess(db, userId, budgetId, options); + 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', + avatarUrl: 'user.avatar_url', + avatarImageMimeType: 'user.avatar_image_mime_type', + avatarImageFileName: 'user.avatar_image_file_name', + avatarImageUpdatedAt: 'user.avatar_image_updated_at', + displayName: 'member.display_name', + 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 BudgetMemberRow[]; + + return rows.map(mapBudgetMember); +} + +function invitationAccessStatus( + invitation: Pick, + now: Date +): 'accepted' | 'expired' | 'pending' { + if (invitation.consumedAt) { + return 'accepted'; + } + if (invitation.expiresAt.getTime() <= now.getTime()) { + return 'expired'; + } + return 'pending'; +} + +function mapInvitationAccessRow( + invitation: BudgetInvitationDb, + now: Date +): BudgetAccessRow { + return { + status: invitationAccessStatus(invitation, now), + invitationId: invitation.id, + budgetId: invitation.budgetId, + email: invitation.email, + role: invitation.role === 'admin' ? 'admin' : 'member', + permissions: invitationPermissions(invitation), + expiresAt: invitation.expiresAt, + consumedAt: invitation.consumedAt ?? null, + createdAt: invitation.createdAt, + updatedAt: invitation.updatedAt + }; +} + +export async function listBudgetAccess( + db: AppDb, + userId: number, + budgetId: number +): Promise { + const access = await resolveBudgetAccess(db, userId, budgetId, { + allowArchived: true + }); + requireBudgetPermission(access, 'canManageMembers'); + + const [members, invitations] = await Promise.all([ + listBudgetMembers(db, userId, budgetId, { allowArchived: true }), + db.budgetInvitations.where(invitation => invitation.budgetId, budgetId) + ]); + const now = new Date(); + return [ + ...members.map(member => ({ status: 'active' as const, ...member })), + ...(invitations as BudgetInvitationDb[]) + .map(invitation => mapInvitationAccessRow(invitation, now)) + .sort((left, right) => { + const statusDelta = left.status.localeCompare(right.status); + if (statusDelta !== 0) { + return statusDelta; + } + return left.email.localeCompare(right.email); + }) + ]; +} + +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, + budgetName: string, + token: string +): Promise { + const url = invitationLink(config, token); + const safeBudgetName = htmlEscape(budgetName); + const safeUrl = htmlEscape(url); + await sendEmail(config, { + to: email, + subject: `Join ${budgetName} on xpenser`, + text: `Open this magic link to join ${budgetName} 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.member.displayName || access.budget.name, + token + ); + + return { message: budgetInvitationMessage }; +} + +export async function acceptBudgetInvitation( + db: AppDb, + userId: number, + token: string, + name: 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.' + ); + } + const budget = (await db.budgets.find(invitation.budgetId)) as + | BudgetDb + | undefined; + if (!budget || budget.archivedAt) { + throw new BudgetInvitationInvalidError( + 'Budget invitation is invalid or expired.' + ); + } + const displayName = normalizedBudgetName(name); + if (!displayName) { + throw new BudgetInvitationInvalidError('Budget name is required.'); + } + await ensureUniqueActiveBudgetDisplayName(db, userId, displayName); + + await db.transaction(async trx => { + await trx + .knex('budget_members') + .insert({ + budget_id: invitation.budgetId, + user_id: userId, + display_name: displayName, + ...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); + const [favorites, recentTransactions] = await Promise.all([ + loadBudgetFavoriteCurrencyList(db, invitation.budgetId), + loadRecentTransactionsByBudget(db, [invitation.budgetId]) + ]); + return mapBudget( + access.budget, + access.member, + user.mainBudgetId, + favorites, + transactionCurrenciesByRecentPopularity( + [access.budget.defaultCurrency, ...favorites], + recentTransactions.get(invitation.budgetId) ?? [] + ) + ); +} diff --git a/apps/api/src/application/categories.test.ts b/apps/api/src/application/categories.test.ts index 5f2ef80..05f503c 100644 --- a/apps/api/src/application/categories.test.ts +++ b/apps/api/src/application/categories.test.ts @@ -9,6 +9,7 @@ import { categoryReportingType, deleteCategory, LastCategoryError, + listCategories, moveAndDeleteCategory } from './categories.js'; @@ -40,6 +41,7 @@ describe('category transaction availability', () => { const car: CategoryDb = { id: 1, userId: 1, + budgetId: 1, name: 'Car', type: 'expense', parentId: null, @@ -112,13 +114,54 @@ describe('category popularity ordering', () => { ) ).toEqual([4, 2, 1, 3]); }); + + it('uses normal category display order as the equal-count fallback', async () => { + const now = new Date(); + const zebra = categoryRow(1, 'Zebra'); + const alpha = categoryRow(2, 'Alpha'); + const coffee = categoryRow(3, 'Coffee'); + const transaction = ( + id: number, + categoryId: number + ): TestTransaction => ({ + id, + userId: 1, + budgetId: 1, + categoryId, + type: 'expense', + occurredAt: now, + updatedAt: now + }); + + const categories = await listCategories( + testDb( + [zebra, alpha, coffee], + [ + transaction(1, zebra.id), + transaction(2, alpha.id), + transaction(3, coffee.id), + transaction(4, coffee.id) + ] + ) as never, + 1, + { budgetId: 1, sort: 'recent-transaction-count' } + ); + + expect(categories.map(category => category.name)).toEqual([ + 'Coffee', + 'Alpha', + 'Zebra' + ]); + }); }); type TestTransaction = { id: number; userId: number; + budgetId: number; categoryId: number; type: 'expense' | 'income'; + occurredAt?: Date; updatedAt: Date; }; @@ -128,10 +171,30 @@ class TestQuery implements PromiseLike { private readonly rows: T[] = source ) {} - where(selector: (row: T) => TValue, value: TValue): TestQuery { + where( + selector: (row: T) => TValue, + valueOrOperator: TValue | '>=' | '<=', + maybeValue?: TValue + ): TestQuery { + if (valueOrOperator === '>=' || valueOrOperator === '<=') { + if (maybeValue == null) { + throw new Error('Comparison value is required.'); + } + return new TestQuery( + this.source, + this.rows.filter(row => { + const actual = selector(row); + const expected = maybeValue; + return valueOrOperator === '>=' + ? actual >= expected + : actual <= expected; + }) + ); + } + return new TestQuery( this.source, - this.rows.filter(row => selector(row) === value) + this.rows.filter(row => selector(row) === valueOrOperator) ); } @@ -180,6 +243,7 @@ function categoryRow( return { id, userId: 1, + budgetId: 1, name, type: 'expense', parentId: null, @@ -192,6 +256,12 @@ function categoryRow( } type TestDb = { + budgetMembers: { + where(): { where(): { first(): Promise } }; + }; + budgets: { + find(id: number): Promise; + }; categories: { where( selector: (row: CategoryDb) => TValue, @@ -211,7 +281,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 +363,7 @@ describe('move and delete category', () => { { id: 1, userId: 1, + budgetId: 1, categoryId: source.id, type: 'income' as const, updatedAt: source.updatedAt @@ -267,6 +371,7 @@ describe('move and delete category', () => { { id: 2, userId: 2, + budgetId: 2, categoryId: source.id, type: 'expense' as const, updatedAt: source.updatedAt @@ -301,6 +406,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..40cd99e 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[]; @@ -275,15 +293,16 @@ export async function listCategories( categoryAvailableForTransactions(category, categoriesById) ) : categoryRows; + const categoriesInDisplayOrder = [...filteredCategories].sort( + (left, right) => compareCategories(categoriesById, left, right) + ); const orderedCategories = query.sort === 'recent-transaction-count' ? categoriesByRecentTransactionCount( - filteredCategories, + categoriesInDisplayOrder, recentTransactions ) - : [...filteredCategories].sort((left, right) => - compareCategories(categoriesById, left, right) - ); + : categoriesInDisplayOrder; return orderedCategories.map(category => mapCategory( @@ -300,15 +319,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 +340,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 +357,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 +387,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 +415,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 +424,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 +450,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 +470,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 +487,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 +497,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 +513,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 +535,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 +550,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 +560,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/currencies.test.ts b/apps/api/src/application/currencies.test.ts index 59a4ecb..b3ede9a 100644 --- a/apps/api/src/application/currencies.test.ts +++ b/apps/api/src/application/currencies.test.ts @@ -81,7 +81,16 @@ describe('exchange rates', () => { }); }); - it('converts amounts to the user default currency', async () => { + it('converts amounts to the budget default currency', async () => { + const budgetMembers = { + where: vi.fn(() => budgetMembers), + first: vi.fn().mockResolvedValue({ + budgetId: 2, + userId: 1, + displayName: 'Travel', + role: 'admin' + }) + }; const db = { users: { find: vi.fn().mockResolvedValue({ @@ -90,6 +99,14 @@ describe('exchange rates', () => { timezone: 'UTC' }) }, + budgets: { + find: vi.fn().mockResolvedValue({ + id: 2, + defaultCurrency: 'GBP', + archivedAt: null + }) + }, + budgetMembers, exchangeRates: { where: vi.fn().mockReturnThis(), first: vi.fn().mockResolvedValue({ @@ -103,19 +120,29 @@ describe('exchange rates', () => { convertCurrencyForUser(db as never, config, 1, { amount: 12, currency: 'EUR', + budgetId: 2, occurredAt: new Date('2026-05-11T12:00:00.000Z') }) ).resolves.toEqual({ amount: 12, currency: 'EUR', defaultCurrencyAmount: 18, - defaultCurrency: 'USD', + defaultCurrency: 'GBP', exchangeRate: 1.5, exchangeRateDate: '2026-05-11' }); }); it('converts primary-currency amounts without querying exchange rates', async () => { + const budgetMembers = { + where: vi.fn(() => budgetMembers), + first: vi.fn().mockResolvedValue({ + budgetId: 2, + userId: 1, + displayName: 'Travel', + role: 'admin' + }) + }; const db = { users: { find: vi.fn().mockResolvedValue({ @@ -124,6 +151,14 @@ describe('exchange rates', () => { timezone: 'UTC' }) }, + budgets: { + find: vi.fn().mockResolvedValue({ + id: 2, + defaultCurrency: 'GBP', + archivedAt: null + }) + }, + budgetMembers, exchangeRates: { where: vi.fn() } @@ -132,14 +167,15 @@ describe('exchange rates', () => { await expect( convertCurrencyForUser(db as never, config, 1, { amount: 12, - currency: 'USD', + currency: 'GBP', + budgetId: 2, occurredAt: new Date('2026-05-11T12:00:00.000Z') }) ).resolves.toEqual({ amount: 12, - currency: 'USD', + currency: 'GBP', defaultCurrencyAmount: 12, - defaultCurrency: 'USD', + defaultCurrency: 'GBP', exchangeRate: 1, exchangeRateDate: '2026-05-11' }); diff --git a/apps/api/src/application/currencies.ts b/apps/api/src/application/currencies.ts index 7a41104..61b29b2 100644 --- a/apps/api/src/application/currencies.ts +++ b/apps/api/src/application/currencies.ts @@ -8,6 +8,7 @@ import { dateToLocalDateParam, defaultTimeZone } from '@xpenser/timezone'; import type { Config } from '../config.js'; import type { AppDb, UserDb } from '../db/schemas.js'; import { FrankfurterCurrencyCatalogFallback } from '../log-templates.js'; +import { resolveBudgetAccess } from './budgets.js'; import { frankfurterCurrencyCatalog } from './frankfurter-currency-catalog.js'; type FrankfurterCurrencyResponse = @@ -204,8 +205,9 @@ export async function convertCurrencyForUser( throw new Error('User was not found.'); } + const access = await resolveBudgetAccess(db, userId, query.budgetId); const currency = query.currency.trim().toUpperCase(); - const defaultCurrency = user.defaultCurrency.trim().toUpperCase(); + const defaultCurrency = access.budget.defaultCurrency.trim().toUpperCase(); const rateDate = transactionDate( query.occurredAt ?? new Date(), user.timezone 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..0ee5309 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, @@ -35,7 +36,6 @@ type ReportPeriod = { type ReportUser = Pick< UserDb, - | 'defaultCurrency' | 'email' | 'id' | 'monthlyEmailReportEnabled' @@ -45,6 +45,7 @@ type ReportUser = Pick< type ReportSendOutcome = { readonly type: EmailReportType; + readonly budgetId: number; readonly status: 'sent' | 'skipped'; readonly from: Date; readonly to: Date; @@ -105,6 +106,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 +456,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 +482,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 +497,7 @@ async function vendorsForUser( async function buildReportAnalytics( db: AppDb, user: ReportUser, + budget: Pick, type: EmailReportType, period: ReportPeriod ): Promise { @@ -503,11 +507,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 +557,8 @@ async function buildReportAnalytics( return { type, + budgetId: budget.id, + budgetName: budget.name, period, periodLabel: periodLabel(period, user.timezone), currency: overview.currency, @@ -596,6 +603,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 +617,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 +726,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 +743,7 @@ function statsUrl( 'date', dateToLocalDateParam(analytics.period.from, timeZone) ); + url.searchParams.set('budgetId', String(analytics.budgetId)); return url.toString(); } @@ -749,6 +761,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 +843,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 +877,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 +909,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 +921,7 @@ async function claimDelivery( `, [ user.id, + budget.id, deliveryKey, type, trigger, @@ -959,6 +977,7 @@ async function sendEmailReport( knex: Knex, config: Config, user: ReportUser, + budget: Pick, type: EmailReportType, trigger: ReportTrigger, now: Date @@ -969,6 +988,7 @@ async function sendEmailReport( knex, config, user, + budget, type, trigger, period @@ -976,6 +996,7 @@ async function sendEmailReport( if (!deliveryId) { return { type, + budgetId: budget.id, status: 'skipped', from: period.from, to: period.to, @@ -984,11 +1005,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 +1032,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; @@ -1016,7 +1050,6 @@ async function listReportUsers(knex: Knex): Promise { .select( 'id', 'email', - 'default_currency as defaultCurrency', 'timezone', 'weekly_email_report_enabled as weeklyEmailReportEnabled', 'monthly_email_report_enabled as monthlyEmailReportEnabled' @@ -1030,6 +1063,20 @@ 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', 'budget_members.display_name as name') + .where('budget_members.user_id', userId) + .whereNull('budgets.archived_at') + .orderBy('budget_members.display_name', 'asc'); + + return rows as Pick[]; +} + export async function sendDueEmailReports( db: AppDb, knex: Knex, @@ -1048,23 +1095,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..df0d6a1 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,10 +109,13 @@ function category(overrides: Partial = {}): Category { function vendor(overrides: Partial = {}): Vendor { return { id: 5, + budgetId: 1, name: 'Walmart', displayName: 'Walmart', domain: 'walmart.com', transactionCount: 3, + contributors: [], + otherContributorCount: 0, createdAt: timestamp, updatedAt: timestamp, ...overrides @@ -120,6 +125,7 @@ function vendor(overrides: Partial = {}): Vendor { function transaction(overrides: Partial = {}): Transaction { return { id: 99, + budgetId: 1, categoryId: 7, vendorId: 5, vendorName: 'Walmart', @@ -136,6 +142,10 @@ function transaction(overrides: Partial = {}): Transaction { exchangeRateDate: '2026-06-01', occurredAt: timestamp, tags: [], + createdBy: { + userId: 1, + email: 'test@cleverbrush.com' + }, createdAt: timestamp, updatedAt: timestamp, ...overrides @@ -146,6 +156,7 @@ function transactionRow(overrides: Partial = {}): TransactionDb { return { id: 99, userId: 1, + budgetId: 1, categoryId: 7, vendorId: 5, type: 'expense', @@ -169,6 +180,7 @@ function scanItem( id: 20, scanId: 10, userId: 1, + budgetId: 1, draftJson: '{}', createdAt: timestamp, updatedAt: timestamp, @@ -180,6 +192,7 @@ function scan(overrides: Partial = {}): TransactionScanDb { return { id: 10, userId: 1, + budgetId: 1, documentKind: 'receipt', imageHash: '6105d6cc76af400325e94d588ce511be5bfdbb73b437dc51eca43917d7a43e3d', @@ -198,6 +211,7 @@ function scanImage( id: 30, scanId: 10, userId: 1, + budgetId: 1, imageHash: '6105d6cc76af400325e94d588ce511be5bfdbb73b437dc51eca43917d7a43e3d', mimeType: 'image/png', @@ -229,6 +243,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..6f73dfb 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) @@ -911,6 +915,7 @@ function scanPrompt() { function promptInput({ categories, correctionExamples, + defaultCurrency, imageProcessing, recentTransactions, user, @@ -918,6 +923,7 @@ function promptInput({ }: { readonly categories: readonly Category[]; readonly correctionExamples: readonly CorrectionExample[]; + readonly defaultCurrency: string; readonly imageProcessing: PreparedScanImages['promptContext']; readonly recentTransactions: readonly Transaction[]; readonly user: UserDb; @@ -925,7 +931,7 @@ function promptInput({ }) { return { user: { - defaultCurrency: user.defaultCurrency, + defaultCurrency, timezone: user.timezone, localToday: dateToLocalDateParam(new Date(), user.timezone) }, @@ -982,20 +988,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, @@ -1013,6 +1028,7 @@ export async function scanTransactionsFromImage( promptInput({ categories, correctionExamples: examples, + defaultCurrency: access.budget.defaultCurrency, imageProcessing: preparedImages.promptContext, recentTransactions, user, @@ -1035,6 +1051,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, @@ -1050,7 +1067,7 @@ export async function scanTransactionsFromImage( for (const raw of rawTransactions.slice(0, maxDrafts)) { const sanitized = sanitizeDraft({ categories, - defaultCurrency: user.defaultCurrency, + defaultCurrency: access.budget.defaultCurrency, raw, recentTransactions, timezone: user.timezone, @@ -1061,6 +1078,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 +1094,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 +1110,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 +1132,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 +1146,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 +1169,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 +1189,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 +1203,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..822a413 100644 --- a/apps/api/src/application/transactions.test.ts +++ b/apps/api/src/application/transactions.test.ts @@ -28,6 +28,47 @@ 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, + displayName: 'Main', + 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 +130,7 @@ describe('transaction category signs', () => { const car = { id: 1, userId: 1, + budgetId: 1, name: 'Car', type: 'expense', parentId: null, @@ -107,6 +149,7 @@ describe('transaction category signs', () => { ({ id, userId: 1, + budgetId: 1, categoryId, type: 'expense', amount, @@ -167,6 +210,7 @@ describe('transaction category signs', () => { const groceries = { id: 1, userId: 1, + budgetId: 1, name: 'Groceries', type: 'expense', parentId: null, @@ -189,6 +233,7 @@ describe('transaction category signs', () => { ({ id, userId: 1, + budgetId: 1, categoryId, type: categoryId === salary.id ? 'income' : 'expense', amount, @@ -241,6 +286,7 @@ describe('transaction category signs', () => { const salary = { id: 1, userId: 1, + budgetId: 1, name: 'Salary', type: 'income', parentId: null, @@ -252,6 +298,7 @@ describe('transaction category signs', () => { ({ id, userId: 1, + budgetId: 1, categoryId: salary.id, type: 'income', amount, @@ -295,6 +342,7 @@ describe('transaction category signs', () => { const salary = { id: 1, userId: 1, + budgetId: 1, name: 'Salary', type: 'income', parentId: null, @@ -317,6 +365,7 @@ describe('transaction category signs', () => { ({ id, userId: 1, + budgetId: 1, categoryId, type: 'income', amount, @@ -444,10 +493,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 +539,7 @@ describe('transaction category signs', () => { const car = { id: 1, userId: 1, + budgetId: 1, name: 'Car', type: 'expense', parentId: null, @@ -510,6 +562,7 @@ describe('transaction category signs', () => { const row = { id: 1, userId: 1, + budgetId: 1, categoryId: returns.id, category: staleJoinedReturns, type: 'expense', @@ -573,6 +626,7 @@ describe('transaction category signs', () => { const groceries = { id: 1, userId: 1, + budgetId: 1, name: 'Groceries', type: 'expense', parentId: null, @@ -594,6 +648,7 @@ describe('transaction category signs', () => { ({ id, userId: 1, + budgetId: 1, name, normalizedName: name.toLowerCase(), createdAt: timestamp, @@ -609,6 +664,7 @@ describe('transaction category signs', () => { ({ id, userId: 1, + budgetId: 1, categoryId, vendorId, type: categoryId === salary.id ? 'income' : 'expense', @@ -698,7 +754,7 @@ describe('transaction category signs', () => { expect(emptyVendorSummary.topVendors).toEqual([]); expect(emptyVendorSummary.categoryVendorBreakdown).toHaveLength(30); expect(expandedVendorSummary.topVendors).toHaveLength(30); - expect(summary.topVendors.slice(0, 3)).toEqual([ + expect(summary.topVendors.slice(0, 3)).toMatchObject([ { vendorId: 1, vendorName: 'Big Store', @@ -733,29 +789,33 @@ describe('transaction category signs', () => { trend: [0, 10, 0, 0, 0] } ]); - expect(expandedVendorSummary.topVendors).toContainEqual({ - vendorId: 3, - vendorName: 'Paycheck Inc', - vendorDomain: undefined, - vendorLogoUrl: undefined, - vendorPrimaryColor: undefined, - type: 'income', - total: 500, - transactionCount: 1, - trend: [0, 500, 0, 0, 0] - }); - expect(expandedVendorSummary.topVendors).toContainEqual({ - vendorId: null, - vendorName: 'No vendor', - vendorDomain: undefined, - vendorLogoUrl: undefined, - vendorPrimaryColor: undefined, - type: 'income', - total: 25, - transactionCount: 1, - trend: [0, 25, 0, 0, 0] - }); - expect(summary.topVendors.slice(3, 4)).toEqual([ + expect(expandedVendorSummary.topVendors).toContainEqual( + expect.objectContaining({ + vendorId: 3, + vendorName: 'Paycheck Inc', + vendorDomain: undefined, + vendorLogoUrl: undefined, + vendorPrimaryColor: undefined, + type: 'income', + total: 500, + transactionCount: 1, + trend: [0, 500, 0, 0, 0] + }) + ); + expect(expandedVendorSummary.topVendors).toContainEqual( + expect.objectContaining({ + vendorId: null, + vendorName: 'No vendor', + vendorDomain: undefined, + vendorLogoUrl: undefined, + vendorPrimaryColor: undefined, + type: 'income', + total: 25, + transactionCount: 1, + trend: [0, 25, 0, 0, 0] + }) + ); + expect(summary.topVendors.slice(3, 4)).toMatchObject([ { vendorId: 10, vendorName: 'Vendor 10', @@ -809,23 +869,25 @@ describe('transaction category signs', () => { trend: [0, 10, 0, 0, 0] } ]); - expect(expandedVendorSummary.categoryVendorBreakdown).toContainEqual({ - categoryId: salary.id, - categoryName: 'Salary', - categoryDisplayName: 'Salary', - categoryParentId: null, - categoryParentName: undefined, - categoryKind: 'normal', - vendorId: null, - vendorName: 'No vendor', - vendorDomain: undefined, - vendorLogoUrl: undefined, - vendorPrimaryColor: undefined, - type: 'income', - total: 25, - transactionCount: 1, - trend: [0, 25, 0, 0, 0] - }); + expect(expandedVendorSummary.categoryVendorBreakdown).toContainEqual( + expect.objectContaining({ + categoryId: salary.id, + categoryName: 'Salary', + categoryDisplayName: 'Salary', + categoryParentId: null, + categoryParentName: undefined, + categoryKind: 'normal', + vendorId: null, + vendorName: 'No vendor', + vendorDomain: undefined, + vendorLogoUrl: undefined, + vendorPrimaryColor: undefined, + type: 'income', + total: 25, + transactionCount: 1, + trend: [0, 25, 0, 0, 0] + }) + ); expect( expandedVendorSummary.topVendors .filter(vendor => vendor.type === 'expense') @@ -843,11 +905,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 +931,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 +942,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 +956,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 +971,7 @@ describe('transaction CSV export', () => { const category = { id: 1, userId: 1, + budgetId: 1, name: 'Meals', type: 'expense', parentId: null, @@ -910,6 +982,7 @@ describe('transaction CSV export', () => { const vendor = { id: 3, userId: 1, + budgetId: 1, name: 'Cafe', normalizedName: 'cafe', domain: null, @@ -941,6 +1014,7 @@ describe('transaction CSV export', () => { const transaction = { id: 1, userId: 1, + budgetId: 1, categoryId: category.id, vendorId: vendor.id, type: 'expense', @@ -988,17 +1062,17 @@ describe('transaction CSV export', () => { }; return { + ...budgetAccessTables(), users: { find: vi.fn(async () => ({ id: 1, defaultCurrency: 'USD', + mainBudgetId: 1, timezone: 'UTC' })) }, - favoriteCurrencies: { - where: vi.fn(() => ({ - orderBy: vi.fn(async () => [{ currency: 'EUR' }]) - })) + budgetFavoriteCurrencies: { + where: vi.fn(async () => [{ budgetId: 1, currency: 'EUR' }]) }, transactions: { include: vi.fn(() => transactionQuery) @@ -1098,6 +1172,23 @@ describe('transaction CSV export', () => { }; return query; } + if (table === 'users') { + const query = { + ids: [] as number[], + whereIn: (_field: string, ids: readonly number[]) => { + query.ids = [...ids]; + return query; + }, + select: () => + Promise.resolve( + query.ids.map(id => ({ + id, + email: 'owner@example.com' + })) + ) + }; + return query; + } throw new Error(`Unexpected table ${table}`); }); } @@ -1227,6 +1318,7 @@ describe('category trend ranges and summaries', () => { const category = { id: 7, userId: 1, + budgetId: 1, name: 'Groceries', type: 'expense', parentId: null, @@ -1244,6 +1336,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 +1625,7 @@ describe('stats tag reports', () => { { id: 1, userId: 1, + budgetId: 1, name: 'Groceries', type: 'expense', parentId: null, @@ -1542,6 +1636,7 @@ describe('stats tag reports', () => { { id: 2, userId: 1, + budgetId: 1, name: 'Rent', type: 'expense', parentId: null, @@ -1552,6 +1647,7 @@ describe('stats tag reports', () => { { id: 3, userId: 1, + budgetId: 1, name: 'Salary', type: 'income', parentId: null, @@ -1564,6 +1660,7 @@ describe('stats tag reports', () => { { id: 1, userId: 1, + budgetId: 1, name: 'Market', normalizedName: 'market', domain: null, @@ -1575,6 +1672,7 @@ describe('stats tag reports', () => { { id: 2, userId: 1, + budgetId: 1, name: 'Landlord', normalizedName: 'landlord', domain: null, @@ -1616,6 +1714,7 @@ describe('stats tag reports', () => { return { id, userId: 1, + budgetId: 1, categoryId, vendorId, type: categoryId === 3 ? 'income' : 'expense', @@ -1720,11 +1819,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..678672e 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, @@ -62,6 +63,13 @@ import { pruneUnusedTransactionTags, replaceTransactionTags } from './transaction-tags.js'; +import { + type ContributorBucket, + contributorSummary, + loadUserAvatarSummaries, + recordContributor, + userIdsFromTransactions +} from './user-avatars.js'; import { getVendor, VendorNotFoundError } from './vendors.js'; export class TransactionNotFoundError extends Error {} @@ -77,6 +85,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 +100,7 @@ type TransactionScanImageRow = TransactionScanAttachmentRow & { }; type TransactionTagRow = { + readonly budgetId: number; readonly createdAt: Date; readonly id: number; readonly name: string; @@ -103,6 +113,8 @@ type TransactionTagCountRow = { readonly transactionCount: number | string; }; +type TransactionCreator = Transaction['createdBy']; + type StatsBucket = StatsOverview['trend'][number]; type StatsCategory = StatsOverview['byCategory'][number]; @@ -126,6 +138,7 @@ type StatsRange = { type TransactionFilterQuery = Pick< TransactionListQuery, + | 'budgetId' | 'categoryId' | 'direction' | 'from' @@ -139,6 +152,7 @@ type TransactionFilterQuery = Pick< >; type FilteredTransactionRows = { + readonly budgetId: number; readonly categoriesById: ReadonlyMap; readonly rows: readonly TransactionDb[]; readonly tagsByTransaction: ReadonlyMap; @@ -155,6 +169,7 @@ type StatsRanges = { }; type PeriodWindowQuery = { + readonly budgetId?: number; readonly period?: DashboardPeriod; readonly date?: Date; readonly currency?: string; @@ -275,11 +290,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,24 +304,52 @@ 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)); } +async function loadTransactionCreators( + knex: Knex, + transactions: readonly Pick[] +): Promise> { + const userIds = Array.from( + new Set(transactions.map(transaction => transaction.userId)) + ); + if (userIds.length === 0) { + return new Map(); + } + + const rows = await loadUserAvatarSummaries({ knex }, userIds); + + return new Map( + [...rows.entries()].map(([userId, row]) => [ + userId, + { + userId, + email: row.email, + avatarUrl: row.avatarUrl + } + ]) + ); +} + async function loadFavoriteCurrencies( db: AppDb, - userId: number + budgetId: number ): Promise { - const rows = await db.favoriteCurrencies - .where(currency => currency.userId, userId) - .orderBy(currency => currency.currency, 'asc'); - return rows.map(row => row.currency); + const rows = await db.budgetFavoriteCurrencies.where( + currency => currency.budgetId, + budgetId + ); + return rows + .map(row => row.currency.trim().toUpperCase()) + .sort((left, right) => left.localeCompare(right)); } function categoryFields( @@ -409,7 +452,8 @@ function mapTransaction( tagsByTransaction: ReadonlyMap< number, readonly TransactionTag[] - > = new Map() + > = new Map(), + creatorsById: ReadonlyMap = new Map() ): Transaction { const category = categoryForTransaction(row, categoriesById); const fields = categoryFields(category, row, categoriesById); @@ -417,6 +461,7 @@ function mapTransaction( return { id: row.id, + budgetId: row.budgetId, categoryId: fields.categoryId, vendorId: vendor?.id ?? row.vendorId ?? null, vendorName: vendor?.name, @@ -436,6 +481,10 @@ function mapTransaction( occurredAt: row.occurredAt, note: row.note ?? undefined, tags: [...(tagsByTransaction.get(row.id) ?? [])], + createdBy: creatorsById.get(row.userId) ?? { + userId: row.userId, + email: '' + }, scanAttachment: scanAttachments.get(row.id) ?? null, createdAt: row.createdAt, updatedAt: row.updatedAt @@ -481,7 +530,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 +540,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 +561,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 +588,7 @@ function scanAttachmentFromRow( async function scanAttachmentsByTransaction( knex: Knex | undefined, - userId: number, + budgetId: number, transactionIds: readonly number[] ): Promise> { const uniqueIds = [...new Set(transactionIds)]; @@ -551,13 +602,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 +657,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 +673,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 +696,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 +727,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 +746,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 +835,7 @@ async function filteredTransactionRows( }); return { + budgetId: access.budget.id, categoriesById, rows: filtered, tagsByTransaction: allTagsByTransaction, @@ -795,6 +853,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,16 +864,20 @@ 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) ); + const creatorsById = await loadTransactionCreators( + knex ?? db.knex, + pageRows + ); return { items: pageRows.map(transaction => @@ -823,7 +886,8 @@ export async function listTransactions( categoriesById, vendorsById, scanAttachments, - pageTagsByTransaction + pageTagsByTransaction, + creatorsById ) ), total: filtered.length, @@ -1058,12 +1122,13 @@ export async function exportTransactionsCsv( query: TransactionExportQuery, knex?: Knex ): Promise<{ readonly csv: string; readonly fileName: string }> { - const [user, favorites] = await Promise.all([ + const [user, access] = await Promise.all([ getUser(db, userId), - loadFavoriteCurrencies(db, userId) + resolveBudgetAccess(db, userId, query.budgetId) ]); + const favorites = await loadFavoriteCurrencies(db, access.budget.id); const allowedCurrencies = new Set( - [user.defaultCurrency, ...favorites].map(currency => + [access.budget.defaultCurrency, ...favorites].map(currency => currency.trim().toUpperCase() ) ); @@ -1083,22 +1148,24 @@ 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 creatorsById = await loadTransactionCreators(knex ?? db.knex, rows); const transactions = rows.map(transaction => mapTransaction( transaction, categoriesById, vendorsById, scanAttachments, - tagsByTransaction + tagsByTransaction, + creatorsById ) ); const rates = await exportCurrencyRates( @@ -1122,9 +1189,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 +1208,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 +1228,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 +1238,7 @@ export async function createTransaction( await replaceTransactionTags( trx.knex, userId, + access.budget.id, created.id, body.tags ?? [] ); @@ -1178,30 +1252,35 @@ 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, creatorsById] = + await Promise.all([ + loadCategoriesById(db, access.budget.id), + loadVendorsById(db, access.budget.id), + transactionTagsByTransaction(db.knex, access.budget.id, [ + transactionId + ]), + loadTransactionCreators(db.knex, [row]) + ]); return mapTransaction( - row as TransactionDb, + row, categoriesById, vendorsById, new Map(), - tagsByTransaction + tagsByTransaction, + creatorsById ); } export async function getTransactionScanImage( + db: AppDb, knex: Knex, userId: number, transactionId: number @@ -1212,14 +1291,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 +1309,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 +1326,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 +1343,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 +1361,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 +1384,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 +1396,7 @@ export async function updateTransaction( await replaceTransactionTags( trx.knex, userId, + access.budget.id, transactionId, body.tags ); @@ -1327,14 +1412,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); }); } @@ -2333,14 +2430,56 @@ function emptyDashboardCategory( transactionCount: 0, previousPeriodTotal: 0, percentChange: 0, - trend: Array.from({ length: bucketCount }, () => 0) + trend: Array.from({ length: bucketCount }, () => 0), + contributors: [], + otherContributorCount: 0 }; } +function contributorBucketFor( + bucketsByKey: Map, + key: string +): ContributorBucket { + const bucket = bucketsByKey.get(key) ?? new Map(); + bucketsByKey.set(key, bucket); + return bucket; +} + +function recordDashboardContributor( + bucketsByKey: Map, + key: string, + row: Pick, + currentUserId?: number +): void { + recordContributor( + contributorBucketFor(bucketsByKey, key), + row, + currentUserId + ); +} + +function applyDashboardContributors< + T extends { contributors: unknown; otherContributorCount: number } +>( + rowsByKey: ReadonlyMap, + bucketsByKey: ReadonlyMap, + usersById: Parameters[1] +): void { + for (const [key, row] of rowsByKey) { + const contributors = contributorSummary( + bucketsByKey.get(key), + usersById + ); + Object.assign(row, contributors); + } +} + function rollUpParentDashboardCategories( categories: readonly DashboardCategory[], bucketCount: number, - categoriesById: ReadonlyMap + categoriesById: ReadonlyMap, + contributorsByKey: ReadonlyMap = new Map(), + usersById: Parameters[1] = new Map() ): DashboardCategory[] { const rollups = new Map(); @@ -2361,10 +2500,15 @@ function rollUpParentDashboardCategories( } for (const category of rollups.values()) { + const key = `${category.type}:${category.categoryId}`; category.percentChange = percentChange( category.total, category.previousPeriodTotal ); + Object.assign( + category, + contributorSummary(contributorsByKey.get(key), usersById) + ); } return Array.from(rollups.values()).sort( @@ -2377,12 +2521,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 +2535,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 +2551,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 +2569,7 @@ export async function categoryTrend( return summarizeCategoryTrendRows({ category, categoriesById, - currency: user.defaultCurrency, + currency: access.budget.defaultCurrency, groupBy: categoryTrendGroupBy(query.groupBy), range, rows, @@ -2442,13 +2587,19 @@ export function summarizeDashboardRows( categoriesById: ReadonlyMap, vendorsById: ReadonlyMap, vendorLimit = dashboardVendorLimit, - reportingAmounts?: DashboardReportingAmounts + reportingAmounts?: DashboardReportingAmounts, + currentUserId?: number, + usersById: Parameters[1] = new Map() ): DashboardSummary { const bucketCount = dashboardTrendBucketCount(period, range, user.timezone); const totalsByCategory = new Map(); const totalsByVendor = new Map(); const totalsByCategoryVendor = new Map(); const previousCategoriesByKey = new Map(); + const contributorsByCategory = new Map(); + const contributorsByParentCategory = new Map(); + const contributorsByVendor = new Map(); + const contributorsByCategoryVendor = new Map(); const comparisonRange = resolveDashboardComparisonRange( period, range, @@ -2488,7 +2639,9 @@ export function summarizeDashboardRows( transactionCount: 0, previousPeriodTotal: 0, percentChange: 0, - trend: Array.from({ length: bucketCount }, () => 0) + trend: Array.from({ length: bucketCount }, () => 0), + contributors: [], + otherContributorCount: 0 }; const total = dashboardTransactionAmount( row, @@ -2509,6 +2662,19 @@ export function summarizeDashboardRows( (current.trend[bucketIndex] ?? 0) + total; } totalsByCategory.set(key, current); + recordDashboardContributor( + contributorsByCategory, + key, + row, + currentUserId + ); + const parentFields = parentCategoryFields(fields, categoriesById); + recordDashboardContributor( + contributorsByParentCategory, + `${parentFields.type}:${parentFields.categoryId}`, + row, + currentUserId + ); const rowVendorFields = dashboardVendorFields(row, vendorsById); if (!rowVendorFields) { @@ -2521,7 +2687,9 @@ export function summarizeDashboardRows( type: fields.type, total: 0, transactionCount: 0, - trend: Array.from({ length: bucketCount }, () => 0) + trend: Array.from({ length: bucketCount }, () => 0), + contributors: [], + otherContributorCount: 0 }; currentVendor.total += total; currentVendor.transactionCount += 1; @@ -2530,6 +2698,12 @@ export function summarizeDashboardRows( (currentVendor.trend[bucketIndex] ?? 0) + total; } totalsByVendor.set(vendorKey, currentVendor); + recordDashboardContributor( + contributorsByVendor, + vendorKey, + row, + currentUserId + ); const categoryVendorKey = `${key}:${ rowVendorFields.vendorId ?? 'none' @@ -2541,7 +2715,9 @@ export function summarizeDashboardRows( ...rowVendorFields, total: 0, transactionCount: 0, - trend: Array.from({ length: bucketCount }, () => 0) + trend: Array.from({ length: bucketCount }, () => 0), + contributors: [], + otherContributorCount: 0 } satisfies DashboardCategoryVendor); currentCategoryVendor.total += total; currentCategoryVendor.transactionCount += 1; @@ -2553,6 +2729,12 @@ export function summarizeDashboardRows( (currentCategoryVendor.trend[bucketIndex] ?? 0) + total; } totalsByCategoryVendor.set(categoryVendorKey, currentCategoryVendor); + recordDashboardContributor( + contributorsByCategoryVendor, + categoryVendorKey, + row, + currentUserId + ); } for (const [key, category] of totalsByCategory) { @@ -2561,6 +2743,17 @@ export function summarizeDashboardRows( category.previousPeriodTotal = previousTotal; category.percentChange = percentChange(category.total, previousTotal); } + applyDashboardContributors( + totalsByCategory, + contributorsByCategory, + usersById + ); + applyDashboardContributors(totalsByVendor, contributorsByVendor, usersById); + applyDashboardContributors( + totalsByCategoryVendor, + contributorsByCategoryVendor, + usersById + ); const categoriesForParentRollups = [ ...totalsByCategory.values(), @@ -2578,7 +2771,9 @@ export function summarizeDashboardRows( const byParentCategory = rollUpParentDashboardCategories( categoriesForParentRollups, bucketCount, - categoriesById + categoriesById, + contributorsByParentCategory, + usersById ).filter(category => category.transactionCount > 0); const topVendors = Array.from(totalsByVendor.values()) .sort( @@ -2636,9 +2831,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 +2855,26 @@ 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 ); + const usersById = await loadUserAvatarSummaries( + db, + userIdsFromTransactions(rows, userId) + ); return summarizeDashboardRows( - user, + reportUser, period, range, rows, @@ -2675,7 +2882,9 @@ export async function dashboardSummary( categoriesById, vendorsById, vendorLimit, - reportingAmounts + reportingAmounts, + userId, + usersById ); } @@ -2685,7 +2894,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,27 +2927,31 @@ 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 ); + const usersById = await loadUserAvatarSummaries( + db, + userIdsFromTransactions(allRows, userId) + ); return { items: plans.map(plan => ({ date: plan.date, summary: summarizeDashboardRows( - user, + reportUser, period, plan.range, rowsInRange(allRows, plan.range), @@ -2739,7 +2959,9 @@ export async function dashboardWindow( categoriesById, vendorsById, query.vendorLimit ?? dashboardVendorLimit, - reportingAmounts + reportingAmounts, + userId, + usersById ) })) }; @@ -2860,7 +3082,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 +3101,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 +3121,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 +3139,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 +3156,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 +3247,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 +3272,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 +3286,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 +3328,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/user-avatars.ts b/apps/api/src/application/user-avatars.ts new file mode 100644 index 0000000..3e34e99 --- /dev/null +++ b/apps/api/src/application/user-avatars.ts @@ -0,0 +1,241 @@ +import type { + UserAvatarSummary, + UserAvatarUploadBody, + UserPreference +} from '@xpenser/contracts'; +import { UserAvatarLimits } from '@xpenser/contracts'; +import type { AppDb, TransactionDb, UserDb } from '../db/schemas.js'; +import { getUserPreference } from './users.js'; + +export class UserAvatarError extends Error {} + +type UserAvatarRow = Pick< + UserDb, + | 'avatarImageFileName' + | 'avatarImageMimeType' + | 'avatarImageUpdatedAt' + | 'avatarUrl' + | 'email' + | 'id' +>; + +export type ContributorSummary = { + readonly contributors: readonly UserAvatarSummary[]; + readonly otherContributorCount: number; +}; + +export type ContributorBucket = Map; + +const avatarPath = (userId: number) => `/app-api/users/${userId}/avatar`; + +function avatarUrl(row: UserAvatarRow): string | undefined { + if (row.avatarImageMimeType) { + return avatarPath(row.id); + } + return row.avatarUrl ?? undefined; +} + +export function mapUserAvatarSummary( + row: UserAvatarRow, + displayName?: string +): UserAvatarSummary { + return { + userId: row.id, + email: row.email, + displayName: displayName || undefined, + avatarUrl: avatarUrl(row) + }; +} + +export async function loadUserAvatarSummaries( + db: Pick, + userIds: readonly number[], + displayNames: ReadonlyMap = new Map() +): Promise> { + const ids = Array.from(new Set(userIds)).filter( + id => Number.isSafeInteger(id) && id > 0 + ); + if (ids.length === 0) { + return new Map(); + } + + const rows = (await db.knex('users').whereIn('id', ids).select({ + id: 'id', + email: 'email', + avatarUrl: 'avatar_url', + avatarImageMimeType: 'avatar_image_mime_type', + avatarImageFileName: 'avatar_image_file_name', + avatarImageUpdatedAt: 'avatar_image_updated_at' + })) as UserAvatarRow[]; + + return new Map( + rows.map(row => [ + row.id, + mapUserAvatarSummary(row, displayNames.get(row.id)) + ]) + ); +} + +export function recordContributor( + bucket: ContributorBucket, + transaction: Pick, + currentUserId?: number +): void { + if (transaction.userId === currentUserId) { + return; + } + const current = bucket.get(transaction.userId); + if (!current || transaction.occurredAt > current) { + bucket.set(transaction.userId, transaction.occurredAt); + } +} + +export function contributorSummary( + bucket: ContributorBucket | undefined, + usersById: ReadonlyMap, + limit = 5 +): ContributorSummary { + if (!bucket || bucket.size === 0) { + return { contributors: [], otherContributorCount: 0 }; + } + + const sorted = [...bucket.entries()].sort( + ([leftId, left], [rightId, right]) => { + const latestDelta = right.getTime() - left.getTime(); + if (latestDelta !== 0) { + return latestDelta; + } + const leftEmail = usersById.get(leftId)?.email ?? ''; + const rightEmail = usersById.get(rightId)?.email ?? ''; + return leftEmail.localeCompare(rightEmail); + } + ); + const knownUsers = sorted + .map(([userId]) => usersById.get(userId)) + .filter((user): user is UserAvatarSummary => Boolean(user)); + const contributors = knownUsers.slice(0, Math.max(0, limit)); + return { + contributors, + otherContributorCount: Math.max( + 0, + knownUsers.length - contributors.length + ) + }; +} + +export function userIdsFromTransactions( + transactions: readonly Pick[], + currentUserId?: number +): number[] { + return Array.from( + new Set( + transactions + .map(transaction => transaction.userId) + .filter(userId => userId !== currentUserId) + ) + ); +} + +function imageBytes(base64: string): number { + try { + return Buffer.from(base64, 'base64').byteLength; + } catch { + return Number.POSITIVE_INFINITY; + } +} + +export async function updateUserAvatar( + db: AppDb, + userId: number, + body: UserAvatarUploadBody +): Promise { + const bytes = imageBytes(body.imageBase64); + if (bytes <= 0 || bytes > UserAvatarLimits.maxImageBytes) { + throw new UserAvatarError( + `Avatar image must be ${UserAvatarLimits.maxImageBytes} bytes or smaller.` + ); + } + + const updated = await db.users + .where(user => user.id, userId) + .update({ + avatarImageBase64: body.imageBase64, + avatarImageMimeType: body.mimeType, + avatarImageFileName: body.fileName, + avatarImageUpdatedAt: new Date(), + updatedAt: new Date() + }); + if (updated.length === 0) { + return undefined; + } + return getUserPreference(db, userId); +} + +export async function deleteUserAvatar( + db: AppDb, + userId: number +): Promise { + const updated = await db.users + .where(user => user.id, userId) + .update({ + avatarImageBase64: null, + avatarImageMimeType: null, + avatarImageFileName: null, + avatarImageUpdatedAt: null, + updatedAt: new Date() + }); + if (updated.length === 0) { + return undefined; + } + return getUserPreference(db, userId); +} + +async function canViewAvatar( + db: AppDb, + requesterUserId: number, + targetUserId: number +): Promise { + if (requesterUserId === targetUserId) { + return true; + } + const row = await db + .knex('budget_members as requester') + .join( + 'budget_members as target', + 'target.budget_id', + 'requester.budget_id' + ) + .where('requester.user_id', requesterUserId) + .where('target.user_id', targetUserId) + .first('target.user_id'); + return Boolean(row); +} + +export async function getUserAvatarImage( + db: AppDb, + requesterUserId: number, + targetUserId: number +): Promise< + | { + readonly imageBase64: string; + readonly mimeType: string; + readonly fileName?: string; + readonly updatedAt?: Date; + } + | undefined +> { + if (!(await canViewAvatar(db, requesterUserId, targetUserId))) { + return undefined; + } + + const user = (await db.users.find(targetUserId)) as UserDb | undefined; + if (!user?.avatarImageBase64 || !user.avatarImageMimeType) { + return undefined; + } + return { + imageBase64: user.avatarImageBase64, + mimeType: user.avatarImageMimeType, + fileName: user.avatarImageFileName, + updatedAt: user.avatarImageUpdatedAt + }; +} 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..49d3850 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 {} @@ -42,6 +47,27 @@ function normalizeCountryCode(value: string | undefined): string { return /^[A-Z]{2}$/.test(countryCode) ? countryCode : 'US'; } +function normalizedAvatarUrl(value: string | undefined): string | undefined { + if (!value) { + return undefined; + } + try { + const url = new URL(value.trim()); + return url.protocol === 'https:' ? url.toString() : undefined; + } catch { + return undefined; + } +} + +function userAvatarUrl( + user: Pick +): string | undefined { + if (user.avatarImageMimeType) { + return `/app-api/users/${user.id}/avatar`; + } + return user.avatarUrl ?? undefined; +} + function normalizedEmail(value: string): string { return value.trim().toLowerCase(); } @@ -128,41 +154,19 @@ function isUnverifiedLocalUser( return user.authProvider === 'local' && user.emailVerified === false; } -async function favoriteCurrencies( +async function hasCategories( db: AppDb, - userId: number -): Promise { - const rows = await db.favoriteCurrencies - .where(currency => currency.userId, userId) - .orderBy(currency => currency.currency, 'asc'); - return rows.map(row => row.currency); -} - -async function hasCategories(db: AppDb, userId: number): Promise { + 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 -): Promise { - const rows = (await db.transactions.where( - transaction => transaction.userId, - userId - )) as TransactionDb[]; - - return rows - .sort( - (left, right) => - right.occurredAt.getTime() - left.occurredAt.getTime() || - right.id - left.id - ) - .slice(0, 10); -} - export function transactionCurrenciesByRecentPopularity( currencies: readonly string[], recentTransactions: readonly CurrencyTransaction[] @@ -226,25 +230,6 @@ export function transactionCurrenciesByRecentPopularity( ); } -async function setFavoriteCurrencies( - db: AppDb, - userId: number, - currencies: readonly string[], - defaultCurrency: string -): Promise { - const unique = Array.from( - new Set(currencies.filter(currency => currency !== defaultCurrency)) - ); - await db.favoriteCurrencies - .where(currency => currency.userId, userId) - .delete(); - if (unique.length > 0) { - await db.favoriteCurrencies.insertMany( - unique.map(currency => ({ userId, currency })) - ); - } -} - function toTokenResponse( config: Config, user: Pick< @@ -255,6 +240,7 @@ function toTokenResponse( | 'role' | 'countryCode' | 'timezone' + | 'mainBudgetId' | 'weeklyEmailReportEnabled' | 'monthlyEmailReportEnabled' >, @@ -278,6 +264,7 @@ function toTokenResponse( defaultCurrency: user.defaultCurrency, countryCode: normalizeCountryCode(user.countryCode), timezone: normalizeTimeZone(user.timezone), + mainBudgetId: user.mainBudgetId ?? null, hasCategories: categories } }; @@ -319,10 +306,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 ); } @@ -355,6 +343,11 @@ export async function issueSingleUserToken( emailVerificationExpiresAt: undefined, role: 'user', authProvider: 'single_user', + avatarUrl: undefined, + avatarImageBase64: undefined, + avatarImageMimeType: undefined, + avatarImageFileName: undefined, + avatarImageUpdatedAt: undefined, defaultCurrency: 'USD', countryCode: 'US', timezone: defaultTimeZone @@ -409,16 +402,20 @@ export async function registerUser( emailVerificationExpiresAt: confirmation.expiresAt, role: 'user', authProvider: 'local', + avatarUrl: undefined, + avatarImageBase64: undefined, + avatarImageMimeType: undefined, + avatarImageFileName: undefined, + avatarImageUpdatedAt: undefined, defaultCurrency: body.defaultCurrency, countryCode: normalizeCountryCode(body.countryCode), timezone: normalizeTimeZone(body.timezone) }); - await setFavoriteCurrencies( + await createMainBudgetForUser( trx, - user.id, - body.favoriteCurrencies, - body.defaultCurrency + user as UserDb, + body.favoriteCurrencies ); return emailConfirmationPendingResponse(user.email); @@ -452,7 +449,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( @@ -530,6 +532,7 @@ async function resolveGoogleIdentity( } return db.transaction(async trx => { + const avatarUrl = normalizedAvatarUrl(identity.avatarUrl); const existingIdentity = await trx.externalIdentities .where(row => row.provider, 'google') .where(row => row.providerSubject, identity.providerSubject) @@ -541,6 +544,11 @@ async function resolveGoogleIdentity( 'Linked account was not found.' ); } + if (avatarUrl && user.avatarUrl !== avatarUrl) { + await trx.users + .where(candidate => candidate.id, user.id) + .update({ avatarUrl, updatedAt: new Date() }); + } return { service_user_id: String(user.id), roles: [user.role] @@ -566,11 +574,22 @@ async function resolveGoogleIdentity( emailVerificationExpiresAt: undefined, role: 'user', authProvider: 'google', + avatarUrl, + avatarImageBase64: undefined, + avatarImageMimeType: undefined, + avatarImageFileName: undefined, + avatarImageUpdatedAt: undefined, defaultCurrency: 'USD', countryCode: 'US', timezone: defaultTimeZone })); + if (avatarUrl && user.avatarUrl !== avatarUrl) { + await trx.users + .where(candidate => candidate.id, user.id) + .update({ avatarUrl, updatedAt: new Date() }); + } + const userIdentity = await trx.externalIdentities .where(row => row.provider, 'google') .where(row => row.userId, user.id) @@ -659,24 +678,33 @@ 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 [categories, budgets] = await Promise.all([ + hasCategories(db, userId, mainBudget.id), + listBudgets(db, userId) ]); - const transactionCurrencies = transactionCurrenciesByRecentPopularity( - [user.defaultCurrency, ...favorites], - recentTransactions - ); + const mainBudgetPreference = + budgets.find( + budget => budget.id === (user.mainBudgetId ?? mainBudget.id) + ) ?? budgets[0]; + const defaultCurrency = + mainBudgetPreference?.defaultCurrency ?? user.defaultCurrency; + const favorites = mainBudgetPreference?.favoriteCurrencies ?? []; + const transactionCurrencies = + mainBudgetPreference?.transactionCurrencies ?? [defaultCurrency]; return { id: user.id, email: user.email, - defaultCurrency: user.defaultCurrency, + avatarUrl: userAvatarUrl(user), + hasUploadedAvatar: Boolean(user.avatarImageMimeType), + defaultCurrency, countryCode: normalizeCountryCode(user.countryCode), favoriteCurrencies: favorites, transactionCurrencies, timezone: normalizeTimeZone(user.timezone), + mainBudgetId: user.mainBudgetId ?? mainBudget.id, + budgets, hasCategories: categories, weeklyEmailReportEnabled: user.weeklyEmailReportEnabled, monthlyEmailReportEnabled: user.monthlyEmailReportEnabled @@ -686,8 +714,6 @@ export async function getUserPreference( export async function updateUserPreference( db: AppDb, userId: number, - defaultCurrency: string, - currencies: readonly string[], countryCode?: string, timezone?: string, weeklyEmailReportEnabled = true, @@ -702,7 +728,6 @@ export async function updateUserPreference( await trx.users .where(candidate => candidate.id, userId) .update({ - defaultCurrency, countryCode: normalizeCountryCode( countryCode ?? user.countryCode ), @@ -711,7 +736,6 @@ export async function updateUserPreference( monthlyEmailReportEnabled, updatedAt: new Date() }); - await setFavoriteCurrencies(trx, userId, currencies, defaultCurrency); }); return getUserPreference(db, userId); 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..1ba224b 100644 --- a/apps/api/src/application/vendors.ts +++ b/apps/api/src/application/vendors.ts @@ -11,15 +11,24 @@ 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 } from './categories.js'; +import { + type ContributorBucket, + contributorSummary, + loadUserAvatarSummaries, + recordContributor, + userIdsFromTransactions +} from './user-avatars.js'; const brandfetchTransactionUrl = 'https://api.brandfetch.io/v2/brands/transaction'; @@ -32,6 +41,7 @@ export class VendorNotFoundError extends Error {} export class VendorMetadataError extends Error {} type VendorStats = { + readonly contributors: ReturnType; readonly latestAt?: Date; readonly transactionCount: number; }; @@ -390,6 +400,7 @@ async function enrichVendor( db: AppDb, config: Config, user: UserDb, + budget: BudgetDb, vendor: VendorDb, options: { readonly force?: boolean } = {} ): Promise { @@ -397,7 +408,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 +426,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 +441,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 +453,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,22 +467,33 @@ 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[]; } -function vendorStats(transactions: readonly TransactionDb[]) { +function vendorStats( + transactions: readonly TransactionDb[], + usersById: Parameters[1], + currentUserId: number +) { const stats = new Map(); + const contributorsByVendor = new Map(); for (const transaction of transactions) { if (!transaction.vendorId) { continue; } + const bucket = + contributorsByVendor.get(transaction.vendorId) ?? + new Map(); + contributorsByVendor.set(transaction.vendorId, bucket); + recordContributor(bucket, transaction, currentUserId); const current = stats.get(transaction.vendorId); stats.set(transaction.vendorId, { + contributors: { contributors: [], otherContributorCount: 0 }, transactionCount: (current?.transactionCount ?? 0) + 1, latestAt: !current?.latestAt || transaction.occurredAt > current.latestAt @@ -479,6 +501,15 @@ function vendorStats(transactions: readonly TransactionDb[]) { : current.latestAt }); } + for (const [vendorId, statsRow] of stats) { + stats.set(vendorId, { + ...statsRow, + contributors: contributorSummary( + contributorsByVendor.get(vendorId), + usersById + ) + }); + } return stats; } @@ -548,6 +579,7 @@ function mapVendor( ): Vendor { return { id: vendor.id, + budgetId: vendor.budgetId, name: vendor.name, displayName: vendor.name, resolvedName: vendor.resolvedName ?? undefined, @@ -563,18 +595,28 @@ function mapVendor( suggestedCategoryId: suggestion?.categoryId, suggestedCategoryDisplayName: suggestion?.categoryDisplayName, transactionCount: stats?.transactionCount ?? 0, + contributors: [...(stats?.contributors.contributors ?? [])], + otherContributorCount: stats?.contributors.otherContributorCount ?? 0, createdAt: vendor.createdAt, updatedAt: vendor.updatedAt }; } -async function vendorReadContext(db: AppDb, userId: number) { +async function vendorReadContext( + db: AppDb, + budgetId: number, + currentUserId: number +) { const [transactions, categoriesById] = await Promise.all([ - loadVendorTransactions(db, userId), - loadCategoriesById(db, userId) + loadVendorTransactions(db, budgetId), + loadCategoriesById(db, budgetId) ]); + const usersById = await loadUserAvatarSummaries( + db, + userIdsFromTransactions(transactions, currentUserId) + ); return { - stats: vendorStats(transactions), + stats: vendorStats(transactions, usersById, currentUserId), suggestions: categorySuggestions(transactions, categoriesById) }; } @@ -584,11 +626,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, userId) ]); return (rows as VendorDb[]) @@ -633,7 +676,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, userId); return mapVendor( vendor, context.stats.get(vendor.id), @@ -723,6 +767,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 +780,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 +805,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 +813,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, userId) ]); return mapVendor( @@ -791,11 +838,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 +909,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 +920,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 +963,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 +979,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..0ed1f43 --- /dev/null +++ b/apps/api/src/db/migrations/019_budgets.ts @@ -0,0 +1,411 @@ +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.timestamp('archived_at', { useTz: true }).nullable(); + table.index(['created_by_user_id'], 'idx_budgets_created_by_user_id'); + table.index(['archived_at'], 'idx_budgets_archived_at'); + }); + + await knex.schema.alterTable('users', table => { + table.string('avatar_url', 1000).nullable(); + table.text('avatar_image_base64').nullable(); + table.string('avatar_image_mime_type', 32).nullable(); + table.string('avatar_image_file_name', 255).nullable(); + table.timestamp('avatar_image_updated_at', { useTz: true }).nullable(); + 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('display_name', 120).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 + .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_favorite_currencies', table => { + table + .integer('budget_id') + .notNullable() + .references('id') + .inTable('budgets') + .onDelete('CASCADE'); + table.string('currency', 3).notNullable(); + table.primary(['budget_id', 'currency'], { + constraintName: 'pk_budget_favorite_currencies' + }); + }); + + 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, + display_name, + 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, + 'Main', + 'admin', + true, + true, + true, + true, + true, + true, + true + from users + where main_budget_id is not null + `); + + await knex.raw(` + insert into budget_favorite_currencies (budget_id, currency) + select distinct u.main_budget_id, currency.currency + from user_favorite_currencies currency + join users u on u.id = currency.user_id + where u.main_budget_id is not null + `); + + await knex.schema.dropTableIfExists('user_favorite_currencies'); + + 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.createTable('user_favorite_currencies', table => { + table + .integer('user_id') + .notNullable() + .references('id') + .inTable('users') + .onDelete('CASCADE'); + table.string('currency', 3).notNullable(); + table.primary(['user_id', 'currency']); + }); + await knex.raw(` + insert into user_favorite_currencies (user_id, currency) + select distinct u.id, currency.currency + from users u + join budget_favorite_currencies currency + on currency.budget_id = u.main_budget_id + where u.main_budget_id is not null + `); + await knex.schema.dropTableIfExists('budget_favorite_currencies'); + await knex.schema.dropTableIfExists('budget_members'); + + await knex.schema.alterTable('users', table => { + table.dropColumn('avatar_image_updated_at'); + table.dropColumn('avatar_image_file_name'); + table.dropColumn('avatar_image_mime_type'); + table.dropColumn('avatar_image_base64'); + table.dropColumn('avatar_url'); + 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..712ab56 100644 --- a/apps/api/src/db/schemas.ts +++ b/apps/api/src/db/schemas.ts @@ -22,9 +22,25 @@ export const UserDbSchema = object({ .hasColumnName('email_verification_expires_at'), role: string(), authProvider: string().hasColumnName('auth_provider'), + avatarUrl: string().optional().hasColumnName('avatar_url'), + avatarImageBase64: string().optional().hasColumnName('avatar_image_base64'), + avatarImageMimeType: string() + .optional() + .hasColumnName('avatar_image_mime_type'), + avatarImageFileName: string() + .optional() + .hasColumnName('avatar_image_file_name'), + avatarImageUpdatedAt: date() + .optional() + .hasColumnName('avatar_image_updated_at'), 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), @@ -42,9 +58,14 @@ export const UserDbSchema = object({ 'emailVerified', 'role', 'authProvider', + 'avatarUrl', + 'avatarImageMimeType', + 'avatarImageFileName', + 'avatarImageUpdatedAt', 'defaultCurrency', 'countryCode', 'timezone', + 'mainBudgetId', 'weeklyEmailReportEnabled', 'monthlyEmailReportEnabled', 'createdAt', @@ -60,22 +81,28 @@ export const UserDbSchema = object({ 'emailVerificationExpiresAt', 'role', 'authProvider', + 'avatarUrl', + 'avatarImageBase64', + 'avatarImageMimeType', + 'avatarImageFileName', + 'avatarImageUpdatedAt', 'defaultCurrency', 'countryCode', 'timezone', + 'mainBudgetId', 'weeklyEmailReportEnabled', 'monthlyEmailReportEnabled' ); -export const FavoriteCurrencyDbSchema = object({ - userId: number() - .hasColumnName('user_id') - .references('users', 'id') +export const BudgetFavoriteCurrencyDbSchema = object({ + budgetId: number() + .hasColumnName('budget_id') + .references('budgets', 'id') .onDelete('CASCADE'), currency: string() }) - .hasTableName('user_favorite_currencies') - .hasPrimaryKey(['userId', 'currency'] as const); + .hasTableName('budget_favorite_currencies') + .hasPrimaryKey(['budgetId', 'currency'] as const); export const TelegramAccountDbSchema = object({ userId: number() @@ -104,6 +131,99 @@ 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(), + archivedAt: date().optional().hasColumnName('archived_at'), + 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'), + displayName: string().hasColumnName('display_name'), + 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 +321,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 +347,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 +377,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 +414,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 +446,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 +466,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 +506,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') @@ -385,7 +540,9 @@ export const ExchangeRateDbSchema = object({ }).hasTableName('exchange_rates'); export const UserEntity = defineEntity(UserDbSchema); -export const FavoriteCurrencyEntity = defineEntity(FavoriteCurrencyDbSchema); +export const BudgetFavoriteCurrencyEntity = defineEntity( + BudgetFavoriteCurrencyDbSchema +); export const ExternalIdentityDbSchema = object({ provider: string(), @@ -403,6 +560,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); @@ -434,10 +594,13 @@ export const ExchangeRateEntity = defineEntity(ExchangeRateDbSchema); export const entityMap = { users: UserEntity, - favoriteCurrencies: FavoriteCurrencyEntity, + budgetFavoriteCurrencies: BudgetFavoriteCurrencyEntity, externalIdentities: ExternalIdentityEntity, telegramAccounts: TelegramAccountEntity, telegramLinkTokens: TelegramLinkTokenEntity, + budgets: BudgetEntity, + budgetMembers: BudgetMemberEntity, + budgetInvitations: BudgetInvitationEntity, apiKeys: ApiKeyEntity, mcpOAuthClients: McpOAuthClientEntity, mcpOAuthGrants: McpOAuthGrantEntity, @@ -463,6 +626,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..23ccc94 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,10 +67,13 @@ function category(overrides: Partial = {}): Category { function vendor(overrides: Partial = {}): Vendor { return { id: 5, + budgetId: 1, name: 'Store', displayName: 'Store', domain: 'store.example', transactionCount: 1, + contributors: [], + otherContributorCount: 0, createdAt: new Date('2026-05-03T00:00:00.000Z'), updatedAt: new Date('2026-05-04T00:00:00.000Z'), ...overrides @@ -90,6 +95,7 @@ function vendorCandidate( function transaction(overrides: Partial = {}): Transaction { return { id: 100, + budgetId: 1, categoryId: 1, vendorId: null, categoryName: 'Food', @@ -107,6 +113,10 @@ function transaction(overrides: Partial = {}): Transaction { note: 'Lunch', tags: [], scanAttachment: null, + createdBy: { + userId: 1, + email: 'test@cleverbrush.com' + }, createdAt: new Date('2026-05-14T01:00:00.000Z'), updatedAt: new Date('2026-05-14T01:00:00.000Z'), ...overrides @@ -118,6 +128,7 @@ function transactionTag( ): TransactionTag { return { id: 9, + budgetId: 1, name: 'wife', transactionCount: 2, createdAt: new Date('2026-05-06T00:00:00.000Z'), @@ -126,6 +137,32 @@ function transactionTag( }; } +function budget(overrides: Partial = {}): Budget { + return { + id: 1, + name: 'Main', + defaultCurrency: 'USD', + favoriteCurrencies: [], + transactionCurrencies: ['USD'], + countryCode: 'US', + role: 'admin', + permissions: { + canCreateTransactions: true, + canUpdateTransactions: true, + canDeleteTransactions: true, + canManageCategories: true, + canManageVendors: true, + canManageTags: true, + canManageMembers: true + }, + isMain: true, + archivedAt: null, + 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 +249,13 @@ describe('MCP tool helpers', () => { transactionCurrencies: ['USD', 'EUR'], timezone: 'UTC', hasCategories: true, + hasUploadedAvatar: false, + 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 +366,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 +432,7 @@ describe('MCP tool helpers', () => { limit: 500 }) ).toEqual({ + budgetId: undefined, search: 'wife', limit: 100 }); @@ -434,6 +477,7 @@ describe('MCP tool helpers', () => { }); expect(data.listCategories).toHaveBeenCalledWith(7, { + budgetId: undefined, activeOnly: true, sort: 'recent-transaction-count' }); @@ -550,6 +594,7 @@ describe('MCP tool helpers', () => { }); expect(data.listTransactionTags).toHaveBeenCalledWith(7, { + budgetId: undefined, search: 'wife', limit: 10 }); @@ -645,7 +690,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..d3d454a 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 ) }); } @@ -1269,16 +1323,25 @@ export function createXpenserMcpTools( name: 'xpenser_get_current_user', title: 'Get current xpenser user', description: - 'Return profile, default currency, transaction currency order, and timezone context for the authenticated xpenser API key owner.', + 'Return profile, active budgets, derived Main budget currency order, and timezone context for the authenticated xpenser API key owner.', inputSchema: EmptyInputSchema, 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/api/src/pr-env.test.ts b/apps/api/src/pr-env.test.ts index 2319662..9630a03 100644 --- a/apps/api/src/pr-env.test.ts +++ b/apps/api/src/pr-env.test.ts @@ -8,6 +8,7 @@ const prEnvScript = readFileSync(resolve(repoRoot, 'pr-env.sh'), 'utf8'); const shellDomain = '$' + '{DOMAIN}'; const shellPassportProject = '$' + '{PASSPORT_PROJECT}'; const shellPassportEnvironment = '$' + '{PASSPORT_ENVIRONMENT}'; +const shellPostgresDb = '$' + '{POSTGRES_DB}'; describe('PR environment script', () => { it('registers Passport against the public /api backend', () => { @@ -38,4 +39,30 @@ describe('PR environment script', () => { expect(prEnvScript).toContain('AUTH_GOOGLE_ID='); expect(prEnvScript).toContain('AUTH_GOOGLE_SECRET='); }); + + it('reinitializes when the PR database marker predates the Docker volume', () => { + expect(prEnvScript).toContain('database_initialized()'); + expect(prEnvScript).toContain( + `local volume_name="$` + `{COMPOSE_PROJECT}_postgres_data"` + ); + expect(prEnvScript).toContain( + `docker volume inspect "$volume_name" --format '{{.CreatedAt}}'` + ); + expect(prEnvScript).toContain( + `marker_epoch="$(stat -c '%Y' "$DB_INITIALIZED_FILE"` + ); + expect(prEnvScript).toContain( + 'if (( marker_epoch < volume_epoch )); then' + ); + }); + + it('resets only the PR database schema before restoring production', () => { + expect(prEnvScript).toContain('reset_pr_database()'); + expect(prEnvScript).toContain( + `log "Resetting PR database ${shellPostgresDb} before restore"` + ); + expect(prEnvScript).toContain('psql -v ON_ERROR_STOP=1'); + expect(prEnvScript).toContain('DROP SCHEMA IF EXISTS public CASCADE;'); + expect(prEnvScript).toContain('reset_pr_database "$pr_container"'); + }); }); diff --git a/apps/telegram-bot/src/bot.test.ts b/apps/telegram-bot/src/bot.test.ts index e8fcdde..506b89f 100644 --- a/apps/telegram-bot/src/bot.test.ts +++ b/apps/telegram-bot/src/bot.test.ts @@ -93,11 +93,37 @@ const me = { email: 'alice@example.test', favoriteCurrencies: [], hasCategories: true, + hasUploadedAvatar: false, id: 1, + mainBudgetId: 1, monthlyEmailReportEnabled: false, timezone: 'UTC', transactionCurrencies: ['USD'], - weeklyEmailReportEnabled: false + weeklyEmailReportEnabled: false, + budgets: [ + { + id: 1, + name: 'Main', + defaultCurrency: 'USD', + favoriteCurrencies: [], + transactionCurrencies: ['USD'], + countryCode: 'US', + role: 'admin', + permissions: { + canCreateTransactions: true, + canUpdateTransactions: true, + canDeleteTransactions: true, + canManageCategories: true, + canManageVendors: true, + canManageTags: true, + canManageMembers: true + }, + isMain: true, + archivedAt: null, + createdAt: new Date('2026-05-01T00:00:00.000Z'), + updatedAt: new Date('2026-05-01T00:00:00.000Z') + } + ] } as UserPreference; const categories = [ @@ -231,6 +257,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 +351,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 +360,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 +369,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..fd63bdb 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; @@ -417,26 +425,31 @@ function vendorById( function preferredCurrency( me: UserPreference, currencies: readonly Currency[], - value: string | null | undefined + value: string | null | undefined, + budgetId?: number ): string { - const options = preferredCurrencies(me, currencies); + const options = preferredCurrencies(me, currencies, budgetId); + const budget = budgetId + ? me.budgets.find(candidate => candidate.id === budgetId) + : undefined; const normalized = value?.trim().toUpperCase(); return normalized && options.includes(normalized) ? normalized - : (options[0] ?? me.defaultCurrency); + : (options[0] ?? budget?.defaultCurrency ?? me.defaultCurrency); } function initialScanValues( draft: TransactionScanDraft, me: UserPreference, categories: readonly Category[], - currencies: readonly Currency[] + currencies: readonly Currency[], + budgetId: number ): ScanDraftValues { const transactionType = draftCategoryType(draft, categories); return { amount: draft.amount, categoryId: draft.categoryId, - currency: preferredCurrency(me, currencies, draft.currency), + currency: preferredCurrency(me, currencies, draft.currency, budgetId), occurredAt: draft.occurredAt ?? new Date(), note: draft.note ?? '', transactionType, @@ -448,12 +461,13 @@ function valuesForScan( scan: TransactionScanResponse, me: UserPreference, categories: readonly Category[], - currencies: readonly Currency[] + currencies: readonly Currency[], + budgetId: number ): Record { return Object.fromEntries( scan.drafts.map(draft => [ draft.id, - initialScanValues(draft, me, categories, currencies) + initialScanValues(draft, me, categories, currencies, budgetId) ]) ); } @@ -502,6 +516,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 +549,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 +594,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 +754,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 +840,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 + } }) ]); @@ -754,10 +884,10 @@ export class XpenserTelegramBot { return; } - if (preferredCurrencies(me, currencies).length === 0) { + if (preferredCurrencies(me, currencies, budget.id).length === 0) { await this.#bot.sendMessage( msg.chat.id, - '💱 Set a default or favorite currency in xpenser Preferences first.', + '💱 Set a primary or favorite currency for this budget in xpenser first.', { reply_markup: quickAddReplyKeyboard() } @@ -768,6 +898,8 @@ export class XpenserTelegramBot { const draft: Draft = { kind: 'manual', step: 'amount', + budgetId: budget.id, + budgetName: budget.name, me, categories: categoriesByRecentUse( categories, @@ -776,13 +908,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 +978,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 +1013,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,17 +1041,24 @@ 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, vendors, scan, attachment: body, - values: valuesForScan(scan, me, categories, currencies), + values: valuesForScan( + scan, + me, + categories, + currencies, + budget.id + ), decisions: {}, index: 0, attachmentSubmitted: false, @@ -961,6 +1115,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 +1140,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 +1257,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, @@ -1102,7 +1306,11 @@ export class XpenserTelegramBot { if (draft.step === 'currency') { await this.#bot.sendMessage(chatId, '💱 Choose a currency.', { - reply_markup: currencyKeyboard(draft.me, draft.currencies) + reply_markup: currencyKeyboard( + draft.me, + draft.currencies, + draft.budgetId + ) }); return; } @@ -1215,7 +1423,11 @@ export class XpenserTelegramBot { }; this.#sessions.set(key, next); await this.#bot.sendMessage(chatId, '💱 Choose currency.', { - reply_markup: currencyKeyboard(session.me, session.currencies) + reply_markup: currencyKeyboard( + session.me, + session.currencies, + session.budgetId + ) }); return; } @@ -1305,9 +1517,11 @@ export class XpenserTelegramBot { if (session.step === 'edit-currency' && data.startsWith('cur:')) { const currency = data.slice('cur:'.length).trim().toUpperCase(); if ( - !preferredCurrencies(session.me, session.currencies).includes( - currency - ) + !preferredCurrencies( + session.me, + session.currencies, + session.budgetId + ).includes(currency) ) { await this.#bot.sendMessage( chatId, @@ -1454,6 +1668,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, @@ -1462,7 +1678,11 @@ export class XpenserTelegramBot { }; this.#sessions.set(key, next); await this.#bot.sendMessage(chatId, '💱 Choose currency.', { - reply_markup: currencyKeyboard(draft.me, draft.currencies) + reply_markup: currencyKeyboard( + draft.me, + draft.currencies, + draft.budgetId + ) }); return; } @@ -1472,7 +1692,11 @@ export class XpenserTelegramBot { chatId, '💱 Please choose a currency button.', { - reply_markup: currencyKeyboard(draft.me, draft.currencies) + reply_markup: currencyKeyboard( + draft.me, + draft.currencies, + draft.budgetId + ) } ); return; @@ -1638,7 +1862,11 @@ export class XpenserTelegramBot { ): Promise { const currency = value.trim().toUpperCase(); if ( - !preferredCurrencies(draft.me, draft.currencies).includes(currency) + !preferredCurrencies( + draft.me, + draft.currencies, + draft.budgetId + ).includes(currency) ) { await this.#bot.sendMessage( chatId, @@ -1648,7 +1876,10 @@ export class XpenserTelegramBot { } try { - const defaultCurrency = draft.me.defaultCurrency + const budget = activeBudget(draft.me, draft.budgetId); + const defaultCurrency = ( + budget?.defaultCurrency ?? draft.me.defaultCurrency + ) .trim() .toUpperCase(); const conversion = @@ -1664,6 +1895,7 @@ export class XpenserTelegramBot { : await (await this.userClient(user)).currencies.convert({ query: { amount: draft.amount, + budgetId: draft.budgetId, currency, occurredAt: new Date() } @@ -1686,6 +1918,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 +1936,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, @@ -1718,7 +1954,11 @@ export class XpenserTelegramBot { apiErrorMessage(err) ?? 'Could not get this exchange rate. Choose another currency or try again.', { - reply_markup: currencyKeyboard(draft.me, draft.currencies) + reply_markup: currencyKeyboard( + draft.me, + draft.currencies, + draft.budgetId + ) } ); } @@ -1734,6 +1974,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 +2005,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 +2041,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 +2120,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 +2195,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.test.ts b/apps/telegram-bot/src/flow.test.ts index dee5f62..da2332d 100644 --- a/apps/telegram-bot/src/flow.test.ts +++ b/apps/telegram-bot/src/flow.test.ts @@ -47,6 +47,7 @@ describe('telegram bot flow helpers', () => { const me = { defaultCurrency: 'USD', favoriteCurrencies: ['EUR', 'USD', 'GBP'], + hasUploadedAvatar: false, transactionCurrencies: ['EUR', 'USD', 'GBP'] } as UserPreference; const currencies = [ @@ -71,6 +72,7 @@ describe('telegram bot flow helpers', () => { const me = { defaultCurrency: 'USD', favoriteCurrencies: ['EUR'], + hasUploadedAvatar: false, transactionCurrencies: ['EUR', 'USD'] } as UserPreference; const currencies = [ diff --git a/apps/telegram-bot/src/flow.ts b/apps/telegram-bot/src/flow.ts index be0d06c..3609a27 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', @@ -116,13 +118,21 @@ export function scanImageSizeError( export function preferredCurrencies( me: UserPreference, - currencies: readonly Currency[] + currencies: readonly Currency[], + budgetId?: number ): string[] { const available = new Set(currencies.map(currency => currency.code)); + const budget = budgetId + ? me.budgets.find(candidate => candidate.id === budgetId) + : undefined; const configured = - me.transactionCurrencies.length > 0 - ? me.transactionCurrencies - : [me.defaultCurrency, ...me.favoriteCurrencies]; + budget && budget.transactionCurrencies.length > 0 + ? budget.transactionCurrencies + : budget + ? [budget.defaultCurrency, ...budget.favoriteCurrencies] + : me.transactionCurrencies.length > 0 + ? me.transactionCurrencies + : [me.defaultCurrency, ...me.favoriteCurrencies]; return Array.from(new Set(configured)).filter(currency => available.has(currency) ); @@ -130,9 +140,10 @@ export function preferredCurrencies( export function currencyKeyboard( me: UserPreference, - currencies: readonly Currency[] + currencies: readonly Currency[], + budgetId?: number ): InlineKeyboardMarkup { - const rows = preferredCurrencies(me, currencies).map(currency => [ + const rows = preferredCurrencies(me, currencies, budgetId).map(currency => [ { text: currency, callback_data: `cur:${currency}` } ]); diff --git a/apps/web/app/(app)/capture/page.tsx b/apps/web/app/(app)/capture/page.tsx index 1f60abb..ef26fd5 100644 --- a/apps/web/app/(app)/capture/page.tsx +++ b/apps/web/app/(app)/capture/page.tsx @@ -1,11 +1,18 @@ 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 transactionCurrencies = + selectedBudget?.transactionCurrencies ?? me.transactionCurrencies; const [ categories, currencies, @@ -13,12 +20,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,11 +45,11 @@ export default async function CapturePage() { recentTransactions.items )} currencies={currencies} - defaultCurrency={me.defaultCurrency} + defaultCurrency={defaultCurrency} vendors={vendors} transactionTags={transactionTags} timezone={me.timezone} - transactionCurrencies={me.transactionCurrencies} + transactionCurrencies={transactionCurrencies} /> ); diff --git a/apps/web/app/(app)/dashboard/page.tsx b/apps/web/app/(app)/dashboard/page.tsx index 057b6d9..397fd42 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,26 +21,41 @@ 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 favoriteCurrencies = + selectedBudget?.favoriteCurrencies ?? me.favoriteCurrencies; + const transactionCurrencies = + selectedBudget?.transactionCurrencies ?? me.transactionCurrencies; const displayCurrency = selectedDashboardCurrency( params.currency, - me.defaultCurrency, - me.favoriteCurrencies + defaultCurrency, + favoriteCurrencies ); const selectedDate = parseDateParam(params.date, me.timezone); const anchorDate = selectedDate ?? new Date(); 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,8 +73,8 @@ export default async function DashboardPage({ ); } diff --git a/apps/web/app/(app)/layout.tsx b/apps/web/app/(app)/layout.tsx index e5bc2ea..f1b79a1 100644 --- a/apps/web/app/(app)/layout.tsx +++ b/apps/web/app/(app)/layout.tsx @@ -1,7 +1,8 @@ import type { Metadata } from 'next'; import { AmountPrivacyProvider } from '@/components/amount-privacy'; import { AppNav } from '@/components/app-nav'; -import { getSessionOrRedirect } from '@/lib/api'; +import { getApiClient, getSessionOrRedirect } from '@/lib/api'; +import { selectedBudgetForUser } from '@/lib/budgets'; import { noIndexRobots } from '@/lib/public-site'; export const dynamic = 'force-dynamic'; @@ -15,10 +16,17 @@ export default async function ProtectedLayout({ readonly children: React.ReactNode; }) { const session = await getSessionOrRedirect(); + const client = await getApiClient(); + const me = await client.auth.me(); + const selectedBudget = await selectedBudgetForUser(me); return (
- +
{children}
diff --git a/apps/web/app/(app)/settings/budgets/[budgetId]/page.tsx b/apps/web/app/(app)/settings/budgets/[budgetId]/page.tsx new file mode 100644 index 0000000..9124108 --- /dev/null +++ b/apps/web/app/(app)/settings/budgets/[budgetId]/page.tsx @@ -0,0 +1,74 @@ +import type { Budget, BudgetAccessRow } from '@xpenser/contracts'; +import { notFound } from 'next/navigation'; +import { BudgetDetailSettings } from '@/components/budget-settings'; +import { getApiClient } from '@/lib/api'; + +type BudgetDetailPageProps = { + readonly params: Promise<{ readonly budgetId: string }>; +}; + +type ApiClient = Awaited>; + +async function loadBudgetAccessRows( + client: ApiClient, + budget: Budget +): Promise { + if (!budget.permissions.canManageMembers) { + return []; + } + + const [accessRows, members] = await Promise.all([ + client.budgets.access({ params: { id: budget.id } }), + client.budgets.members({ params: { id: budget.id } }) + ]); + const activeUserIds = new Set( + accessRows.flatMap(row => (row.status === 'active' ? [row.userId] : [])) + ); + return [ + ...members + .filter(member => !activeUserIds.has(member.userId)) + .map(member => ({ status: 'active' as const, ...member })), + ...accessRows + ]; +} + +export default async function BudgetDetailPage({ + params +}: BudgetDetailPageProps) { + const { budgetId } = await params; + const id = Number(budgetId); + if (!Number.isSafeInteger(id) || id <= 0) { + notFound(); + } + + const client = await getApiClient({ disableBatching: true }); + const [me, currencies, archivedBudgets] = await Promise.all([ + client.auth.me(), + client.currencies.list(), + client.budgets.list({ query: { status: 'archived' } }) + ]); + const budget = [...me.budgets, ...archivedBudgets].find( + item => item.id === id + ); + if (!budget) { + notFound(); + } + const accessRows = await loadBudgetAccessRows(client, budget); + + return ( +
+
+

{budget.name}

+

+ Manage your label, budget currencies, lifecycle, and access. +

+
+ +
+ ); +} diff --git a/apps/web/app/(app)/settings/budgets/page.tsx b/apps/web/app/(app)/settings/budgets/page.tsx new file mode 100644 index 0000000..a62190e --- /dev/null +++ b/apps/web/app/(app)/settings/budgets/page.tsx @@ -0,0 +1,28 @@ +import { BudgetSettings } from '@/components/budget-settings'; +import { getApiClient } from '@/lib/api'; + +export default async function BudgetsPage() { + const client = await getApiClient(); + const [me, currencies, archivedBudgets] = await Promise.all([ + client.auth.me(), + client.currencies.list(), + client.budgets.list({ query: { status: 'archived' } }) + ]); + + return ( +
+
+

Budgets

+

+ Manage personal labels, currencies, sharing, and archived + budgets. +

+
+ +
+ ); +} 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..0dccbc2 100644 --- a/apps/web/app/(app)/settings/preferences/page.tsx +++ b/apps/web/app/(app)/settings/preferences/page.tsx @@ -7,10 +7,17 @@ import { CardHeader, CardTitle } from '@xpenser/ui'; -import { FolderTreeIcon, Send, StoreIcon, Unlink } from 'lucide-react'; +import { + FolderTreeIcon, + Send, + StoreIcon, + Unlink, + WalletCardsIcon +} from 'lucide-react'; import Link from 'next/link'; import { ApiKeysSettings } from '@/components/api-keys-settings'; import { PreferencesForm } from '@/components/forms/preferences-form'; +import { UserAvatarSettings } from '@/components/user-avatar-settings'; import { createTelegramLinkAction, disconnectTelegramAction @@ -20,14 +27,12 @@ import { publicAppUrl } from '@/lib/public-url'; export default async function PreferencesPage() { const client = await getApiClient(); - const [me, currencies, telegram, apiKeys, mcpConnections] = - await Promise.all([ - client.auth.me(), - client.currencies.list(), - client.users.telegramStatus(), - client.users.listApiKeys(), - client.users.listMcpOAuthConnections() - ]); + const [me, telegram, apiKeys, mcpConnections] = await Promise.all([ + client.auth.me(), + client.users.telegramStatus(), + client.users.listApiKeys(), + client.users.listMcpOAuthConnections() + ]); const telegramName = telegram.telegramUsername ? `@${telegram.telegramUsername}` : [telegram.telegramFirstName, telegram.telegramLastName] @@ -40,14 +45,37 @@ export default async function PreferencesPage() { User preferences - Default currency affects future conversions. Time zone - affects transaction display and reports. + Country localizes vendor enrichment. Time zone affects + transaction display and reports. - + + + + +
+
+ Budgets + + Manage budget names, currencies, sharing, and + lifecycle settings. + +
+ +
+
+
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..dfa617a 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,12 @@ 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 favoriteCurrencies = + selectedBudget?.favoriteCurrencies ?? me.favoriteCurrencies; const selectedDate = parseDateParam(params.date, me.timezone); const anchorDate = selectedDate ?? new Date(); const initialView = reportView(params.view); @@ -60,6 +67,7 @@ export default async function StatsPage({ client.currencies.list(), client.stats.window({ query: { + ...budgetQuery, after: 2, before: 2, period, @@ -71,6 +79,7 @@ export default async function StatsPage({ initialView === 'tags' ? await client.stats.tags({ query: { + ...budgetQuery, period, ...(selectedDate ? { date: selectedDate } : {}), ...(initialTag ? { tag: initialTag } : {}) @@ -81,8 +90,8 @@ export default async function StatsPage({ return (
diff --git a/apps/web/app/(app)/vendors/page.tsx b/apps/web/app/(app)/vendors/page.tsx index b4585c5..75ba269 100644 --- a/apps/web/app/(app)/vendors/page.tsx +++ b/apps/web/app/(app)/vendors/page.tsx @@ -1,5 +1,6 @@ import { VendorsExplorer } from '@/components/vendors-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,26 +21,41 @@ export default async function VendorsPage({ 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 favoriteCurrencies = + selectedBudget?.favoriteCurrencies ?? me.favoriteCurrencies; + const transactionCurrencies = + selectedBudget?.transactionCurrencies ?? me.transactionCurrencies; const displayCurrency = selectedDashboardCurrency( params.currency, - me.defaultCurrency, - me.favoriteCurrencies + defaultCurrency, + favoriteCurrencies ); const selectedDate = parseDateParam(params.date, me.timezone); const anchorDate = selectedDate ?? new Date(); 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: vendorAnalyticsVendorLimit, @@ -53,8 +69,8 @@ export default async function VendorsPage({ ); } diff --git a/apps/web/app/app-api/dashboard/window/route.ts b/apps/web/app/app-api/dashboard/window/route.ts index 51dd285..bf12ad8 100644 --- a/apps/web/app/app-api/dashboard/window/route.ts +++ b/apps/web/app/app-api/dashboard/window/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 { dashboardPeriodWindowQuery } from '@/lib/period-window-query'; @@ -27,12 +28,16 @@ export async function GET(request: NextRequest) { }); try { + const budgetId = await selectedBudgetIdFromCookie(); return NextResponse.json( await client.dashboard.window({ - query: dashboardPeriodWindowQuery( - request.nextUrl.searchParams, - session.user.timezone - ) + query: { + ...dashboardPeriodWindowQuery( + request.nextUrl.searchParams, + session.user.timezone + ), + ...(budgetId ? { budgetId } : {}) + } }) ); } catch (err) { diff --git a/apps/web/app/app-api/stats/tags/route.ts b/apps/web/app/app-api/stats/tags/route.ts index 23faf1a..3ce48d1 100644 --- a/apps/web/app/app-api/stats/tags/route.ts +++ b/apps/web/app/app-api/stats/tags/route.ts @@ -2,6 +2,7 @@ import { createXpenserClient } from '@xpenser/client'; import type { StatsTagReportQuery } from '@xpenser/contracts'; import { type NextRequest, NextResponse } from 'next/server'; import { getCurrentSession } from '@/lib/api'; +import { selectedBudgetIdFromCookie } from '@/lib/budgets'; import { webConfig } from '@/lib/config'; import { isDashboardPeriod, parseDateParam } from '@/lib/dashboard-periods'; @@ -49,12 +50,16 @@ export async function GET(request: NextRequest) { }); try { + const budgetId = await selectedBudgetIdFromCookie(); return NextResponse.json( await client.stats.tags({ - query: tagReportQuery( - request.nextUrl.searchParams, - session.user.timezone - ) + query: { + ...tagReportQuery( + request.nextUrl.searchParams, + session.user.timezone + ), + ...(budgetId ? { budgetId } : {}) + } }) ); } catch (err) { diff --git a/apps/web/app/app-api/stats/window/route.ts b/apps/web/app/app-api/stats/window/route.ts index 0251e36..06b4c11 100644 --- a/apps/web/app/app-api/stats/window/route.ts +++ b/apps/web/app/app-api/stats/window/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 { periodWindowQuery } from '@/lib/period-window-query'; @@ -27,12 +28,16 @@ export async function GET(request: NextRequest) { }); try { + const budgetId = await selectedBudgetIdFromCookie(); return NextResponse.json( await client.stats.window({ - query: periodWindowQuery( - request.nextUrl.searchParams, - session.user.timezone - ) + query: { + ...periodWindowQuery( + request.nextUrl.searchParams, + session.user.timezone + ), + ...(budgetId ? { budgetId } : {}) + } }) ); } catch (err) { diff --git a/apps/web/app/app-api/transaction-scans/route.test.ts b/apps/web/app/app-api/transaction-scans/route.test.ts index dd758aa..35673d0 100644 --- a/apps/web/app/app-api/transaction-scans/route.test.ts +++ b/apps/web/app/app-api/transaction-scans/route.test.ts @@ -5,6 +5,7 @@ import { deleteScanUpload } from '@/lib/transaction-scan-upload-store'; const mocks = vi.hoisted(() => ({ 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/app-api/users/[userId]/avatar/route.ts b/apps/web/app/app-api/users/[userId]/avatar/route.ts new file mode 100644 index 0000000..a468d45 --- /dev/null +++ b/apps/web/app/app-api/users/[userId]/avatar/route.ts @@ -0,0 +1,43 @@ +import { type NextRequest, NextResponse } from 'next/server'; +import { getCurrentSession } from '@/lib/api'; +import { webConfig } from '@/lib/config'; + +export const dynamic = 'force-dynamic'; +export const runtime = 'nodejs'; + +type AvatarRouteContext = { + readonly params: + | { readonly userId: string } + | Promise<{ readonly userId: string }>; +}; + +export async function GET(_request: NextRequest, context: AvatarRouteContext) { + const [{ userId }, session] = await Promise.all([ + Promise.resolve(context.params), + getCurrentSession() + ]); + const id = Number(userId); + if (!session?.apiToken || !Number.isSafeInteger(id) || id <= 0) { + return NextResponse.json({ message: 'Not found' }, { status: 404 }); + } + + const upstream = await fetch( + new URL(`/api/users/${id}/avatar`, webConfig.apiBaseUrl), + { + headers: { + Authorization: `Bearer ${session.apiToken}` + } + } + ); + if (!upstream.ok) { + return NextResponse.json({ message: 'Not found' }, { status: 404 }); + } + + const headers = new Headers(upstream.headers); + headers.delete('content-encoding'); + headers.delete('transfer-encoding'); + return new NextResponse(upstream.body, { + status: upstream.status, + headers + }); +} 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..2e101ac --- /dev/null +++ b/apps/web/app/budgets/invitations/accept/page.tsx @@ -0,0 +1,79 @@ +import { + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + Input +} 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 + + +
+
+ setQuery(event.currentTarget.value)} + onKeyDown={event => { + if (event.key === 'Enter' || event.key === ',') { + event.preventDefault(); + addCurrency(query); + } + }} + placeholder="Search currency" + value={query} + /> + +
+ + {suggestions.slice(0, 20).map(currency => ( + +
+ ); +} + +export function BudgetCurrencyFields({ + currencies, + defaultCurrency, + idPrefix, + selectedCurrencies +}: { + readonly currencies: readonly Currency[]; + readonly defaultCurrency: string; + readonly idPrefix?: string; + readonly selectedCurrencies: readonly string[]; +}) { + const generatedId = useId(); + const fieldId = idPrefix ?? generatedId; + const [primary, setPrimary] = useState(defaultCurrency); + + return ( + <> + + + + ); +} diff --git a/apps/web/components/budget-selector.tsx b/apps/web/components/budget-selector.tsx new file mode 100644 index 0000000..8ae8efe --- /dev/null +++ b/apps/web/components/budget-selector.tsx @@ -0,0 +1,46 @@ +'use client'; + +import type { Budget } from '@xpenser/contracts'; +import { usePathname, useSearchParams } from 'next/navigation'; +import { selectBudgetAction } from '@/lib/actions'; + +export function BudgetSelector({ + budgets, + selectedBudgetId +}: { + readonly budgets: readonly Budget[]; + readonly selectedBudgetId?: number; +}) { + const pathname = usePathname(); + const searchParams = useSearchParams(); + if (budgets.length === 0) { + return null; + } + + const query = searchParams.toString(); + const returnTo = query ? `${pathname}?${query}` : pathname; + + return ( +
+ + + + + ); +} diff --git a/apps/web/components/budget-settings.test.tsx b/apps/web/components/budget-settings.test.tsx new file mode 100644 index 0000000..7f3203f --- /dev/null +++ b/apps/web/components/budget-settings.test.tsx @@ -0,0 +1,178 @@ +/** + * @vitest-environment jsdom + */ + +import { render, screen } from '@testing-library/react'; +import type { Budget, BudgetMember, Currency } from '@xpenser/contracts'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { BudgetDetailSettings, BudgetSettings } from './budget-settings'; + +const archiveBudgetAction = vi.fn(); +const createBudgetAction = vi.fn(); +const deleteBudgetAction = vi.fn(); +const inviteBudgetMemberAction = vi.fn(); +const removeBudgetMemberAction = vi.fn(); +const restoreBudgetAction = vi.fn(); +const updateBudgetAction = vi.fn(); +const updateBudgetMemberAction = vi.fn(); + +vi.mock('@/lib/actions', () => ({ + archiveBudgetAction: (formData: FormData) => archiveBudgetAction(formData), + createBudgetAction: (formData: FormData) => createBudgetAction(formData), + deleteBudgetAction: (formData: FormData) => deleteBudgetAction(formData), + inviteBudgetMemberAction: (formData: FormData) => + inviteBudgetMemberAction(formData), + removeBudgetMemberAction: (formData: FormData) => + removeBudgetMemberAction(formData), + restoreBudgetAction: (formData: FormData) => restoreBudgetAction(formData), + updateBudgetAction: (formData: FormData) => updateBudgetAction(formData), + updateBudgetMemberAction: (formData: FormData) => + updateBudgetMemberAction(formData) +})); + +const timestamp = new Date('2026-06-01T00:00:00.000Z'); +const currencies: Currency[] = [ + { code: 'USD', name: 'US dollar' }, + { code: 'EUR', name: 'Euro' } +]; + +function budget(overrides: Partial = {}): Budget { + return { + id: 1, + name: 'Main', + defaultCurrency: 'USD', + favoriteCurrencies: [], + transactionCurrencies: ['USD'], + countryCode: 'US', + role: 'admin', + permissions: { + canCreateTransactions: true, + canUpdateTransactions: true, + canDeleteTransactions: true, + canManageCategories: true, + canManageVendors: true, + canManageTags: true, + canManageMembers: true + }, + isMain: true, + archivedAt: null, + createdAt: timestamp, + updatedAt: timestamp, + ...overrides + }; +} + +function member(overrides: Partial = {}): BudgetMember { + return { + budgetId: 1, + userId: 1, + email: 'owner@example.com', + user: { + userId: 1, + email: 'owner@example.com', + displayName: 'Owner' + }, + role: 'admin', + permissions: { + canCreateTransactions: true, + canUpdateTransactions: true, + canDeleteTransactions: true, + canManageCategories: true, + canManageVendors: true, + canManageTags: true, + canManageMembers: true + }, + createdAt: timestamp, + updatedAt: timestamp, + ...overrides + }; +} + +function renderSettings({ + activeBudgets = [budget()], + archivedBudgets = [] +}: { + readonly activeBudgets?: readonly Budget[]; + readonly archivedBudgets?: readonly Budget[]; +} = {}) { + return render( + + ); +} + +function renderDetail(selectedBudget = budget(), rows = [member()]) { + return render( + ({ + status: 'active' as const, + ...row + }))} + budget={selectedBudget} + currencies={currencies} + currentUserId={1} + /> + ); +} + +describe('BudgetSettings', () => { + afterEach(() => { + archiveBudgetAction.mockReset(); + createBudgetAction.mockReset(); + deleteBudgetAction.mockReset(); + inviteBudgetMemberAction.mockReset(); + removeBudgetMemberAction.mockReset(); + restoreBudgetAction.mockReset(); + updateBudgetAction.mockReset(); + updateBudgetMemberAction.mockReset(); + }); + + it('creates budgets from name and budget currencies only', () => { + renderSettings(); + + expect(screen.getByPlaceholderText('Shared household')).toBeTruthy(); + expect(screen.getAllByText('Primary').length).toBeGreaterThan(0); + expect( + screen.getAllByText('Favorite currencies').length + ).toBeGreaterThan(0); + expect(screen.queryByLabelText('Country')).toBeNull(); + }); + + it('lets admins invite members to Main with a personal budget name', () => { + renderDetail(); + + expect(screen.getByLabelText('Invite email')).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Invite' })).toBeTruthy(); + expect(screen.getByText('owner@example.com')).toBeTruthy(); + expect(screen.getByText('Active')).toBeTruthy(); + }); + + it('shows archive controls for active budgets and restore/delete for archived budgets', () => { + renderDetail( + budget({ + id: 2, + isMain: false, + name: 'Travel' + }) + ); + + expect(screen.getByRole('button', { name: 'Archive' })).toBeTruthy(); + }); + + it('shows restore/delete for archived budget details', () => { + renderDetail( + budget({ + archivedAt: new Date('2026-06-02T00:00:00.000Z'), + id: 3, + isMain: false, + name: 'Old travel' + }) + ); + + expect(screen.getByRole('button', { name: 'Restore' })).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Delete' })).toBeTruthy(); + }); +}); diff --git a/apps/web/components/budget-settings.tsx b/apps/web/components/budget-settings.tsx new file mode 100644 index 0000000..2f7002e --- /dev/null +++ b/apps/web/components/budget-settings.tsx @@ -0,0 +1,497 @@ +import type { + Budget, + BudgetAccessRow, + BudgetMember, + Currency +} from '@xpenser/contracts'; +import { + Badge, + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + Input +} from '@xpenser/ui'; +import { SettingsIcon } from 'lucide-react'; +import Link from 'next/link'; +import { BudgetCurrencyFields } from '@/components/budget-currency-fields'; +import { + archiveBudgetAction, + createBudgetAction, + deleteBudgetAction, + inviteBudgetMemberAction, + removeBudgetMemberAction, + restoreBudgetAction, + updateBudgetAction, + updateBudgetMemberAction +} from '@/lib/actions'; + +const permissionOptions = [ + ['canCreateTransactions', 'Add transactions', true], + ['canUpdateTransactions', 'Edit transactions', false], + ['canDeleteTransactions', 'Delete transactions', false], + ['canManageCategories', 'Manage categories', false], + ['canManageVendors', 'Manage vendors', false], + ['canManageTags', 'Manage tags', false], + ['canManageMembers', 'Manage members', false] +] as const; + +function roleLabel(role: Budget['role']) { + return role === 'admin' ? 'Admin' : 'Member'; +} + +function permissionSummary(member: Pick) { + if (member.role === 'admin') { + return 'Full access'; + } + const enabled = permissionOptions + .filter(([key]) => member.permissions[key]) + .map(([, label]) => label); + return enabled.length > 0 ? enabled.join(', ') : 'View only'; +} + +function BudgetBadges({ budget }: { readonly budget: Budget }) { + return ( +
+

{budget.name}

+ {roleLabel(budget.role)} + {budget.isMain ? Main : null} + {budget.archivedAt ? ( + Archived + ) : null} +
+ ); +} + +function BudgetDetails({ budget }: { readonly budget: Budget }) { + const currencies = [budget.defaultCurrency, ...budget.favoriteCurrencies]; + return ( +

+ {currencies.join(', ')} +

+ ); +} + +function BudgetOverviewRow({ budget }: { readonly budget: Budget }) { + return ( +
+
+
+ + +
+ +
+
+ ); +} + +export function BudgetSettings({ + archivedBudgets, + budgets, + currencies +}: { + readonly archivedBudgets: readonly Budget[]; + readonly budgets: readonly Budget[]; + readonly currencies: readonly Currency[]; +}) { + const defaultCurrency = + budgets.find(budget => budget.isMain)?.defaultCurrency ?? + budgets[0]?.defaultCurrency ?? + 'USD'; + + return ( + + + Budgets + + Separate transaction spaces and invite other users to shared + budgets. + + + +
+ + + + + +
+ {budgets.map(budget => ( + + ))} +
+ + {archivedBudgets.length > 0 ? ( +
+

+ Archived budgets +

+ {archivedBudgets.map(budget => ( + + ))} +
+ ) : null} +
+
+ ); +} + +function BudgetNameForm({ budget }: { readonly budget: Budget }) { + return ( +
+ + + + + ); +} + +function BudgetCurrencyForm({ + budget, + currencies +}: { + readonly budget: Budget; + readonly currencies: readonly Currency[]; +}) { + return ( +
+ + + + + ); +} + +function LifecycleActions({ budget }: { readonly budget: Budget }) { + if (budget.isMain) { + return null; + } + if (budget.archivedAt) { + return ( +
+
+ + + +
+ + + +
+ ); + } + return ( +
+ + + + ); +} + +function PermissionCheckboxes({ + member +}: { + readonly member?: Pick; +}) { + return ( +
+ {permissionOptions.map(([key, label, defaultChecked]) => ( + + ))} +
+ ); +} + +function ActiveAccessRow({ + budgetId, + currentUserId, + row +}: { + readonly budgetId: number; + readonly currentUserId: number; + readonly row: Extract; +}) { + return ( +
+
+
+

{row.email}

+

+ {roleLabel(row.role)} - {permissionSummary(row)} +

+
+ Active +
+
+ + + + + + + {row.userId === currentUserId ? null : ( +
+ + + + + )} +
+ ); +} + +function InvitationAccessRow({ + row +}: { + readonly row: Extract< + BudgetAccessRow, + { readonly status: 'pending' | 'expired' | 'accepted' } + >; +}) { + return ( +
+
+

{row.email}

+

+ {roleLabel(row.role)} - {permissionSummary(row)} +

+
+ + {row.status.charAt(0).toUpperCase() + row.status.slice(1)} + +
+ ); +} + +function AccessList({ + accessRows, + budgetId, + currentUserId +}: { + readonly accessRows: readonly BudgetAccessRow[]; + readonly budgetId: number; + readonly currentUserId: number; +}) { + return ( +
+ {accessRows.map(row => + row.status === 'active' ? ( + + ) : ( + + ) + )} +
+ ); +} + +function InviteForm({ budget }: { readonly budget: Budget }) { + return ( +
+ +
+ + + +
+ + + ); +} + +export function BudgetDetailSettings({ + accessRows, + budget, + currencies, + currentUserId +}: { + readonly accessRows: readonly BudgetAccessRow[]; + readonly budget: Budget; + readonly currencies: readonly Currency[]; + readonly currentUserId: number; +}) { + const canManage = budget.permissions.canManageMembers; + const editable = canManage && !budget.archivedAt; + + return ( +
+ + +
+
+ + +
+ {canManage ? ( + + ) : null} +
+
+ + + +
+ + {editable ? ( + + + Budget currencies + + Set the primary reporting currency and quick-pick + currencies for this budget. + + + + + + + ) : null} + + {canManage ? ( + + + Access + + Manage active users and review invitation statuses. + + + + + {editable ? : null} + + + ) : null} +
+ ); +} diff --git a/apps/web/components/category-manager.test.tsx b/apps/web/components/category-manager.test.tsx index 03fe8e1..5984cea 100644 --- a/apps/web/components/category-manager.test.tsx +++ b/apps/web/components/category-manager.test.tsx @@ -46,6 +46,7 @@ function category( ): Category { return { id, + budgetId: 1, name, type: 'expense', parentId: null, diff --git a/apps/web/components/contributor-avatars.tsx b/apps/web/components/contributor-avatars.tsx new file mode 100644 index 0000000..c775f1b --- /dev/null +++ b/apps/web/components/contributor-avatars.tsx @@ -0,0 +1,42 @@ +'use client'; + +import type { UserAvatarSummary } from '@xpenser/contracts'; +import { UserAvatar } from './user-avatar'; + +export function ContributorAvatars({ + className = '', + contributors, + otherContributorCount +}: { + readonly className?: string; + readonly contributors: readonly UserAvatarSummary[]; + readonly otherContributorCount: number; +}) { + const visible = contributors.slice(0, 3); + const hiddenCount = + Math.max(0, contributors.length - visible.length) + + Math.max(0, otherContributorCount); + if (visible.length === 0 && hiddenCount === 0) { + return null; + } + + return ( +
+ {visible.map(user => ( + + ))} + {hiddenCount > 0 ? ( + + +{hiddenCount} + + ) : null} +
+ ); +} diff --git a/apps/web/components/dashboard-explorer.test.tsx b/apps/web/components/dashboard-explorer.test.tsx index 387573c..d1d044a 100644 --- a/apps/web/components/dashboard-explorer.test.tsx +++ b/apps/web/components/dashboard-explorer.test.tsx @@ -33,6 +33,8 @@ function category( transactionCount: 2, trend: [100], type: 'expense', + contributors: [], + otherContributorCount: 0, ...overrides }; } @@ -58,7 +60,9 @@ function categoryVendor( type: categoryRow.type, total, transactionCount: 1, - trend: [total] + trend: [total], + contributors: [], + otherContributorCount: 0 }; } diff --git a/apps/web/components/dashboard-explorer.tsx b/apps/web/components/dashboard-explorer.tsx index 8a91224..1fbcf38 100644 --- a/apps/web/components/dashboard-explorer.tsx +++ b/apps/web/components/dashboard-explorer.tsx @@ -14,6 +14,7 @@ import Link from 'next/link'; import { useEffect, useMemo, useState } from 'react'; import { AddTransactionDialog } from '@/components/add-transaction-dialog'; import { AmountDisplay } from '@/components/amount-display'; +import { ContributorAvatars } from '@/components/contributor-avatars'; import { type DashboardExpansionAction, DashboardExpansionButton @@ -123,6 +124,11 @@ function fallbackDashboardParentCategory( 0 ) ), + contributors: categories.flatMap(category => category.contributors), + otherContributorCount: categories.reduce( + (sum, category) => sum + category.otherContributorCount, + 0 + ), type }; } @@ -375,8 +381,16 @@ function CategoryRow({ )} - - {displayLabel} + + + {displayLabel} + + {category.transactionCount}{' '} @@ -499,8 +513,14 @@ function CategoryVendorRow({ size="sm" /> - - {item.vendorName} + + + {item.vendorName} + + {item.vendorDomain diff --git a/apps/web/components/forms/preferences-form.test.tsx b/apps/web/components/forms/preferences-form.test.tsx index e90b3a4..782c6b2 100644 --- a/apps/web/components/forms/preferences-form.test.tsx +++ b/apps/web/components/forms/preferences-form.test.tsx @@ -3,7 +3,7 @@ */ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -import type { Currency, UserPreference } from '@xpenser/contracts'; +import type { UserPreference } from '@xpenser/contracts'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { PreferencesForm } from './preferences-form'; import { XpenserWebFormProvider } from './schema-fields'; @@ -15,11 +15,6 @@ vi.mock('@/lib/actions', () => ({ updatePreferencesAction(formData) })); -const currencies: Currency[] = [ - { code: 'USD', name: 'US dollar' }, - { code: 'EUR', name: 'Euro' } -]; - const me: UserPreference = { id: 1, email: 'jane@example.com', @@ -29,6 +24,32 @@ const me: UserPreference = { transactionCurrencies: ['USD', 'EUR'], timezone: 'UTC', hasCategories: true, + hasUploadedAvatar: false, + mainBudgetId: 1, + budgets: [ + { + id: 1, + name: 'Main', + defaultCurrency: 'USD', + favoriteCurrencies: ['EUR'], + transactionCurrencies: ['USD', 'EUR'], + countryCode: 'US', + role: 'admin', + permissions: { + canCreateTransactions: true, + canUpdateTransactions: true, + canDeleteTransactions: true, + canManageCategories: true, + canManageVendors: true, + canManageTags: true, + canManageMembers: true + }, + isMain: true, + archivedAt: null, + createdAt: new Date('2026-05-01T00:00:00.000Z'), + updatedAt: new Date('2026-05-01T00:00:00.000Z') + } + ], weeklyEmailReportEnabled: true, monthlyEmailReportEnabled: true }; @@ -43,7 +64,7 @@ describe('PreferencesForm', () => { render( - + ); @@ -60,7 +81,6 @@ describe('PreferencesForm', () => { const formData = updatePreferencesAction.mock.calls.at(0)?.[0]; expect(formData).toBeInstanceOf(FormData); - expect(formData?.getAll('favoriteCurrencies')).toEqual(['EUR']); expect(formData?.get('weeklyEmailReportEnabled')).toBe('false'); expect(formData?.get('monthlyEmailReportEnabled')).toBe('true'); }); diff --git a/apps/web/components/forms/preferences-form.tsx b/apps/web/components/forms/preferences-form.tsx index 6395853..dcff98a 100644 --- a/apps/web/components/forms/preferences-form.tsx +++ b/apps/web/components/forms/preferences-form.tsx @@ -2,7 +2,6 @@ import { Field as SchemaField, useSchemaForm } from '@cleverbrush/react-form'; import { - type Currency, UpdateUserPreferenceBodySchema, type UserPreference } from '@xpenser/contracts'; @@ -20,32 +19,14 @@ import { import { type FormEvent, useEffect, useMemo, useState } from 'react'; import { updatePreferencesAction } from '@/lib/actions'; import { countryLabel, supportedCountries } from '@/lib/countries'; -import { sortCurrenciesForDisplay } from '@/lib/currency-display'; import { supportedTimeZones, timeZoneLabel } from '@/lib/timezones'; -import { CurrencyOption } from './currency-option'; import { isNextRedirectError, valuesToFormData } from './form-utils'; -import type { CurrencyMultiSelectRendererFieldProps } from './schema-fields'; -export function PreferencesForm({ - me, - currencies -}: { - readonly me: UserPreference; - readonly currencies: readonly Currency[]; -}) { +export function PreferencesForm({ me }: { readonly me: UserPreference }) { const form = useSchemaForm(UpdateUserPreferenceBodySchema); - const [selectedDefaultCurrency, setSelectedDefaultCurrency] = useState( - me.defaultCurrency - ); const [selectedCountryCode, setSelectedCountryCode] = useState( me.countryCode ); - const [selectedFavoriteCurrencies, setSelectedFavoriteCurrencies] = - useState( - me.favoriteCurrencies.filter( - currency => currency !== me.defaultCurrency - ) - ); const [selectedTimezone, setSelectedTimezone] = useState(me.timezone); const [ selectedWeeklyEmailReportEnabled, @@ -58,38 +39,24 @@ export function PreferencesForm({ const [formVersion, setFormVersion] = useState(0); const [error, setError] = useState(null); const [pending, setPending] = useState(false); - const sortedCurrencies = useMemo( - () => sortCurrenciesForDisplay(currencies), - [currencies] - ); const timeZones = useMemo(() => supportedTimeZones(), []); const countries = useMemo(() => supportedCountries(), []); useEffect(() => { - const nextFavoriteCurrencies = me.favoriteCurrencies.filter( - currency => currency !== me.defaultCurrency - ); - form.reset({ - defaultCurrency: me.defaultCurrency, countryCode: me.countryCode, - favoriteCurrencies: nextFavoriteCurrencies, timezone: me.timezone, weeklyEmailReportEnabled: me.weeklyEmailReportEnabled, monthlyEmailReportEnabled: me.monthlyEmailReportEnabled }); - setSelectedDefaultCurrency(me.defaultCurrency); setSelectedCountryCode(me.countryCode); - setSelectedFavoriteCurrencies(nextFavoriteCurrencies); setSelectedTimezone(me.timezone); setSelectedWeeklyEmailReportEnabled(me.weeklyEmailReportEnabled); setSelectedMonthlyEmailReportEnabled(me.monthlyEmailReportEnabled); setFormVersion(version => version + 1); }, [ form, - me.defaultCurrency, me.countryCode, - me.favoriteCurrencies, me.monthlyEmailReportEnabled, me.timezone, me.weeklyEmailReportEnabled @@ -98,13 +65,8 @@ export function PreferencesForm({ async function handleSubmit(event: FormEvent) { event.preventDefault(); - const favoriteCurrencies = selectedFavoriteCurrencies.filter( - currency => currency !== selectedDefaultCurrency - ); form.setValue({ - defaultCurrency: selectedDefaultCurrency, countryCode: selectedCountryCode, - favoriteCurrencies, timezone: selectedTimezone, weeklyEmailReportEnabled: selectedWeeklyEmailReportEnabled, monthlyEmailReportEnabled: selectedMonthlyEmailReportEnabled @@ -117,9 +79,7 @@ export function PreferencesForm({ setPending(true); setError(null); try { - await updatePreferencesAction( - valuesToFormData({ ...result.object, favoriteCurrencies }) - ); + await updatePreferencesAction(valuesToFormData(result.object)); } catch (caught) { if (isNextRedirectError(caught)) { throw caught; @@ -137,36 +97,6 @@ export function PreferencesForm({ Email - { - const nextFavoriteCurrencies = - selectedFavoriteCurrencies.filter( - currency => currency !== value - ); - - field.onChange(value); - setSelectedDefaultCurrency(value); - setSelectedFavoriteCurrencies( - nextFavoriteCurrencies - ); - form.setValue({ - favoriteCurrencies: nextFavoriteCurrencies - }); - }, - options: sortedCurrencies.map(currency => ({ - label: , - value: currency.code - })), - value: selectedDefaultCurrency - } satisfies SelectRendererFieldProps - } - forProperty={field => field.defaultCurrency} - form={form} - label="Default currency" - variant="select" - /> - { - field.onChange(values); - setSelectedFavoriteCurrencies(values); - }, - selectedCurrencies: selectedFavoriteCurrencies - } satisfies CurrencyMultiSelectRendererFieldProps - } - forProperty={field => field.favoriteCurrencies} - form={form} - variant="currency-multi-select" - /> { + it('renders inline blog screenshots with accessible image text', () => { + const MdxImage = mdxComponents.img; + + render( + + + + Shared budgets keep each transaction inside a selected + workspace. + + + ); + + const image = screen.getByRole('img', { + name: 'Shared budget overview screenshot' + }); + + expect(image.getAttribute('src')).toContain( + 'shared-budgets-overview.png' + ); + expect(image.getAttribute('loading')).toBe('lazy'); + expect( + screen.getByText( + 'Shared budgets keep each transaction inside a selected workspace.' + ) + ).toBeTruthy(); + }); +}); diff --git a/apps/web/components/mdx-components.tsx b/apps/web/components/mdx-components.tsx index be5c43f..106ae8c 100644 --- a/apps/web/components/mdx-components.tsx +++ b/apps/web/components/mdx-components.tsx @@ -1,3 +1,4 @@ +import Image from 'next/image'; import Link from 'next/link'; import type { ComponentProps } from 'react'; @@ -9,12 +10,22 @@ export const mdxComponents = { {...props} /> ), + figcaption: (props: ComponentProps<'figcaption'>) => ( +
+ ), + figure: (props: ComponentProps<'figure'>) => ( +
+ ), h2: (props: ComponentProps<'h2'>) => (

), h3: (props: ComponentProps<'h3'>) => (

), + img: MdxImage, li: (props: ComponentProps<'li'>) => (
  • ), @@ -35,6 +46,25 @@ export const mdxComponents = { ) } as const; +function MdxImage({ alt, src, title }: ComponentProps<'img'>) { + if (typeof src !== 'string') { + return null; + } + + return ( + + ); +} + function MdxLink({ href, ...props }: ComponentProps<'a'>) { const className = 'font-medium text-primary underline-offset-4 hover:underline'; diff --git a/apps/web/components/quick-capture-form.test.tsx b/apps/web/components/quick-capture-form.test.tsx index 30cebf8..6317295 100644 --- a/apps/web/components/quick-capture-form.test.tsx +++ b/apps/web/components/quick-capture-form.test.tsx @@ -42,6 +42,7 @@ function category( ): Category { return { id, + budgetId: 1, name, type, parentId: null, @@ -69,6 +70,7 @@ const currencies: Currency[] = [ function savedTransaction(): Transaction { return { id: 42, + budgetId: 1, categoryId: 7, vendorId: null, categoryName: 'Groceries', @@ -84,6 +86,10 @@ function savedTransaction(): Transaction { exchangeRateDate: '2026-05-10', occurredAt: timestamp, tags: [], + createdBy: { + userId: 1, + email: 'test@cleverbrush.com' + }, createdAt: timestamp, updatedAt: timestamp }; @@ -92,10 +98,13 @@ function savedTransaction(): Transaction { function vendor(overrides: Partial = {}): Vendor { return { id: 5, + budgetId: 1, name: 'Walmart', displayName: 'Walmart', domain: 'walmart.com', transactionCount: 0, + contributors: [], + otherContributorCount: 0, createdAt: timestamp, updatedAt: timestamp, ...overrides diff --git a/apps/web/components/transaction-dialog.test.tsx b/apps/web/components/transaction-dialog.test.tsx index b5812f2..339d84a 100644 --- a/apps/web/components/transaction-dialog.test.tsx +++ b/apps/web/components/transaction-dialog.test.tsx @@ -31,6 +31,7 @@ describe('TransactionDialog', () => { action={action} categories={[ { + budgetId: 1, createdAt: new Date('2026-05-01T00:00:00.000Z'), displayName: 'Groceries', hasChildren: false, @@ -58,6 +59,7 @@ describe('TransactionDialog', () => { tags: [ { id: 1, + budgetId: 1, name: 'wife', transactionCount: 1, createdAt: new Date('2026-05-01T00:00:00.000Z'), diff --git a/apps/web/components/transaction-scan-capture.test.tsx b/apps/web/components/transaction-scan-capture.test.tsx index d53c87d..b0fb5c8 100644 --- a/apps/web/components/transaction-scan-capture.test.tsx +++ b/apps/web/components/transaction-scan-capture.test.tsx @@ -32,6 +32,7 @@ const timestamp = new Date('2026-06-01T12:00:00.000Z'); function category(overrides: Partial = {}): Category { return { id: 7, + budgetId: 1, name: 'Groceries', type: 'expense', parentId: null, diff --git a/apps/web/components/transaction-tag-picker.test.tsx b/apps/web/components/transaction-tag-picker.test.tsx index 1b01fd5..678faf8 100644 --- a/apps/web/components/transaction-tag-picker.test.tsx +++ b/apps/web/components/transaction-tag-picker.test.tsx @@ -13,6 +13,7 @@ const timestamp = new Date('2026-06-01T00:00:00.000Z'); function tag(overrides: Partial = {}): TransactionTag { return { id: 1, + budgetId: 1, name: 'wife', transactionCount: 2, createdAt: timestamp, diff --git a/apps/web/components/transactions-browser.test.tsx b/apps/web/components/transactions-browser.test.tsx new file mode 100644 index 0000000..1600333 --- /dev/null +++ b/apps/web/components/transactions-browser.test.tsx @@ -0,0 +1,126 @@ +/** + * @vitest-environment jsdom + */ + +import { render, screen } from '@testing-library/react'; +import type { + Category, + Currency, + Transaction, + TransactionTag, + Vendor +} from '@xpenser/contracts'; +import { describe, expect, it, vi } from 'vitest'; +import { TransactionsBrowser } from './transactions-browser'; + +const push = vi.fn(); + +vi.mock('next/navigation', () => ({ + usePathname: () => '/transactions', + useRouter: () => ({ push }), + useSearchParams: () => new URLSearchParams() +})); + +vi.mock('@/lib/actions', () => ({ + deleteTransactionAction: vi.fn(), + updateTransactionAction: vi.fn() +})); + +const timestamp = new Date('2026-06-01T12:00:00.000Z'); + +const categories: Category[] = [ + { + id: 7, + budgetId: 1, + name: 'Groceries', + displayName: 'Groceries', + type: 'expense', + parentId: null, + kind: 'normal', + inUse: true, + hasChildren: false, + archivedAt: null, + createdAt: timestamp, + updatedAt: timestamp + } +]; + +const currencies: Currency[] = [{ code: 'USD', name: 'US dollar' }]; + +function transaction(overrides: Partial = {}): Transaction { + return { + id: 42, + budgetId: 1, + categoryId: 7, + vendorId: null, + categoryName: 'Groceries', + categoryDisplayName: 'Groceries', + categoryParentId: null, + categoryKind: 'normal', + type: 'expense', + amount: 12.34, + currency: 'USD', + defaultCurrencyAmount: 12.34, + defaultCurrency: 'USD', + exchangeRate: 1, + exchangeRateDate: '2026-06-01', + occurredAt: timestamp, + tags: [], + createdBy: { + userId: 1, + email: 'owner@example.com' + }, + scanAttachment: null, + createdAt: timestamp, + updatedAt: timestamp, + ...overrides + }; +} + +function renderBrowser(items: readonly Transaction[]) { + render( + + ); +} + +describe('TransactionsBrowser', () => { + it('shows creator avatars only for transactions added by other users', () => { + renderBrowser([ + transaction(), + transaction({ + id: 43, + createdBy: { + userId: 2, + email: 'teammate@example.com', + avatarUrl: '/app-api/users/2/avatar' + } + }) + ]); + + expect(screen.queryByText(/Added by/i)).toBeNull(); + expect( + screen.queryByRole('img', { name: 'owner@example.com' }) + ).toBeNull(); + expect( + screen.getAllByRole('img', { name: 'teammate@example.com' }).length + ).toBe(2); + }); +}); diff --git a/apps/web/components/transactions-browser.tsx b/apps/web/components/transactions-browser.tsx index e62585d..027c2a5 100644 --- a/apps/web/components/transactions-browser.tsx +++ b/apps/web/components/transactions-browser.tsx @@ -70,6 +70,7 @@ import { transactionPageSize } from '@/lib/transaction-query'; import { AmountDisplay } from './amount-display'; import { DashboardViewSettingsMenu } from './dashboard-view-settings-menu'; import { TransactionDialog } from './transaction-dialog'; +import { UserAvatar } from './user-avatar'; import { VendorLogo } from './vendor-display'; type TransactionFeedResponse = { @@ -243,6 +244,24 @@ function transactionBadges(transaction: Transaction) { ); } +function transactionCreatorAvatar( + transaction: Transaction, + currentUserId: number +) { + if (transaction.createdBy.userId === currentUserId) { + return null; + } + + return ( + + ); +} + function transactionVendor(transaction: Transaction) { if (!transaction.vendorName) { return null; @@ -433,6 +452,7 @@ function TransactionActions({ function TransactionCards({ categories, currencies, + currentUserId, defaultCurrency, transactionTags: availableTransactionTags, vendors, @@ -441,6 +461,7 @@ function TransactionCards({ }: { readonly categories: readonly Category[]; readonly currencies: readonly Currency[]; + readonly currentUserId: number; readonly defaultCurrency: string; readonly transactionTags: readonly TransactionTag[]; readonly vendors: readonly Vendor[]; @@ -461,6 +482,10 @@ function TransactionCards({
  • {transactionBadges(transaction)} + {transactionCreatorAvatar( + transaction, + currentUserId + )} {formatDateTime( transaction.occurredAt, @@ -493,6 +518,7 @@ function TransactionCards({ function TransactionTable({ categories, currencies, + currentUserId, defaultCurrency, transactionTags: availableTransactionTags, vendors, @@ -501,6 +527,7 @@ function TransactionTable({ }: { readonly categories: readonly Category[]; readonly currencies: readonly Currency[]; + readonly currentUserId: number; readonly defaultCurrency: string; readonly transactionTags: readonly TransactionTag[]; readonly vendors: readonly Vendor[]; @@ -516,6 +543,7 @@ function TransactionTable({ Category Vendor Tags + Creator Type Amount When @@ -542,6 +570,16 @@ function TransactionTable({ )} + + {transactionCreatorAvatar( + transaction, + currentUserId + ) ?? ( + + - + + )} +
    {transactionBadges(transaction)} @@ -583,6 +621,7 @@ function TransactionTable({ export function TransactionsBrowser({ categories, currencies, + currentUserId, defaultCurrency, favoriteCurrencies, hasInitialFilters, @@ -594,6 +633,7 @@ export function TransactionsBrowser({ }: { readonly categories: readonly Category[]; readonly currencies: readonly Currency[]; + readonly currentUserId: number; readonly defaultCurrency: string; readonly favoriteCurrencies: readonly string[]; readonly hasInitialFilters: boolean; @@ -951,6 +991,7 @@ export function TransactionsBrowser({ ({ + useRouter: () => ({ refresh }) +})); + +vi.mock('@/lib/actions', () => ({ + updateUserAvatarAction: (formData: FormData) => + updateUserAvatarAction(formData), + deleteUserAvatarAction: () => deleteUserAvatarAction() +})); + +function me(overrides: Partial = {}): UserPreference { + return { + id: 1, + email: 'owner@example.com', + avatarUrl: undefined, + hasUploadedAvatar: false, + defaultCurrency: 'USD', + countryCode: 'US', + favoriteCurrencies: [], + transactionCurrencies: ['USD'], + timezone: 'UTC', + mainBudgetId: 1, + budgets: [], + hasCategories: true, + weeklyEmailReportEnabled: true, + monthlyEmailReportEnabled: true, + ...overrides + }; +} + +describe('UserAvatarSettings', () => { + afterEach(() => { + refresh.mockReset(); + updateUserAvatarAction.mockReset(); + deleteUserAvatarAction.mockReset(); + }); + + it('rejects unsupported avatar files before calling the action', async () => { + render(); + + fireEvent.change(screen.getByLabelText('Avatar image'), { + target: { + files: [ + new File(['not an image'], 'avatar.txt', { + type: 'text/plain' + }) + ] + } + }); + fireEvent.click(screen.getByRole('button', { name: 'Upload' })); + + expect((await screen.findByRole('alert')).textContent).toBe( + 'Upload a PNG, JPEG, or WebP image.' + ); + expect(updateUserAvatarAction).not.toHaveBeenCalled(); + }); + + it('rejects oversized avatar files before calling the action', async () => { + render(); + + fireEvent.change(screen.getByLabelText('Avatar image'), { + target: { + files: [ + new File( + [new Uint8Array(UserAvatarLimits.maxImageBytes + 1)], + 'avatar.png', + { type: 'image/png' } + ) + ] + } + }); + fireEvent.click(screen.getByRole('button', { name: 'Upload' })); + + expect((await screen.findByRole('alert')).textContent).toBe( + 'Avatar image must be 512 KB or smaller.' + ); + expect(updateUserAvatarAction).not.toHaveBeenCalled(); + }); + + it('uploads an avatar and refreshes the current page', async () => { + updateUserAvatarAction.mockResolvedValue({ success: true }); + render(); + + fireEvent.change(screen.getByLabelText('Avatar image'), { + target: { + files: [ + new File(['image'], 'avatar.png', { type: 'image/png' }) + ] + } + }); + fireEvent.click(screen.getByRole('button', { name: 'Upload' })); + + await waitFor(() => + expect(updateUserAvatarAction).toHaveBeenCalledOnce() + ); + expect(refresh).toHaveBeenCalledOnce(); + expect(screen.getByRole('status').textContent).toBe('Avatar uploaded.'); + }); + + it('shows server action errors inline', async () => { + updateUserAvatarAction.mockResolvedValue({ + error: 'Could not upload avatar.' + }); + render(); + + fireEvent.change(screen.getByLabelText('Avatar image'), { + target: { + files: [ + new File(['image'], 'avatar.webp', { type: 'image/webp' }) + ] + } + }); + fireEvent.click(screen.getByRole('button', { name: 'Upload' })); + + expect((await screen.findByRole('alert')).textContent).toBe( + 'Could not upload avatar.' + ); + expect(refresh).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/components/user-avatar-settings.tsx b/apps/web/components/user-avatar-settings.tsx new file mode 100644 index 0000000..3bfa4a8 --- /dev/null +++ b/apps/web/components/user-avatar-settings.tsx @@ -0,0 +1,163 @@ +'use client'; + +import type { UserPreference } from '@xpenser/contracts'; +import { UserAvatarLimits } from '@xpenser/contracts'; +import { + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + FieldError, + Input +} from '@xpenser/ui'; +import { useRouter } from 'next/navigation'; +import { type FormEvent, useState } from 'react'; +import { deleteUserAvatarAction, updateUserAvatarAction } from '@/lib/actions'; +import { isNextRedirectError } from './forms/form-utils'; +import { UserAvatar } from './user-avatar'; + +const allowedAvatarTypes = ['image/jpeg', 'image/png', 'image/webp']; + +function avatarValidationError(file: File | null): string | undefined { + if (!file || file.size === 0) { + return 'Choose an avatar image.'; + } + if (!allowedAvatarTypes.includes(file.type)) { + return 'Upload a PNG, JPEG, or WebP image.'; + } + if (file.size > UserAvatarLimits.maxImageBytes) { + return `Avatar image must be ${Math.round( + UserAvatarLimits.maxImageBytes / 1024 + )} KB or smaller.`; + } + return undefined; +} + +export function UserAvatarSettings({ me }: { readonly me: UserPreference }) { + const router = useRouter(); + const [error, setError] = useState(null); + const [message, setMessage] = useState(null); + const [pending, setPending] = useState(false); + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + + const form = event.currentTarget; + const input = form.elements.namedItem('avatar'); + const file = + input instanceof HTMLInputElement + ? (input.files?.[0] ?? null) + : null; + if (!file) { + setError('Choose an avatar image.'); + setMessage(null); + return; + } + const validationError = avatarValidationError(file); + if (validationError) { + setError(validationError); + setMessage(null); + return; + } + + setPending(true); + setError(null); + setMessage(null); + try { + const formData = new FormData(); + formData.append('avatar', file); + const response = await updateUserAvatarAction(formData); + if (response && 'error' in response && response.error) { + setError(response.error); + return; + } + form.reset(); + setMessage('Avatar uploaded.'); + router.refresh(); + } catch (caught) { + if (isNextRedirectError(caught)) { + throw caught; + } + setError('Could not upload avatar. Choose another image.'); + } finally { + setPending(false); + } + } + + return ( + + + Avatar + + Upload an image or keep the one from your sign-in provider. + + + +
    +
    + +
    +

    + {me.email} +

    +

    + PNG, JPEG, or WebP up to{' '} + {Math.round( + UserAvatarLimits.maxImageBytes / 1024 + )}{' '} + KB. +

    +
    +
    +
    +
    + + + + {error ? ( + {error} + ) : null} + {message ? ( +

    + {message} +

    + ) : null} + {me.hasUploadedAvatar ? ( +
    + + + ) : null} +
    +
    +
    +
    + ); +} diff --git a/apps/web/components/user-avatar.test.tsx b/apps/web/components/user-avatar.test.tsx new file mode 100644 index 0000000..750d463 --- /dev/null +++ b/apps/web/components/user-avatar.test.tsx @@ -0,0 +1,33 @@ +/** + * @vitest-environment jsdom + */ + +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { UserAvatar } from './user-avatar'; + +describe('UserAvatar', () => { + it('renders an image with accessible identity', () => { + const { container } = render( + + ); + + expect(screen.getByRole('img', { name: 'Jane Doe' })).toBeTruthy(); + expect(container.querySelector('img')?.getAttribute('src')).toBe( + '/app-api/users/2/avatar' + ); + }); + + it('falls back to email initials', () => { + render(); + + expect( + screen.getByRole('img', { name: 'teammate@example.com' }) + ).toBeTruthy(); + expect(screen.getByText('TE')).toBeTruthy(); + }); +}); diff --git a/apps/web/components/user-avatar.tsx b/apps/web/components/user-avatar.tsx new file mode 100644 index 0000000..b352cea --- /dev/null +++ b/apps/web/components/user-avatar.tsx @@ -0,0 +1,47 @@ +'use client'; + +import { Avatar, AvatarFallback, AvatarImage, cn } from '@xpenser/ui'; + +type UserAvatarIdentity = { + readonly avatarUrl?: string; + readonly displayName?: string; + readonly email: string; +}; + +function fallbackText(user: UserAvatarIdentity): string { + const source = user.displayName || user.email || 'User'; + return source.slice(0, 2).toUpperCase(); +} + +export function userAvatarLabel(user: UserAvatarIdentity): string { + return user.displayName || user.email || 'User'; +} + +export function UserAvatar({ + avatarUrl, + className, + displayName, + email, + fallbackClassName, + imageClassName +}: UserAvatarIdentity & { + readonly className?: string; + readonly fallbackClassName?: string; + readonly imageClassName?: string; +}) { + const label = userAvatarLabel({ avatarUrl, displayName, email }); + + return ( + + + + {fallbackText({ avatarUrl, displayName, email })} + + + ); +} diff --git a/apps/web/components/vendor-actions.test.tsx b/apps/web/components/vendor-actions.test.tsx index 076ce45..b6af0d9 100644 --- a/apps/web/components/vendor-actions.test.tsx +++ b/apps/web/components/vendor-actions.test.tsx @@ -37,11 +37,14 @@ const timestamp = new Date('2026-06-01T00:00:00.000Z'); function vendor(overrides: Partial = {}): Vendor { return { id: 1, + budgetId: 1, name: 'Old Walmart', displayName: 'Old Walmart', resolvedName: 'Walmart', domain: 'old.example', transactionCount: 0, + contributors: [], + otherContributorCount: 0, createdAt: timestamp, updatedAt: timestamp, ...overrides diff --git a/apps/web/components/vendor-analytics-panel.test.tsx b/apps/web/components/vendor-analytics-panel.test.tsx index 6496ab6..71f492c 100644 --- a/apps/web/components/vendor-analytics-panel.test.tsx +++ b/apps/web/components/vendor-analytics-panel.test.tsx @@ -26,6 +26,8 @@ function vendor( total: id * 10, transactionCount: id, trend: [id * 5, id * 10], + contributors: [], + otherContributorCount: 0, ...overrides }; } @@ -50,7 +52,9 @@ function categoryVendor( type: vendorRow.type, total, transactionCount: 1, - trend: [0, total] + trend: [0, total], + contributors: [], + otherContributorCount: 0 }; } diff --git a/apps/web/components/vendor-analytics-panel.tsx b/apps/web/components/vendor-analytics-panel.tsx index c9c619d..4497ef6 100644 --- a/apps/web/components/vendor-analytics-panel.tsx +++ b/apps/web/components/vendor-analytics-panel.tsx @@ -13,6 +13,7 @@ import { ChevronDownIcon, ChevronRightIcon, InfoIcon } from 'lucide-react'; import Link from 'next/link'; import { useEffect, useState } from 'react'; import { AmountDisplay } from '@/components/amount-display'; +import { ContributorAvatars } from '@/components/contributor-avatars'; import { type DashboardExpansionAction, DashboardExpansionButton @@ -274,8 +275,16 @@ function VendorRow({ size="sm" /> - - {vendor.vendorName} + + + {vendor.vendorName} + + {vendor.vendorDomain @@ -363,8 +372,14 @@ function VendorCategoryRow({ {shareLabel} - - {item.categoryDisplayName} + + + {item.categoryDisplayName} + + {vendorTransactionLabel(item.transactionCount)} ·{' '} diff --git a/apps/web/components/vendor-directory.test.tsx b/apps/web/components/vendor-directory.test.tsx index 1281d52..3d10326 100644 --- a/apps/web/components/vendor-directory.test.tsx +++ b/apps/web/components/vendor-directory.test.tsx @@ -12,6 +12,7 @@ const timestamp = new Date('2026-06-01T00:00:00.000Z'); function vendor(overrides: Partial = {}): Vendor { return { id: 42, + budgetId: 1, name: 'Walmart', displayName: 'Walmart', resolvedName: 'Walmart', @@ -25,6 +26,8 @@ function vendor(overrides: Partial = {}): Vendor { suggestedCategoryId: 7, suggestedCategoryDisplayName: 'Groceries', transactionCount: 3, + contributors: [], + otherContributorCount: 0, createdAt: timestamp, updatedAt: timestamp, ...overrides diff --git a/apps/web/components/vendor-directory.tsx b/apps/web/components/vendor-directory.tsx index e0cb4ea..ee5d026 100644 --- a/apps/web/components/vendor-directory.tsx +++ b/apps/web/components/vendor-directory.tsx @@ -13,6 +13,7 @@ import { } from '@xpenser/ui'; import { SearchIcon } from 'lucide-react'; import Link from 'next/link'; +import { ContributorAvatars } from './contributor-avatars'; import { VendorLogo, vendorDisplayName } from './vendor-display'; function vendorSubtitle(vendor: Vendor): string { @@ -29,10 +30,16 @@ function VendorIdentity({ vendor }: { readonly vendor: Vendor }) {
    - {vendorDisplayName(vendor)} + + {vendorDisplayName(vendor)} + +

    {vendorSubtitle(vendor)} diff --git a/apps/web/components/vendor-picker.test.tsx b/apps/web/components/vendor-picker.test.tsx index cafd261..3dd2dd0 100644 --- a/apps/web/components/vendor-picker.test.tsx +++ b/apps/web/components/vendor-picker.test.tsx @@ -21,9 +21,12 @@ const timestamp = new Date('2026-06-01T00:00:00.000Z'); function vendor(overrides: Partial = {}): Vendor { return { id: 1, + budgetId: 1, name: 'Bufet', displayName: 'Bufet', transactionCount: 0, + contributors: [], + otherContributorCount: 0, createdAt: timestamp, updatedAt: timestamp, ...overrides diff --git a/apps/web/content/blog/shared-budgets.mdx b/apps/web/content/blog/shared-budgets.mdx new file mode 100644 index 0000000..e15b973 --- /dev/null +++ b/apps/web/content/blog/shared-budgets.mdx @@ -0,0 +1,127 @@ +--- +title: "Shared budget expense tracker for collaborative finance" +slug: shared-budgets +description: "xpenser shared budgets separate transactions, permissions, currencies, reports, API workflows, and member attribution for collaborative finance tracking." +sourcePrNumber: "67" +sourcePrUrl: "https://github.com/cleverbrush/xpenser/pull/67" +heroImage: "/blog/shared-budgets-overview.png" +heroImageAlt: "xpenser budget overview showing a Main budget and shared budget creation controls" +publishedAt: 2026-07-07 +updatedAt: 2026-07-08 +targetKeyword: "shared budget expense tracker" +keywords: + - "collaborative expense tracking" + - "shared personal finance app" + - "open-source expense tracker" + - "self-hosted personal finance tracker" + - "budget permissions" +draft: false +--- + +xpenser now works as a shared budget expense tracker for people who need more than one financial workspace. A budget is the boundary for transactions, categories, vendors, tags, scanned receipts, reports, Telegram capture, MCP tools, and API access. That means a household budget, a trip budget, a business expense space, or a shared project can each keep its own data without mixing into another view. + +Existing accounts are migrated into a `Main` budget, and new transactions are always created inside a budget. The product no longer treats a transaction as a loose record owned only by one user. It belongs to a selected budget, and the people with access to that budget can see the same financial activity. + +## What is a shared budget in xpenser? + +A shared budget is a finance workspace with its own members, permissions, currencies, and transaction history. It is not just a filter on top of one personal account. When you switch budgets from the selector near the xpenser logo, the app changes the active context for entry, reports, vendors, categories, tags, scans, and exports. + +This matters because collaborative expense tracking usually fails when two different ideas are forced into one list. A personal lunch, a family grocery run, and a reimbursable work receipt may all be expenses, but they should not always share the same reports, categories, or collaborators. Budgets give those records a home. + +For people evaluating xpenser as an [open-source expense tracker](/open-source-expense-tracker), this also keeps the data model easy to reason about. A transaction always belongs to a budget. Budget access decides who can see or change it. Budget settings decide how the totals are reported. + +## Clear transaction boundaries + +Before this release, xpenser was centered around one user's transaction set. That is enough for a single-person tracker, but it becomes awkward for shared finance. If another user can add transactions to the same space, every surrounding object needs to follow the same boundary. + +Budgets now separate: + +- Transactions and scanned transaction drafts. +- Categories, including category management permissions. +- Vendors and vendor management permissions. +- Transaction tags. +- Dashboard totals and report windows. +- CSV exports and drill-down links. +- Telegram and MCP workflows that create or inspect finance data. + +The practical result is simple: when you are in a budget, the app should behave as if that budget is the current workspace. A category created for one budget does not need to leak into another. A vendor report for one shared space should not include unrelated personal spending. A transaction added from Telegram or an API workflow needs the same budget context as a transaction added through the web app. + +## Personal names for shared budgets + +Shared data and personal labels are deliberately separate. If someone invites you to a budget named `Main`, you should not be forced to see two identical `Main` entries forever. When you accept an invitation, xpenser asks for your own display name for that budget. + +That local name changes only what you see. It does not rename the budget for every other member. This keeps shared finance flexible without creating accidental global changes. One person can call the same space `Household`, another can call it `Family expenses`, and the underlying shared transactions still stay together. + +## Admin and member access + +Shared budgets include two visible roles: admin and member. Admins can manage the budget's access list. Members can participate according to the permissions assigned to them. + +The permission model is intentionally more granular than a simple read/write switch. A budget admin can decide whether a member can add transactions, update transactions, delete transactions, manage categories, manage vendors, manage tags, manage members, or manage budget settings. This is important for real shared personal finance work because not every collaborator should have the same level of control. + +For example, a partner may be trusted to add and edit shared household transactions. A helper may only need to add receipts. Someone reviewing expenses may need visibility without category or member-management access. Those cases all fit better with budget permissions than with a single "shared with" flag. + +

    + Budget detail page with local rename, budget currencies, and member access management +
    The budget detail page keeps local naming, currencies, invites, and member permissions together without overloading the main Preferences screen.
    +
    + +## Email invitations with magic links + +Admins invite another user by entering an email address and choosing a role. If a user with that email exists, xpenser sends a magic link that lets the invited user join the budget. The invitation flow avoids creating access for arbitrary unknown email addresses, and the invite is accepted only by the intended existing account. + +The acceptance step includes the local display name choice. That means the invite does not need to guess how the recipient wants the shared space to appear in their own budget selector. + +## Budget currencies instead of user currencies + +Currency preferences moved from the user level to the budget level. Each budget has a primary reporting currency and favorite currencies for quick entry. This is the correct boundary because a travel budget, household budget, and work budget may not use the same currency set. + +The budget management page uses chip-style favorite currency entry with autocomplete search. That makes the setting feel like a short, editable list rather than a heavy multiselect. It also keeps the primary reporting currency separate from quick-pick currencies used while entering transactions. + +This makes xpenser a stronger [self-hosted personal finance tracker](/self-hosted-personal-finance-tracker) for people who track more than one financial context. The currency setup follows the place where reporting happens: the budget. + +## Transaction attribution with avatars + +When a transaction was created by another budget member, xpenser shows a small avatar instead of a visible `Added by` email label. Transactions created by the current user stay unmarked. The goal is to make shared activity scannable without adding noise to every row. + +Avatar support is now a general user identity feature, not a one-off shared-budget label. xpenser can use a profile image from sign-in providers such as Google, and users can upload their own avatar from Preferences. The same user avatar surface can now be reused anywhere the app needs to show who performed an action. + +
    + Transactions list in xpenser with creator avatars for transactions added by another budget member +
    Shared transaction rows can show who added an item while keeping current-user transactions visually quiet.
    +
    + +## Archive and delete lifecycle + +Budgets can be archived when they should disappear from everyday screens without being destroyed. Archived budgets are hidden from normal selectors and app workflows, which keeps old trips, finished projects, or closed business periods out of the active finance surface. + +Admins can delete archived budgets when they are sure the data should be removed. Keeping deletion behind the archive state adds a deliberate pause before permanent removal. The `Main` budget is protected because it is the migration target for each user's existing data and the default home for their account. + +## Reports, Telegram, MCP, and API workflows + +Budgets are not only a web UI concept. Reports, scans, Telegram capture, MCP tools, and API workflows all need to understand the selected budget. Otherwise a shared budget expense tracker would only be shared in one interface and inconsistent everywhere else. + +With this release, the same boundary is carried through the app's integration surfaces. Reports read from the selected budget. Telegram-created transactions need budget context. MCP and API access work against budget-aware contracts. Dashboard and vendor views can show contribution avatars for users who added transactions to a shared budget. + +Developers and self-hosters can still start from the [personal finance API and MCP tools](/personal-finance-api-mcp) page, but the important product change is that programmable finance workflows now fit the same shared-budget model as the web app. + +## How this helps collaborative expense tracking + +The most useful part of shared budgets is not the invite itself. It is the combination of shared data, local labels, controlled permissions, budget-level settings, and transaction attribution. + +That combination supports several common workflows: + +- A household can keep groceries, utilities, subscriptions, and shared purchases in one place. +- A couple can track a trip without mixing travel spending into personal reports. +- A small team can record reimbursable expenses with clear ownership. +- A self-hosted user can expose a budget through API or MCP workflows without flattening every transaction into one account. +- An admin can invite someone for data entry while keeping settings, categories, vendors, tags, and member management restricted. + +xpenser still works as a single-user tracker. If you never share a budget, your `Main` budget simply becomes the workspace that contains your existing data. The difference is that the model now has room for collaboration when you need it. + +## Where shared budgets fit in xpenser + +Shared budgets build on the existing xpenser product surface: transaction capture, dashboards, vendors, reports, category organization, Telegram scanning, and programmable access. They make those features more useful when financial activity belongs to more than one context or more than one person. + +For a broader product overview, start with the [xpenser home page](/). If your priority is source access and transparent finance records, read about the [open-source expense tracker](/open-source-expense-tracker). If you run your own deployment, the [self-hosted personal finance tracker](/self-hosted-personal-finance-tracker) page explains the deployment-oriented side of the product. + +Shared budgets make collaboration part of the core finance model instead of an afterthought. Every transaction has a budget. Every budget has access rules. Every collaborator can use the name and workflow that make sense for them. diff --git a/apps/web/content/blog/transaction-csv-export.mdx b/apps/web/content/blog/transaction-csv-export.mdx index 1b3c452..bf7fd8f 100644 --- a/apps/web/content/blog/transaction-csv-export.mdx +++ b/apps/web/content/blog/transaction-csv-export.mdx @@ -19,7 +19,7 @@ draft: false xpenser can now export the transactions behind the current view as a CSV file. -The export action lives in the View settings menu and asks which currency columns to include. It starts with the active display currency selected, then lets users choose from their default currency and favorite currencies. +The export action lives in the View settings menu and asks which currency columns to include. It starts with the active display currency selected, then lets users choose from the budget default currency and budget favorite currencies. ## What changed diff --git a/apps/web/lib/actions.ts b/apps/web/lib/actions.ts index 81b9040..6738415 100644 --- a/apps/web/lib/actions.ts +++ b/apps/web/lib/actions.ts @@ -7,6 +7,7 @@ import { type TransactionScanDecisionBody, type TransactionScanImageResponse, UpdateVendorBodySchema, + UserAvatarLimits, type Vendor, type VendorCandidate } from '@xpenser/contracts'; @@ -18,6 +19,7 @@ import { getApiClient, getSessionOrRedirect } from './api'; +import { selectedBudgetCookie, selectedBudgetIdFromCookie } from './budgets'; import { getGoogleSignInProvider, webConfig } from './config'; import { VendorUpdateActionRejected } from './log-templates'; import { loggerFor } from './logger'; @@ -92,6 +94,10 @@ function booleanString( return value === 'true'; } +function checkboxString(formData: FormData, key: string): boolean { + return formData.getAll(key).includes('true'); +} + function editableString(formData: FormData, key: string): string | undefined { const value = formData.get(key); return typeof value === 'string' ? normalizeFormText(value) : undefined; @@ -125,6 +131,13 @@ function transactionBody(formData: FormData, editableNote = false) { }; } +async function withSelectedBudget>( + body: T +): Promise { + const budgetId = await selectedBudgetIdFromCookie(); + return budgetId ? { ...body, budgetId } : body; +} + function vendorBody(formData: FormData) { return { name: requiredString(formData, 'name'), @@ -231,11 +244,95 @@ function categoryBody(formData: FormData) { }; } +function budgetCreateBody(formData: FormData) { + const countryCode = optionalString(formData, 'countryCode'); + const defaultCurrency = requiredString(formData, 'defaultCurrency') + .trim() + .toUpperCase(); + return { + name: requiredString(formData, 'name'), + defaultCurrency, + favoriteCurrencies: favoriteCurrencies(formData, defaultCurrency), + ...(countryCode ? { countryCode: countryCode.toUpperCase() } : {}) + }; +} + +function budgetUpdateBody(formData: FormData) { + const name = optionalString(formData, 'name'); + const defaultCurrency = optionalString(formData, 'defaultCurrency') + ?.trim() + .toUpperCase(); + const countryCode = optionalString(formData, 'countryCode'); + return { + ...(name ? { name } : {}), + ...(defaultCurrency + ? { + defaultCurrency, + favoriteCurrencies: favoriteCurrencies( + formData, + defaultCurrency + ) + } + : {}), + ...(countryCode ? { countryCode: countryCode.toUpperCase() } : {}) + }; +} + +function budgetPermissions(formData: FormData) { + return { + canCreateTransactions: checkboxString( + formData, + 'canCreateTransactions' + ), + canUpdateTransactions: checkboxString( + formData, + 'canUpdateTransactions' + ), + canDeleteTransactions: checkboxString( + formData, + 'canDeleteTransactions' + ), + canManageCategories: checkboxString(formData, 'canManageCategories'), + canManageVendors: checkboxString(formData, 'canManageVendors'), + canManageTags: checkboxString(formData, 'canManageTags'), + canManageMembers: checkboxString(formData, 'canManageMembers') + }; +} + +function budgetMemberRole(formData: FormData): 'admin' | 'member' { + return requiredString(formData, 'role') === 'admin' ? 'admin' : 'member'; +} + function favoriteCurrencies(formData: FormData, defaultCurrency: string) { - return formData - .getAll('favoriteCurrencies') - .filter((value): value is string => typeof value === 'string') - .filter(currency => currency !== defaultCurrency); + const normalizedDefault = defaultCurrency.trim().toUpperCase(); + return Array.from( + new Set( + formData + .getAll('favoriteCurrencies') + .filter((value): value is string => typeof value === 'string') + .map(currency => currency.trim().toUpperCase()) + .filter(currency => /^[A-Z]{3}$/.test(currency)) + .filter(currency => currency !== normalizedDefault) + ) + ); +} + +function budgetDetailPath(budgetId: number): string { + return `/settings/budgets/${budgetId}`; +} + +function avatarFile(formData: FormData): File { + const value = formData.get('avatar'); + if (!(value instanceof File) || value.size === 0) { + throw new Error('avatar is required'); + } + if (!['image/jpeg', 'image/png', 'image/webp'].includes(value.type)) { + throw new Error('avatar must be a PNG, JPEG, or WebP image'); + } + if (value.size > UserAvatarLimits.maxImageBytes) { + throw new Error('avatar image is too large'); + } + return value; } function apiErrorMessage(err: unknown): string | undefined { @@ -439,7 +536,7 @@ export async function createCategoryAction( ): Promise { const client = await getApiClient(); const category = await client.categories.create({ - body: categoryBody(formData) + body: await withSelectedBudget(categoryBody(formData)) }); revalidatePath('/settings/categories'); revalidatePath('/settings/preferences'); @@ -515,7 +612,7 @@ export async function moveAndDeleteCategoryAction(formData: FormData) { export async function createVendorAction(formData: FormData): Promise { const client = await getApiClient(); const vendor = await client.vendors.create({ - body: vendorBody(formData) + body: await withSelectedBudget(vendorBody(formData)) }); revalidatePath('/capture'); revalidatePath('/dashboard'); @@ -635,7 +732,7 @@ export async function retryVendorEnrichmentAction( export async function createTransactionAction(formData: FormData) { const client = await getApiClient(); await client.transactions.create({ - body: transactionBody(formData) + body: await withSelectedBudget(transactionBody(formData)) }); revalidatePath('/capture'); revalidatePath('/dashboard'); @@ -649,7 +746,7 @@ export async function createCaptureTransactionAction( ): Promise { const client = await getApiClient(); const transaction = await client.transactions.create({ - body: transactionBody(formData) + body: await withSelectedBudget(transactionBody(formData)) }); revalidatePath('/capture'); revalidatePath('/dashboard'); @@ -732,13 +829,10 @@ export async function deleteTransactionAction(formData: FormData) { export async function updatePreferencesAction(formData: FormData) { const client = await getApiClient(); - const defaultCurrency = requiredString(formData, 'defaultCurrency'); await client.users.updatePreferences({ body: { - defaultCurrency, countryCode: optionalString(formData, 'countryCode')?.toUpperCase() ?? 'US', - favoriteCurrencies: favoriteCurrencies(formData, defaultCurrency), timezone: optionalString(formData, 'timezone') ?? 'UTC', weeklyEmailReportEnabled: booleanString( formData, @@ -768,6 +862,227 @@ export async function disconnectTelegramAction() { revalidatePath('/settings/preferences'); } +export async function selectBudgetAction(formData: FormData) { + const budgetId = Number(requiredString(formData, 'budgetId')); + const returnTo = + safeInternalRedirect(optionalString(formData, 'returnTo')) ?? + '/dashboard'; + const client = await getApiClient(); + const me = await client.auth.me(); + const budget = me.budgets.find(item => item.id === budgetId); + if (!budget) { + redirect(returnTo); + } + + const cookieStore = await cookies(); + cookieStore.set(selectedBudgetCookie, String(budget.id), { + httpOnly: true, + maxAge: 365 * 24 * 60 * 60, + path: '/', + sameSite: 'lax', + secure: webConfig.appUrl.startsWith('https://') + }); + redirect(returnTo); +} + +export async function createBudgetAction(formData: FormData) { + const client = await getApiClient(); + const budget = await client.budgets.create({ + body: budgetCreateBody(formData) + }); + const cookieStore = await cookies(); + cookieStore.set(selectedBudgetCookie, String(budget.id), { + httpOnly: true, + maxAge: 365 * 24 * 60 * 60, + path: '/', + sameSite: 'lax', + secure: webConfig.appUrl.startsWith('https://') + }); + revalidatePath('/settings/budgets'); + redirect(budgetDetailPath(budget.id)); +} + +export async function updateBudgetAction(formData: FormData) { + const budgetId = Number(requiredString(formData, 'budgetId')); + const client = await getApiClient(); + await client.budgets.update({ + params: { id: budgetId }, + body: budgetUpdateBody(formData) + }); + revalidatePath('/settings/budgets'); + revalidatePath(budgetDetailPath(budgetId)); + revalidatePath('/dashboard'); + revalidatePath('/transactions'); + revalidatePath('/stats'); +} + +async function clearSelectedBudgetIfNeeded(budgetId: number) { + const selectedBudgetId = await selectedBudgetIdFromCookie(); + if (selectedBudgetId !== budgetId) { + return; + } + const cookieStore = await cookies(); + cookieStore.delete(selectedBudgetCookie); +} + +export async function archiveBudgetAction(formData: FormData) { + const budgetId = Number(requiredString(formData, 'budgetId')); + const client = await getApiClient(); + await client.budgets.update({ + params: { id: budgetId }, + body: { archived: true } + }); + await clearSelectedBudgetIfNeeded(budgetId); + revalidatePath('/settings/budgets'); + revalidatePath(budgetDetailPath(budgetId)); + revalidatePath('/dashboard'); + revalidatePath('/transactions'); + revalidatePath('/stats'); +} + +export async function restoreBudgetAction(formData: FormData) { + const client = await getApiClient(); + await client.budgets.update({ + params: { id: Number(requiredString(formData, 'budgetId')) }, + body: { archived: false } + }); + revalidatePath('/settings/budgets'); + revalidatePath( + budgetDetailPath(Number(requiredString(formData, 'budgetId'))) + ); +} + +export async function deleteBudgetAction(formData: FormData) { + const budgetId = Number(requiredString(formData, 'budgetId')); + const client = await getApiClient(); + await client.budgets.delete({ + params: { id: budgetId } + }); + await clearSelectedBudgetIfNeeded(budgetId); + revalidatePath('/settings/budgets'); + revalidatePath('/dashboard'); + revalidatePath('/transactions'); + revalidatePath('/stats'); +} + +export async function inviteBudgetMemberAction(formData: FormData) { + const budgetId = Number(requiredString(formData, 'budgetId')); + const client = await getApiClient(); + await client.budgets.invite({ + params: { id: budgetId }, + body: { + email: requiredString(formData, 'email'), + role: budgetMemberRole(formData), + permissions: budgetPermissions(formData) + } + }); + revalidatePath('/settings/budgets'); + revalidatePath(budgetDetailPath(budgetId)); +} + +export async function updateBudgetMemberAction(formData: FormData) { + const budgetId = Number(requiredString(formData, 'budgetId')); + const userId = Number(requiredString(formData, 'userId')); + const client = await getApiClient(); + await client.budgets.updateMember({ + params: { budgetId, userId }, + body: { + role: budgetMemberRole(formData), + permissions: budgetPermissions(formData) + } + }); + revalidatePath('/settings/budgets'); + revalidatePath(budgetDetailPath(budgetId)); +} + +export async function removeBudgetMemberAction(formData: FormData) { + const budgetId = Number(requiredString(formData, 'budgetId')); + const client = await getApiClient(); + await client.budgets.removeMember({ + params: { + budgetId, + userId: Number(requiredString(formData, 'userId')) + } + }); + revalidatePath('/settings/budgets'); + revalidatePath(budgetDetailPath(budgetId)); +} + +export async function acceptBudgetInvitationAction(formData: FormData) { + const client = await getApiClient(); + const budget = await client.budgets.acceptInvitation({ + body: { + token: requiredString(formData, 'token'), + name: requiredString(formData, 'name') + } + }); + const cookieStore = await cookies(); + cookieStore.set(selectedBudgetCookie, String(budget.id), { + httpOnly: true, + maxAge: 365 * 24 * 60 * 60, + path: '/', + sameSite: 'lax', + secure: webConfig.appUrl.startsWith('https://') + }); + revalidatePath('/settings/budgets'); + redirect('/dashboard'); +} + +export async function updateUserAvatarAction(formData: FormData) { + let file: File; + try { + file = avatarFile(formData); + } catch (err) { + return { + error: + err instanceof Error ? err.message : 'Could not upload avatar.' + }; + } + const imageBase64 = Buffer.from(await file.arrayBuffer()).toString( + 'base64' + ); + const client = await getApiClient(); + try { + await client.users.updateAvatar({ + body: { + mimeType: file.type as + | 'image/jpeg' + | 'image/png' + | 'image/webp', + imageBase64, + fileName: file.name || undefined + } + }); + } catch (err) { + if (apiErrorStatus(err) === 400) { + return { + error: + apiErrorMessage(err) ?? + 'Could not upload avatar. Choose another image.' + }; + } + throw err; + } + revalidatePath('/settings/preferences'); + revalidatePath('/settings/budgets'); + revalidatePath('/dashboard'); + revalidatePath('/vendors'); + revalidatePath('/settings/vendors'); + revalidatePath('/transactions'); + return { success: true }; +} + +export async function deleteUserAvatarAction() { + const client = await getApiClient(); + await client.users.deleteAvatar(); + revalidatePath('/settings/preferences'); + revalidatePath('/settings/budgets'); + revalidatePath('/dashboard'); + revalidatePath('/vendors'); + revalidatePath('/settings/vendors'); + revalidatePath('/transactions'); +} + export async function createApiKeyAction(formData: FormData) { const client = await getApiClient(); const result = await client.users.createApiKey({ diff --git a/apps/web/lib/api-session-token.test.ts b/apps/web/lib/api-session-token.test.ts index 75970e3..72d34a2 100644 --- a/apps/web/lib/api-session-token.test.ts +++ b/apps/web/lib/api-session-token.test.ts @@ -60,7 +60,8 @@ describe('API session token helpers', () => { defaultCurrency: 'USD', countryCode: 'US', timezone: 'UTC', - hasCategories: true + hasCategories: true, + mainBudgetId: 1 } } ); diff --git a/apps/web/lib/api.test.ts b/apps/web/lib/api.test.ts index 708ae1e..424cd13 100644 --- a/apps/web/lib/api.test.ts +++ b/apps/web/lib/api.test.ts @@ -34,7 +34,8 @@ function tokenResponse(): TokenResponse { defaultCurrency: 'USD', countryCode: 'US', timezone: 'UTC', - hasCategories: true + hasCategories: true, + mainBudgetId: 1 } }; } diff --git a/apps/web/lib/api.ts b/apps/web/lib/api.ts index 7ed8427..0fc00d5 100644 --- a/apps/web/lib/api.ts +++ b/apps/web/lib/api.ts @@ -81,7 +81,7 @@ export async function getSessionOrRedirect() { type ApiClientOptions = Pick< XpenserClientOptions, - 'retryOnTimeout' | 'timeoutMs' + 'disableBatching' | 'retryOnTimeout' | 'timeoutMs' >; export async function getApiClient(options: ApiClientOptions = {}) { @@ -93,6 +93,7 @@ export async function getApiClient(options: ApiClientOptions = {}) { redirect(expiredSessionPath); }, invalidateCacheTag: tag => revalidateTag(tag, 'max'), + disableBatching: options.disableBatching, retryOnTimeout: options.retryOnTimeout, timeoutMs: options.timeoutMs }); diff --git a/apps/web/lib/budgets.ts b/apps/web/lib/budgets.ts new file mode 100644 index 0000000..abe1cc9 --- /dev/null +++ b/apps/web/lib/budgets.ts @@ -0,0 +1,39 @@ +import type { Budget, UserPreference } from '@xpenser/contracts'; +import { cookies } from 'next/headers'; + +export const selectedBudgetCookie = 'xpenser_selected_budget'; + +type BudgetSource = Pick; + +function parseBudgetId(value: string | undefined): number | undefined { + if (!value) { + return undefined; + } + const id = Number(value); + return Number.isSafeInteger(id) && id > 0 ? id : undefined; +} + +export async function selectedBudgetIdFromCookie(): Promise< + number | undefined +> { + const cookieStore = await cookies(); + return parseBudgetId(cookieStore.get(selectedBudgetCookie)?.value); +} + +export async function selectedBudgetForUser( + user: BudgetSource +): Promise { + const selectedBudgetId = await selectedBudgetIdFromCookie(); + return ( + user.budgets.find(budget => budget.id === selectedBudgetId) ?? + user.budgets.find(budget => budget.id === user.mainBudgetId) ?? + user.budgets[0] + ); +} + +export async function selectedBudgetQuery( + user: BudgetSource +): Promise<{ readonly budgetId?: number }> { + const budget = await selectedBudgetForUser(user); + return budget ? { budgetId: budget.id } : {}; +} diff --git a/apps/web/lib/capture-suggestions.test.ts b/apps/web/lib/capture-suggestions.test.ts index 4af48b3..768d103 100644 --- a/apps/web/lib/capture-suggestions.test.ts +++ b/apps/web/lib/capture-suggestions.test.ts @@ -11,6 +11,7 @@ function category( ): Category { return { id, + budgetId: 1, name, type, parentId: null, diff --git a/apps/web/lib/category-display.test.ts b/apps/web/lib/category-display.test.ts index d5d6327..0a6769a 100644 --- a/apps/web/lib/category-display.test.ts +++ b/apps/web/lib/category-display.test.ts @@ -16,6 +16,7 @@ function category( ): Category { return { id, + budgetId: 1, name, type: 'expense', parentId: null, diff --git a/apps/web/lib/dashboard-category-share.test.ts b/apps/web/lib/dashboard-category-share.test.ts index cc0acc7..8e2eccc 100644 --- a/apps/web/lib/dashboard-category-share.test.ts +++ b/apps/web/lib/dashboard-category-share.test.ts @@ -42,6 +42,8 @@ function category( transactionCount: 0, trend: [], type: 'expense', + contributors: [], + otherContributorCount: 0, ...overrides }; } diff --git a/apps/web/public/blog/shared-budget-detail.png b/apps/web/public/blog/shared-budget-detail.png new file mode 100644 index 0000000..d86f9ec Binary files /dev/null and b/apps/web/public/blog/shared-budget-detail.png differ diff --git a/apps/web/public/blog/shared-budget-transactions.png b/apps/web/public/blog/shared-budget-transactions.png new file mode 100644 index 0000000..dd59525 Binary files /dev/null and b/apps/web/public/blog/shared-budget-transactions.png differ diff --git a/apps/web/public/blog/shared-budgets-overview.png b/apps/web/public/blog/shared-budgets-overview.png new file mode 100644 index 0000000..bcd522e Binary files /dev/null and b/apps/web/public/blog/shared-budgets-overview.png differ diff --git a/packages/client/src/index.test.ts b/packages/client/src/index.test.ts index 39a4034..04224ec 100644 --- a/packages/client/src/index.test.ts +++ b/packages/client/src/index.test.ts @@ -114,6 +114,27 @@ describe('createXpenserClient', () => { ); }); + it('allows callers to disable batching with an API root base URL', () => { + createXpenserClient({ + baseUrl: 'http://api:4000', + disableBatching: true + }); + + expect(middlewareMocks.batching).not.toHaveBeenCalled(); + expect(middlewareMocks.createClient).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + middlewares: [ + { name: 'tracing' }, + { name: 'retry' }, + { name: 'timeout' }, + { name: 'dedupe' }, + { name: 'cacheTags' } + ] + }) + ); + }); + it('adds external cache tag invalidation when configured', () => { const invalidateCacheTag = vi.fn(); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 8c7a7e1..1875a39 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -27,6 +27,8 @@ export type XpenserClientOptions = { readonly retryOnTimeout?: boolean; /** Optional bridge to an external cache system such as Next.js tag cache. */ readonly invalidateCacheTag?: CacheTagInvalidator; + /** Disable request batching for call sites that need direct API reads. */ + readonly disableBatching?: boolean; }; function hasBasePath(baseUrl: string): boolean { @@ -51,9 +53,10 @@ function hasBasePath(baseUrl: string): boolean { * root by `ServerBuilder.useBatching()`. */ export function createXpenserClient(options: XpenserClientOptions) { - const batchingMiddleware = hasBasePath(options.baseUrl) - ? [] - : [batching({ maxSize: 10, windowMs: 10 })]; + const batchingMiddleware = + options.disableBatching || hasBasePath(options.baseUrl) + ? [] + : [batching({ maxSize: 10, windowMs: 10 })]; const externalCacheMiddleware = options.invalidateCacheTag ? [ externalCacheTags({ diff --git a/packages/contracts/src/api.test.ts b/packages/contracts/src/api.test.ts index a15c8f7..aa6a827 100644 --- a/packages/contracts/src/api.test.ts +++ b/packages/contracts/src/api.test.ts @@ -35,6 +35,15 @@ describe('api contract authorization metadata', () => { api.users.revokeApiKey, api.users.listMcpOAuthConnections, api.users.revokeMcpOAuthConnection, + api.budgets.list, + api.budgets.create, + api.budgets.update, + api.budgets.delete, + api.budgets.members, + api.budgets.invite, + api.budgets.updateMember, + api.budgets.removeMember, + api.budgets.acceptInvitation, api.oauth.authorizationRequest, api.oauth.authorize, api.categories.list, @@ -56,6 +65,7 @@ describe('api contract authorization metadata', () => { api.transactions.update, api.transactions.delete, api.transactions.scanImage, + api.transactionTags.list, api.transactionScans.create, api.transactionScans.start, api.transactionScans.decide, @@ -63,6 +73,7 @@ describe('api contract authorization metadata', () => { api.dashboard.window, api.stats.overview, api.stats.window, + api.stats.tags, api.stats.categoryTrend ]; diff --git a/packages/contracts/src/api.ts b/packages/contracts/src/api.ts index 10c9552..e0ed9c6 100644 --- a/packages/contracts/src/api.ts +++ b/packages/contracts/src/api.ts @@ -1,7 +1,12 @@ import { array, number } from '@cleverbrush/schema'; import { defineApi, endpoint, route } from '@cleverbrush/server/contract'; import { + AcceptBudgetInvitationBodySchema, ApiKeySchema, + BudgetAccessRowSchema, + BudgetInvitationResponseSchema, + BudgetMemberSchema, + BudgetSchema, CategoryListQuerySchema, CategorySchema, CategoryTrendQuerySchema, @@ -9,6 +14,7 @@ import { ConfirmEmailBodySchema, CreateApiKeyBodySchema, CreateApiKeyResponseSchema, + CreateBudgetBodySchema, CreateCategoryBodySchema, CreateTelegramLinkTokenResponseSchema, CreateTransactionBodySchema, @@ -24,8 +30,10 @@ import { EmailConfirmationPendingResponseSchema, ErrorResponseSchema, GoogleSignInBodySchema, + InviteBudgetMemberBodySchema, LinkTelegramAccountBodySchema, LinkTelegramAccountResponseSchema, + ListBudgetsQuerySchema, LoginBodySchema, McpOAuthAuthorizationQuerySchema, McpOAuthAuthorizationRequestSchema, @@ -62,10 +70,13 @@ import { TransactionSchema, TransactionTagListQuerySchema, TransactionTagSchema, + UpdateBudgetBodySchema, + UpdateBudgetMemberBodySchema, UpdateCategoryBodySchema, UpdateTransactionBodySchema, UpdateUserPreferenceBodySchema, UpdateVendorBodySchema, + UserAvatarUploadBodySchema, UserPreferenceSchema, VendorCandidateDetailsQuerySchema, VendorCandidateSchema, @@ -75,6 +86,15 @@ import { } from './schemas.js'; const ById = route({ id: number().coerce() })`/${t => t.id}`; +const BudgetMembers = route({ id: number().coerce() })`/${t => t.id}/members`; +const BudgetAccess = route({ id: number().coerce() })`/${t => t.id}/access`; +const BudgetMemberById = route({ + budgetId: number().coerce(), + userId: number().coerce() +})`/${t => t.budgetId}/members/${t => t.userId}`; +const BudgetInvitations = route({ id: number().coerce() })`/${t => + t.id}/invitations`; +const BudgetInvitationAccept = route`/accept`; const CategoryMoveAndDelete = route({ id: number().coerce() })`/${t => t.id}/move-and-delete`; const VendorEnrich = route({ id: number().coerce() })`/${t => t.id}/enrich`; @@ -90,6 +110,10 @@ const TransactionScanJobStatus = route`/jobs/status`; const TransactionExportCsv = route`/export.csv`; const TransactionScanImage = route({ id: number().coerce() })`/${t => t.id}/scan-image`; +const budgets = endpoint.resource('/api/budgets').authorize(PrincipalSchema); +const budgetInvitations = endpoint + .resource('/api/budget-invitations') + .authorize(PrincipalSchema); const categories = endpoint .resource('/api/categories') .authorize(PrincipalSchema); @@ -113,6 +137,9 @@ const stats = endpoint.resource('/api/stats').authorize(PrincipalSchema); const apiKeys = endpoint .resource('/api/users/me/api-keys') .authorize(PrincipalSchema); +const userAvatars = endpoint.resource('/api/users').authorize(PrincipalSchema); +const CurrentUserAvatar = route`/me/avatar`; +const UserAvatarImage = route({ id: number().coerce() })`/${t => t.id}/avatar`; const mcpConnections = endpoint .resource('/api/users/me/mcp-connections') .authorize(PrincipalSchema); @@ -240,6 +267,31 @@ export const api = defineApi({ 204: null, 401: ErrorResponseSchema }), + updateAvatar: userAvatars + .put(CurrentUserAvatar) + .body(UserAvatarUploadBodySchema) + .clearsCacheTag('user-profile') + .clearsCacheTag('budgets') + .clearsCacheTag('dashboard') + .clearsCacheTag('vendors') + .clearsCacheTag('transactions') + .responses({ + 200: UserPreferenceSchema, + 400: ErrorResponseSchema, + 401: ErrorResponseSchema + }), + deleteAvatar: userAvatars + .delete(CurrentUserAvatar) + .clearsCacheTag('user-profile') + .clearsCacheTag('budgets') + .clearsCacheTag('dashboard') + .clearsCacheTag('vendors') + .clearsCacheTag('transactions') + .responses({ + 200: UserPreferenceSchema, + 401: ErrorResponseSchema + }), + avatarImage: userAvatars.get(UserAvatarImage), listApiKeys: apiKeys .get() .cacheTag('api-keys') @@ -280,6 +332,100 @@ export const api = defineApi({ 404: ErrorResponseSchema }) }, + budgets: { + list: budgets + .get() + .query(ListBudgetsQuerySchema) + .cacheTag('budgets') + .responses({ 200: array(BudgetSchema) }), + create: budgets + .post() + .body(CreateBudgetBodySchema) + .clearsCacheTag('budgets') + .clearsCacheTag('user-profile') + .responses({ + 201: BudgetSchema, + 400: ErrorResponseSchema, + 403: ErrorResponseSchema, + 404: ErrorResponseSchema + }), + update: budgets + .patch(ById) + .body(UpdateBudgetBodySchema) + .clearsCacheTag('budgets') + .clearsCacheTag('user-profile') + .clearsCacheTag('dashboard') + .clearsCacheTag('stats') + .responses({ + 200: BudgetSchema, + 400: ErrorResponseSchema, + 403: ErrorResponseSchema, + 404: ErrorResponseSchema + }), + delete: budgets + .delete(ById) + .clearsCacheTag('budgets') + .clearsCacheTag('user-profile') + .clearsCacheTag('dashboard') + .clearsCacheTag('stats') + .responses({ + 204: null, + 400: ErrorResponseSchema, + 403: ErrorResponseSchema, + 404: ErrorResponseSchema + }), + members: budgets.get(BudgetMembers).responses({ + 200: array(BudgetMemberSchema), + 400: ErrorResponseSchema, + 403: ErrorResponseSchema, + 404: ErrorResponseSchema + }), + access: budgets.get(BudgetAccess).responses({ + 200: array(BudgetAccessRowSchema), + 400: ErrorResponseSchema, + 403: ErrorResponseSchema, + 404: ErrorResponseSchema + }), + invite: budgets + .post(BudgetInvitations) + .body(InviteBudgetMemberBodySchema) + .clearsCacheTag('budgets') + .responses({ + 200: BudgetInvitationResponseSchema, + 400: ErrorResponseSchema, + 403: ErrorResponseSchema, + 404: ErrorResponseSchema + }), + updateMember: budgets + .patch(BudgetMemberById) + .body(UpdateBudgetMemberBodySchema) + .clearsCacheTag('budgets') + .responses({ + 200: BudgetMemberSchema, + 400: ErrorResponseSchema, + 403: ErrorResponseSchema, + 404: ErrorResponseSchema + }), + removeMember: budgets + .delete(BudgetMemberById) + .clearsCacheTag('budgets') + .responses({ + 204: null, + 400: ErrorResponseSchema, + 403: ErrorResponseSchema, + 404: ErrorResponseSchema + }), + acceptInvitation: budgetInvitations + .post(BudgetInvitationAccept) + .body(AcceptBudgetInvitationBodySchema) + .clearsCacheTag('budgets') + .clearsCacheTag('user-profile') + .responses({ + 200: BudgetSchema, + 400: ErrorResponseSchema, + 401: ErrorResponseSchema + }) + }, oauth: { authorizationRequest: endpoint .get('/api/oauth/authorize-request') @@ -330,6 +476,7 @@ export const api = defineApi({ .query(CurrencyConversionQuerySchema) .cacheTag('currency-conversion', request => ({ amount: request.query.amount, + budgetId: request.query.budgetId, currency: request.query.currency, occurredAt: request.query.occurredAt })) @@ -344,14 +491,23 @@ export const api = defineApi({ .get() .query(CategoryListQuerySchema) .cacheTag('categories') - .responses({ 200: array(CategorySchema) }), + .responses({ + 200: array(CategorySchema), + 403: ErrorResponseSchema, + 404: ErrorResponseSchema + }), create: categories .post() .body(CreateCategoryBodySchema) .clearsCacheTag('categories') .clearsCacheTag('user-profile') .clearsCacheTag('stats') - .responses({ 201: CategorySchema, 400: ErrorResponseSchema }), + .responses({ + 201: CategorySchema, + 400: ErrorResponseSchema, + 403: ErrorResponseSchema, + 404: ErrorResponseSchema + }), update: categories .patch(ById) .body(UpdateCategoryBodySchema) @@ -361,6 +517,7 @@ export const api = defineApi({ .responses({ 200: CategorySchema, 400: ErrorResponseSchema, + 403: ErrorResponseSchema, 404: ErrorResponseSchema }), delete: categories @@ -371,6 +528,7 @@ export const api = defineApi({ .responses({ 204: null, 400: ErrorResponseSchema, + 403: ErrorResponseSchema, 404: ErrorResponseSchema }), moveAndDelete: categories @@ -384,6 +542,7 @@ export const api = defineApi({ .responses({ 204: null, 400: ErrorResponseSchema, + 403: ErrorResponseSchema, 404: ErrorResponseSchema }) }, @@ -403,16 +562,26 @@ export const api = defineApi({ .get() .query(VendorListQuerySchema) .cacheTag('vendors') - .responses({ 200: array(VendorSchema) }), + .responses({ + 200: array(VendorSchema), + 403: ErrorResponseSchema, + 404: ErrorResponseSchema + }), get: vendors.get(ById).cacheTag('vendors').responses({ 200: VendorSchema, + 403: ErrorResponseSchema, 404: ErrorResponseSchema }), create: vendors .post() .body(CreateVendorBodySchema) .clearsCacheTag('vendors') - .responses({ 201: VendorSchema, 400: ErrorResponseSchema }), + .responses({ + 201: VendorSchema, + 400: ErrorResponseSchema, + 403: ErrorResponseSchema, + 404: ErrorResponseSchema + }), update: vendors .patch(ById) .body(UpdateVendorBodySchema) @@ -421,6 +590,7 @@ export const api = defineApi({ .responses({ 200: VendorSchema, 400: ErrorResponseSchema, + 403: ErrorResponseSchema, 404: ErrorResponseSchema }), enrich: vendors @@ -429,6 +599,7 @@ export const api = defineApi({ .clearsCacheTag('transactions') .responses({ 200: VendorSchema, + 403: ErrorResponseSchema, 404: ErrorResponseSchema }) }, @@ -437,12 +608,21 @@ export const api = defineApi({ .get() .query(TransactionListQuerySchema) .cacheTag('transactions') - .responses({ 200: TransactionListResponseSchema }), + .responses({ + 200: TransactionListResponseSchema, + 403: ErrorResponseSchema, + 404: ErrorResponseSchema + }), exportCsv: transactions .get(TransactionExportCsv) .query(TransactionExportQuerySchema) .cacheTag('transactions') - .producesFile('text/csv', 'Transaction CSV export'), + .producesFile('text/csv', 'Transaction CSV export') + .responses({ + 400: ErrorResponseSchema, + 403: ErrorResponseSchema, + 404: ErrorResponseSchema + }), create: transactions .post() .body(CreateTransactionBodySchema) @@ -453,7 +633,12 @@ export const api = defineApi({ .clearsCacheTag('user-profile') .clearsCacheTag('dashboard') .clearsCacheTag('stats') - .responses({ 201: TransactionSchema, 400: ErrorResponseSchema }), + .responses({ + 201: TransactionSchema, + 400: ErrorResponseSchema, + 403: ErrorResponseSchema, + 404: ErrorResponseSchema + }), update: transactions .patch(ById) .body(UpdateTransactionBodySchema) @@ -467,6 +652,7 @@ export const api = defineApi({ .responses({ 200: TransactionSchema, 400: ErrorResponseSchema, + 403: ErrorResponseSchema, 404: ErrorResponseSchema }), delete: transactions @@ -478,9 +664,14 @@ export const api = defineApi({ .clearsCacheTag('user-profile') .clearsCacheTag('dashboard') .clearsCacheTag('stats') - .responses({ 204: null, 404: ErrorResponseSchema }), + .responses({ + 204: null, + 403: ErrorResponseSchema, + 404: ErrorResponseSchema + }), scanImage: transactions.get(TransactionScanImage).responses({ 200: TransactionScanImageResponseSchema, + 403: ErrorResponseSchema, 404: ErrorResponseSchema }) }, @@ -489,7 +680,11 @@ export const api = defineApi({ .get() .query(TransactionTagListQuerySchema) .cacheTag('transaction-tags') - .responses({ 200: array(TransactionTagSchema) }) + .responses({ + 200: array(TransactionTagSchema), + 403: ErrorResponseSchema, + 404: ErrorResponseSchema + }) }, transactionScans: { create: transactionScans @@ -497,7 +692,9 @@ export const api = defineApi({ .body(TransactionScanBodySchema) .responses({ 201: TransactionScanResponseSchema, - 400: ErrorResponseSchema + 400: ErrorResponseSchema, + 403: ErrorResponseSchema, + 404: ErrorResponseSchema }), start: transactionScans .post(TransactionScanJobs) @@ -525,6 +722,7 @@ export const api = defineApi({ .responses({ 204: null, 400: ErrorResponseSchema, + 403: ErrorResponseSchema, 404: ErrorResponseSchema }) }, @@ -534,12 +732,17 @@ export const api = defineApi({ .authorize(PrincipalSchema) .query(DashboardQuerySchema) .cacheTag('dashboard', request => ({ + budgetId: request.query.budgetId, currency: request.query.currency, date: request.query.date, vendorLimit: request.query.vendorLimit, period: request.query.period })) - .responses({ 200: DashboardSummarySchema }), + .responses({ + 200: DashboardSummarySchema, + 403: ErrorResponseSchema, + 404: ErrorResponseSchema + }), window: endpoint .get('/api/dashboard/window') .authorize(PrincipalSchema) @@ -547,12 +750,17 @@ export const api = defineApi({ .cacheTag('dashboard', request => ({ after: request.query.after, before: request.query.before, + budgetId: request.query.budgetId, currency: request.query.currency, date: request.query.date, vendorLimit: request.query.vendorLimit, period: request.query.period })) - .responses({ 200: DashboardWindowResponseSchema }) + .responses({ + 200: DashboardWindowResponseSchema, + 403: ErrorResponseSchema, + 404: ErrorResponseSchema + }) }, stats: { overview: endpoint @@ -560,6 +768,7 @@ export const api = defineApi({ .authorize(PrincipalSchema) .query(StatsQuerySchema) .cacheTag('stats', request => ({ + budgetId: request.query.budgetId, date: request.query.date, from: request.query.from, groupBy: request.query.groupBy, @@ -567,7 +776,11 @@ export const api = defineApi({ timeframe: request.query.timeframe, to: request.query.to })) - .responses({ 200: StatsOverviewSchema }), + .responses({ + 200: StatsOverviewSchema, + 403: ErrorResponseSchema, + 404: ErrorResponseSchema + }), window: endpoint .get('/api/stats/window') .authorize(PrincipalSchema) @@ -575,23 +788,34 @@ export const api = defineApi({ .cacheTag('stats', request => ({ after: request.query.after, before: request.query.before, + budgetId: request.query.budgetId, date: request.query.date, period: request.query.period })) - .responses({ 200: StatsWindowResponseSchema }), + .responses({ + 200: StatsWindowResponseSchema, + 403: ErrorResponseSchema, + 404: ErrorResponseSchema + }), tags: stats .get(StatsTags) .query(StatsTagReportQuerySchema) .cacheTag('stats', request => ({ + budgetId: request.query.budgetId, date: request.query.date, period: request.query.period, tag: request.query.tag })) - .responses({ 200: StatsTagReportSchema }), + .responses({ + 200: StatsTagReportSchema, + 403: ErrorResponseSchema, + 404: ErrorResponseSchema + }), categoryTrend: stats .get(StatsCategoryTrend) .query(CategoryTrendQuerySchema) .cacheTag('stats', request => ({ + budgetId: request.query.budgetId, categoryId: request.params.id, from: request.query.from, groupBy: request.query.groupBy, @@ -600,7 +824,9 @@ export const api = defineApi({ })) .responses({ 200: CategoryTrendResponseSchema, - 400: ErrorResponseSchema + 400: ErrorResponseSchema, + 403: ErrorResponseSchema, + 404: ErrorResponseSchema }) } }); diff --git a/packages/contracts/src/limits.ts b/packages/contracts/src/limits.ts index d49b62b..8f08107 100644 --- a/packages/contracts/src/limits.ts +++ b/packages/contracts/src/limits.ts @@ -8,6 +8,8 @@ export const FieldLimits = { apiKeyName: 120, brandfetchBrandId: 100, + budgetInviteToken: 128, + budgetName: 120, categoryName: 120, confirmationToken: 128, email: 255, @@ -17,6 +19,8 @@ export const FieldLimits = { passportProvider: 50, passportSubject: 255, password: 256, + userAvatarBase64: 700_000, + userAvatarFileName: 255, telegramFirstName: 128, telegramLastName: 128, telegramLinkToken: 128, @@ -35,6 +39,10 @@ export const FieldLimits = { vendorSearch: 160 } as const; +export const UserAvatarLimits = { + maxImageBytes: 512 * 1024 +} as const; + export const TransactionTagLimits = { maxTagsPerTransaction: 12 } as const; diff --git a/packages/contracts/src/schemas.test.ts b/packages/contracts/src/schemas.test.ts index 7e0ed3c..906993f 100644 --- a/packages/contracts/src/schemas.test.ts +++ b/packages/contracts/src/schemas.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { FieldLimits, TransactionTagLimits } from './limits.js'; import { + BudgetSchema, CategoryListQuerySchema, CategorySchema, CategoryTrendQuerySchema, @@ -16,6 +17,7 @@ import { EmailConfirmationPendingResponseSchema, GoogleSignInBodySchema, LinkTelegramAccountBodySchema, + ListBudgetsQuerySchema, LoginBodySchema, MoveAndDeleteCategoryBodySchema, PassportExchangeBodySchema, @@ -38,6 +40,7 @@ import { TransactionScanProgressQuerySchema, TransactionScanResponseSchema, TransactionSchema, + UpdateBudgetBodySchema, UpdateCategoryBodySchema, UpdateUserPreferenceBodySchema, UpdateVendorBodySchema, @@ -136,9 +139,7 @@ describe('shared schemas', () => { expect(TimeZoneSchema.validate('Not/AZone').valid).toBe(false); expect( UpdateUserPreferenceBodySchema.validate({ - defaultCurrency: 'USD', countryCode: 'US', - favoriteCurrencies: ['EUR'], timezone: 'Europe/London' }).valid ).toBe(true); @@ -146,9 +147,7 @@ describe('shared schemas', () => { it('rejects preference and Passport identity values longer than DB columns', () => { const timezoneResult = UpdateUserPreferenceBodySchema.validate({ - defaultCurrency: 'USD', countryCode: 'US', - favoriteCurrencies: ['EUR'], timezone: 'A'.repeat(FieldLimits.timeZone + 1) }); expect(timezoneResult.valid).toBe(false); @@ -172,12 +171,38 @@ describe('shared schemas', () => { const result = UserPreferenceSchema.validate({ id: 1, email: 'jane@example.com', + hasUploadedAvatar: false, defaultCurrency: 'USD', countryCode: 'US', favoriteCurrencies: ['EUR'], transactionCurrencies: ['EUR', 'USD'], timezone: 'UTC', hasCategories: true, + mainBudgetId: 1, + budgets: [ + { + id: 1, + name: 'Main', + defaultCurrency: 'USD', + favoriteCurrencies: ['EUR'], + transactionCurrencies: ['EUR', 'USD'], + countryCode: 'US', + role: 'admin', + permissions: { + canCreateTransactions: true, + canUpdateTransactions: true, + canDeleteTransactions: true, + canManageCategories: true, + canManageVendors: true, + canManageTags: true, + canManageMembers: true + }, + isMain: true, + archivedAt: null, + createdAt: new Date(), + updatedAt: new Date() + } + ], weeklyEmailReportEnabled: true, monthlyEmailReportEnabled: true }); @@ -185,6 +210,49 @@ describe('shared schemas', () => { expect(result.valid).toBe(true); }); + it('validates budget lifecycle fields and query controls', () => { + const budget = BudgetSchema.validate({ + id: 1, + name: 'Household', + defaultCurrency: 'USD', + favoriteCurrencies: ['EUR'], + transactionCurrencies: ['EUR', 'USD'], + countryCode: 'US', + role: 'admin', + permissions: { + canCreateTransactions: true, + canUpdateTransactions: true, + canDeleteTransactions: true, + canManageCategories: true, + canManageVendors: true, + canManageTags: true, + canManageMembers: true + }, + isMain: false, + archivedAt: new Date('2026-06-01T00:00:00.000Z'), + createdAt: new Date(), + updatedAt: new Date() + }); + + expect(budget.valid).toBe(true); + expect( + UpdateBudgetBodySchema.validate({ + archived: true, + defaultCurrency: 'EUR', + favoriteCurrencies: ['USD', 'GBP'], + name: 'Shared household' + }).valid + ).toBe(true); + expect( + ListBudgetsQuerySchema.validate({ status: 'archived' }).object + ?.status + ).toBe('archived'); + expect( + ListBudgetsQuerySchema.validate({ status: 'deleted' } as never) + .valid + ).toBe(false); + }); + it('validates category archive update payloads', () => { const result = UpdateCategoryBodySchema.validate({ archived: true @@ -205,9 +273,7 @@ describe('shared schemas', () => { it('defaults email report preferences to enabled when updating preferences', () => { const result = UpdateUserPreferenceBodySchema.validate({ - defaultCurrency: 'USD', countryCode: 'US', - favoriteCurrencies: ['EUR'], timezone: 'UTC' }); @@ -220,6 +286,7 @@ describe('shared schemas', () => { expect( CategoryListQuerySchema.validate({ activeOnly: 'true', + budgetId: '1', sort: 'recent-transaction-count' } as never).object?.activeOnly ).toBe(true); @@ -241,6 +308,7 @@ describe('shared schemas', () => { const result = CategorySchema.validate({ id: 2, + budgetId: 1, name: 'Returns', type: 'expense', kind: 'offset', @@ -298,6 +366,7 @@ describe('shared schemas', () => { expect( VendorSchema.validate({ id: 3, + budgetId: 1, name: 'Trader Joe', displayName: 'Trader Joe', resolvedName: 'Trader Joe', @@ -309,6 +378,8 @@ describe('shared schemas', () => { suggestedCategoryId: 1, suggestedCategoryDisplayName: 'Groceries', transactionCount: 2, + contributors: [], + otherContributorCount: 0, createdAt: new Date(), updatedAt: new Date() }).valid @@ -432,7 +503,8 @@ describe('shared schemas', () => { defaultCurrency: 'USD', countryCode: 'US', timezone: 'UTC', - hasCategories: false + hasCategories: false, + mainBudgetId: 1 } }).valid ).toBe(true); @@ -666,6 +738,7 @@ describe('shared schemas', () => { expect( TransactionSchema.validate({ id: 42, + budgetId: 1, categoryId: 1, vendorId: null, categoryName: 'Groceries', @@ -683,12 +756,17 @@ describe('shared schemas', () => { tags: [ { id: 1, + budgetId: 1, name: 'wife', transactionCount: 2, createdAt: new Date('2026-06-01T12:00:00.000Z'), updatedAt: new Date('2026-06-01T12:00:00.000Z') } ], + createdBy: { + userId: 1, + email: 'jane@example.com' + }, scanAttachment: { scanId: 10, scanItemId: 20, @@ -833,7 +911,9 @@ describe('shared schemas', () => { type: 'expense', total: 100, transactionCount: 3, - trend: [20, 80] + trend: [20, 80], + contributors: [], + otherContributorCount: 0 }, { vendorId: null, @@ -841,7 +921,9 @@ describe('shared schemas', () => { type: 'income', total: 50, transactionCount: 1, - trend: [0, 50] + trend: [0, 50], + contributors: [], + otherContributorCount: 0 } ], categoryVendorBreakdown: [ @@ -859,7 +941,9 @@ describe('shared schemas', () => { type: 'expense', total: 100, transactionCount: 3, - trend: [20, 80] + trend: [20, 80], + contributors: [], + otherContributorCount: 0 } ], byCategory: [], diff --git a/packages/contracts/src/schemas.ts b/packages/contracts/src/schemas.ts index cc7e3be..9b9c1e1 100644 --- a/packages/contracts/src/schemas.ts +++ b/packages/contracts/src/schemas.ts @@ -10,7 +10,11 @@ import { string, union } from '@cleverbrush/schema'; -import { FieldLimits, TransactionTagLimits } from './limits.js'; +import { + FieldLimits, + TransactionTagLimits, + UserAvatarLimits +} from './limits.js'; export const CurrencyCodeSchema = string() .required('currency is required') @@ -172,6 +176,32 @@ export const ImageMimeTypeSchema = enumOf( 'image/webp' ).describe('Supported image MIME type.'); +export const UserAvatarSummarySchema = object({ + /** User identifier. */ + userId: number().describe('User identifier.'), + /** User email address. */ + email: string().describe('User email address.'), + /** User display name, when available. */ + displayName: string() + .optional() + .describe('User display name, when available.'), + /** Avatar URL for rendering this user. */ + avatarUrl: string() + .optional() + .describe('Avatar URL for rendering this user.') +}).schemaName('UserAvatarSummary'); + +const ContributorFields = { + /** Other budget users who contributed transactions to this row. */ + contributors: array(UserAvatarSummarySchema).describe( + 'Other budget users who contributed transactions to this row.' + ), + /** Additional contributor count not included in contributors. */ + otherContributorCount: number().describe( + 'Additional contributor count not included in contributors.' + ) +}; + export const PrincipalSchema = object({ /** Authenticated user identifier encoded in the API JWT. */ userId: number().describe( @@ -409,6 +439,295 @@ export const SessionTokenBodySchema = object({ ) }).schemaName('SessionTokenBody'); +export const BudgetRoleSchema = enumOf('admin', 'member').describe( + 'Budget access role.' +); + +export const BudgetPermissionsSchema = object({ + canCreateTransactions: boolean().describe( + 'Allows creating transactions in the budget.' + ), + canUpdateTransactions: boolean().describe( + 'Allows updating transactions in the budget.' + ), + canDeleteTransactions: boolean().describe( + 'Allows deleting transactions from the budget.' + ), + canManageCategories: boolean().describe( + 'Allows creating, editing, archiving, and deleting budget categories.' + ), + canManageVendors: boolean().describe( + 'Allows creating and editing budget vendors.' + ), + canManageTags: boolean().describe( + 'Allows creating and assigning transaction tags in the budget.' + ), + canManageMembers: boolean().describe( + 'Allows inviting, editing, and removing budget members.' + ) +}); + +export const BudgetSchema = object({ + /** Unique budget identifier. */ + id: number().describe('Unique budget identifier.'), + /** Budget name shown to the current user in navigation and reports. */ + name: string().describe( + 'Budget name shown to the current user in navigation and reports.' + ), + /** Default currency used for new transactions and reports in this budget. */ + defaultCurrency: CurrencyCodeSchema.describe( + 'Default currency used for new transactions and reports in this budget.' + ), + /** Favorite currencies offered when entering transactions in this budget. */ + favoriteCurrencies: array(CurrencyCodeSchema).describe( + 'Favorite currencies offered when entering transactions in this budget.' + ), + /** Transaction entry currencies ordered by recent usage popularity in this budget. */ + transactionCurrencies: array(CurrencyCodeSchema).describe( + 'Transaction entry currencies ordered by recent usage popularity in this budget.' + ), + /** Country used to localize vendor enrichment in this budget. */ + countryCode: CountryCodeSchema.describe( + 'Country used to localize vendor enrichment in this budget.' + ), + /** Current user role in this budget. */ + role: BudgetRoleSchema.describe('Current user role in this budget.'), + /** Current user permissions in this budget. */ + permissions: BudgetPermissionsSchema, + /** True when this is the user main budget. */ + isMain: boolean().describe('True when this is the user main budget.'), + /** Timestamp when the budget was archived and hidden from normal workflows. */ + archivedAt: date() + .coerce() + .nullable() + .describe( + 'Timestamp when the budget was archived and hidden from normal workflows.' + ), + /** Creation timestamp. */ + createdAt: date().coerce().describe('Creation timestamp.'), + /** Last update timestamp. */ + updatedAt: date().coerce().describe('Last update timestamp.') +}).schemaName('Budget'); + +export const BudgetMemberSchema = object({ + /** Budget identifier. */ + budgetId: number().describe('Budget identifier.'), + /** User identifier. */ + userId: number().describe('User identifier.'), + /** User email address. */ + email: string().describe('User email address.'), + /** Avatar and display details for this member. */ + user: UserAvatarSummarySchema, + /** Member role. */ + role: BudgetRoleSchema.describe('Member role.'), + /** Member permissions. */ + permissions: BudgetPermissionsSchema, + /** Creation timestamp. */ + createdAt: date().coerce().describe('Creation timestamp.'), + /** Last update timestamp. */ + updatedAt: date().coerce().describe('Last update timestamp.') +}).schemaName('BudgetMember'); + +export const BudgetAccessActiveRowSchema = object({ + /** Access row status. */ + status: enumOf('active').describe('Access row status.'), + /** Budget identifier. */ + budgetId: number().describe('Budget identifier.'), + /** User identifier. */ + userId: number().describe('User identifier.'), + /** User email address. */ + email: string().describe('User email address.'), + /** Avatar and display details for this member. */ + user: UserAvatarSummarySchema, + /** Member role. */ + role: BudgetRoleSchema.describe('Member role.'), + /** Member permissions. */ + permissions: BudgetPermissionsSchema, + /** Creation timestamp. */ + createdAt: date().coerce().describe('Creation timestamp.'), + /** Last update timestamp. */ + updatedAt: date().coerce().describe('Last update timestamp.') +}).schemaName('BudgetAccessActiveRow'); + +export const BudgetAccessInvitationRowSchema = object({ + /** Access row status. */ + status: enumOf('pending', 'expired', 'accepted').describe( + 'Access row status.' + ), + /** Invitation identifier. */ + invitationId: number().describe('Invitation identifier.'), + /** Budget identifier. */ + budgetId: number().describe('Budget identifier.'), + /** Invited email address. */ + email: string().describe('Invited email address.'), + /** Role granted by the invitation. */ + role: BudgetRoleSchema.describe('Role granted by the invitation.'), + /** Permissions granted by the invitation. */ + permissions: BudgetPermissionsSchema, + /** Expiration timestamp. */ + expiresAt: date().coerce().describe('Expiration timestamp.'), + /** Consumption timestamp, when accepted. */ + consumedAt: date() + .coerce() + .nullable() + .describe('Consumption timestamp, when accepted.'), + /** Creation timestamp. */ + createdAt: date().coerce().describe('Creation timestamp.'), + /** Last update timestamp. */ + updatedAt: date().coerce().describe('Last update timestamp.') +}).schemaName('BudgetAccessInvitationRow'); + +export const BudgetAccessRowSchema = union(BudgetAccessActiveRowSchema) + .or(BudgetAccessInvitationRowSchema) + .schemaName('BudgetAccessRow'); + +const BudgetIdSchema = number() + .coerce() + .optional() + .describe( + 'Budget identifier. Defaults to the authenticated user Main budget.' + ); + +export const CreateBudgetBodySchema = object({ + /** Budget name shown to the creator in navigation and reports. */ + name: string() + .required('budget name is required') + .nonempty('budget name is required') + .maxLength(FieldLimits.budgetName, 'budget name is too long') + .describe( + 'Budget name shown to the creator in navigation and reports.' + ), + /** Default currency used for new transactions and reports in this budget. */ + defaultCurrency: CurrencyCodeSchema.optional().describe( + 'Default currency used for new transactions and reports in this budget.' + ), + /** Favorite currencies offered when entering transactions in this budget. */ + favoriteCurrencies: array(CurrencyCodeSchema) + .default([]) + .describe( + 'Favorite currencies offered when entering transactions in this budget.' + ), + /** Country used to localize vendor enrichment in this budget. */ + countryCode: CountryCodeSchema.optional().describe( + 'Country used to localize vendor enrichment in this budget.' + ) +}).schemaName('CreateBudgetBody'); + +export const UpdateBudgetBodySchema = object({ + /** Budget name shown only to the current user in navigation and reports. */ + name: string() + .minLength(1, 'budget name is required') + .maxLength(FieldLimits.budgetName, 'budget name is too long') + .optional() + .describe( + 'Budget name shown only to the current user in navigation and reports.' + ), + /** Default currency used for new transactions and reports in this budget. */ + defaultCurrency: CurrencyCodeSchema.optional().describe( + 'Default currency used for new transactions and reports in this budget.' + ), + /** Favorite currencies offered when entering transactions in this budget. */ + favoriteCurrencies: array(CurrencyCodeSchema) + .optional() + .describe( + 'Favorite currencies offered when entering transactions in this budget.' + ), + /** Country used to localize vendor enrichment in this budget. */ + countryCode: CountryCodeSchema.optional().describe( + 'Country used to localize vendor enrichment in this budget.' + ), + /** Whether this budget should be archived or restored. */ + archived: boolean() + .optional() + .describe('Whether this budget should be archived or restored.') +}).schemaName('UpdateBudgetBody'); + +export const ListBudgetsQuerySchema = object({ + /** Budget lifecycle status to include. */ + status: enumOf('active', 'archived', 'all') + .optional() + .describe('Budget lifecycle status to include.') +}).schemaName('ListBudgetsQuery'); + +export const TransactionCreatorSchema = object({ + /** User identifier that created the transaction. */ + userId: number().describe('User identifier that created the transaction.'), + /** Creator email address. */ + email: string().describe('Creator email address.'), + /** Creator avatar URL, when available. */ + avatarUrl: string() + .optional() + .describe('Creator avatar URL, when available.') +}).schemaName('TransactionCreator'); + +export const InviteBudgetMemberBodySchema = object({ + /** Email address to invite. */ + email: string() + .required('email is required') + .nonempty('email is required') + .maxLength(FieldLimits.email, 'email is too long') + .email('must be a valid email address') + .describe('Email address to invite.'), + /** Role to grant when the invitation is accepted. */ + role: BudgetRoleSchema.default('member').describe( + 'Role to grant when the invitation is accepted.' + ), + /** Permissions to grant when the invitation is accepted. */ + permissions: BudgetPermissionsSchema.optional() +}).schemaName('InviteBudgetMemberBody'); + +export const UpdateBudgetMemberBodySchema = object({ + /** Updated member role. */ + role: BudgetRoleSchema.describe('Updated member role.'), + /** Updated member permissions. */ + permissions: BudgetPermissionsSchema +}).schemaName('UpdateBudgetMemberBody'); + +export const AcceptBudgetInvitationBodySchema = object({ + /** One-time budget invitation token from the magic link. */ + token: string() + .required('invitation token is required') + .nonempty('invitation token is required') + .maxLength( + FieldLimits.budgetInviteToken, + 'invitation token is too long' + ) + .describe('One-time budget invitation token from the magic link.'), + /** Budget name shown only to the accepting user. */ + name: string() + .required('budget name is required') + .nonempty('budget name is required') + .maxLength(FieldLimits.budgetName, 'budget name is too long') + .describe('Budget name shown only to the accepting user.') +}).schemaName('AcceptBudgetInvitationBody'); + +export const BudgetInvitationResponseSchema = object({ + /** User-facing invitation status message. */ + message: string().describe('User-facing invitation status message.') +}).schemaName('BudgetInvitationResponse'); + +export const UserAvatarUploadBodySchema = object({ + /** Avatar image MIME type. */ + mimeType: ImageMimeTypeSchema.describe('Avatar image MIME type.'), + /** Base64 encoded avatar image bytes. */ + imageBase64: string() + .required('avatar image is required') + .nonempty('avatar image is required') + .maxLength( + FieldLimits.userAvatarBase64, + `avatar image must be ${UserAvatarLimits.maxImageBytes} bytes or smaller` + ) + .describe('Base64 encoded avatar image bytes.'), + /** Original upload file name, when available. */ + fileName: string() + .optional() + .maxLength( + FieldLimits.userAvatarFileName, + 'avatar file name is too long' + ) + .describe('Original upload file name, when available.') +}).schemaName('UserAvatarUploadBody'); + export const TokenResponseSchema = object({ /** Signed API JWT used as the Bearer token for protected API calls. */ token: string().describe( @@ -438,6 +757,10 @@ export const TokenResponseSchema = object({ timezone: TimeZoneSchema.describe( 'Time zone used for transaction display and reporting periods.' ), + /** User Main budget identifier. */ + mainBudgetId: number() + .nullable() + .describe('User Main budget identifier.'), /** True when the user has at least one category. */ hasCategories: boolean().describe( 'True when the user has at least one category.' @@ -450,6 +773,14 @@ export const UserPreferenceSchema = object({ id: number().describe('Unique user identifier.'), /** User email address. */ email: string().describe('User email address.'), + /** Avatar URL for rendering the current user. */ + avatarUrl: string() + .optional() + .describe('Avatar URL for rendering the current user.'), + /** True when the current user has a manually uploaded avatar. */ + hasUploadedAvatar: boolean().describe( + 'True when the current user has a manually uploaded avatar.' + ), /** Default currency used for reports and new transactions. */ defaultCurrency: CurrencyCodeSchema.describe( 'Default currency used for reports and new transactions.' @@ -470,6 +801,10 @@ export const UserPreferenceSchema = object({ timezone: TimeZoneSchema.describe( 'Time zone used for transaction display and reporting periods.' ), + /** User Main budget identifier. */ + mainBudgetId: number().nullable().describe('User Main budget identifier.'), + /** Budgets accessible to this user. */ + budgets: array(BudgetSchema).describe('Budgets accessible to this user.'), /** True when the user has at least one category. */ hasCategories: boolean().describe( 'True when the user has at least one category.' @@ -619,18 +954,10 @@ export const McpOAuthAuthorizeResponseSchema = object({ }).schemaName('McpOAuthAuthorizeResponse'); export const UpdateUserPreferenceBodySchema = object({ - /** Default currency used for reports and new transactions. */ - defaultCurrency: CurrencyCodeSchema.describe( - 'Default currency used for reports and new transactions.' - ), /** Country used to localize vendor enrichment. */ countryCode: CountryCodeSchema.optional().describe( 'Country used to localize vendor enrichment.' ), - /** Favorite currencies offered when entering transactions. */ - favoriteCurrencies: array(CurrencyCodeSchema).describe( - 'Favorite currencies offered when entering transactions.' - ), /** Time zone used for transaction display and reporting periods. */ timezone: TimeZoneSchema.optional().describe( 'Time zone used for transaction display and reporting periods.' @@ -754,6 +1081,8 @@ export const CurrencySchema = object({ }).schemaName('Currency'); export const CurrencyConversionQuerySchema = object({ + /** Budget identifier. Defaults to the authenticated user Main budget. */ + budgetId: BudgetIdSchema, /** Amount entered by the user in the original currency. */ amount: decimalNumber() .required('amount is required') @@ -779,13 +1108,13 @@ export const CurrencyConversionSchema = object({ currency: CurrencyCodeSchema.describe( 'Currency used for the entered amount.' ), - /** Amount converted to the user default currency. */ + /** Amount converted to the budget default currency. */ defaultCurrencyAmount: decimalNumber().describe( - 'Amount converted to the user default currency.' + 'Amount converted to the budget default currency.' ), - /** User default currency used for conversion. */ + /** Budget default currency used for conversion. */ defaultCurrency: CurrencyCodeSchema.describe( - 'User default currency used for conversion.' + 'Budget default currency used for conversion.' ), /** Exchange rate used for the conversion. */ exchangeRate: decimalNumber().describe( @@ -800,6 +1129,8 @@ export const CurrencyConversionSchema = object({ export const CategorySchema = object({ /** Unique category identifier. */ id: number().describe('Unique category identifier.'), + /** Budget identifier. */ + budgetId: number().describe('Budget identifier.'), /** Category name shown in transaction forms and reports. */ name: string().describe( 'Category name shown in transaction forms and reports.' @@ -846,6 +1177,8 @@ export const CategorySchema = object({ }).schemaName('Category'); export const CategoryListQuerySchema = object({ + /** Budget identifier. Defaults to the authenticated user Main budget. */ + budgetId: BudgetIdSchema, /** Optional category ordering mode. */ sort: enumOf('recent-transaction-count') .optional() @@ -860,6 +1193,8 @@ export const CategoryListQuerySchema = object({ }).schemaName('CategoryListQuery'); export const CreateCategoryBodySchema = object({ + /** Budget identifier. Defaults to the authenticated user Main budget. */ + budgetId: BudgetIdSchema, /** Category name shown in transaction forms and reports. */ name: string() .required('category name is required') @@ -924,6 +1259,8 @@ export const VendorEnrichmentStatusSchema = enumOf( export const VendorSchema = object({ /** Unique vendor identifier. */ id: number().describe('Unique vendor identifier.'), + /** Budget identifier. */ + budgetId: number().describe('Budget identifier.'), /** User-entered vendor name. */ name: string().describe('User-entered vendor name.'), /** Vendor name shown in transaction forms and reports. */ @@ -979,6 +1316,7 @@ export const VendorSchema = object({ transactionCount: number().describe( 'Number of transactions linked to this vendor.' ), + ...ContributorFields, /** Creation timestamp. */ createdAt: date().coerce().describe('Creation timestamp.'), /** Last update timestamp. */ @@ -986,6 +1324,8 @@ export const VendorSchema = object({ }).schemaName('Vendor'); export const VendorListQuerySchema = object({ + /** Budget identifier. Defaults to the authenticated user Main budget. */ + budgetId: BudgetIdSchema, /** Text search applied to vendor names and domains. */ search: string() .optional() @@ -1083,6 +1423,8 @@ export const VendorCandidateSchema = object({ }).schemaName('VendorCandidate'); export const CreateVendorBodySchema = object({ + /** Budget identifier. Defaults to the authenticated user Main budget. */ + budgetId: BudgetIdSchema, /** User-entered vendor name. */ name: string() .required('vendor name is required') @@ -1193,6 +1535,8 @@ export const TransactionTagNameSchema = string() export const TransactionTagSchema = object({ /** Unique transaction tag identifier. */ id: number().describe('Unique transaction tag identifier.'), + /** Budget identifier. */ + budgetId: number().describe('Budget identifier.'), /** User-entered transaction tag name. */ name: string().describe('User-entered transaction tag name.'), /** Number of transactions currently using this tag. */ @@ -1206,6 +1550,8 @@ export const TransactionTagSchema = object({ }).schemaName('TransactionTag'); export const TransactionTagListQuerySchema = object({ + /** Budget identifier. Defaults to the authenticated user Main budget. */ + budgetId: BudgetIdSchema, /** Text search applied to transaction tag names. */ search: string() .optional() @@ -1232,6 +1578,8 @@ const TransactionTagNamesBodySchema = array(TransactionTagNameSchema) export const TransactionSchema = object({ /** Unique transaction identifier. */ id: number().describe('Unique transaction identifier.'), + /** Budget identifier. */ + budgetId: number().describe('Budget identifier.'), /** Category identifier selected for the transaction. */ categoryId: number().describe( 'Category identifier selected for the transaction.' @@ -1276,13 +1624,13 @@ export const TransactionSchema = object({ currency: CurrencyCodeSchema.describe( 'Currency used for the entered amount.' ), - /** Amount converted to the user default currency at write time. */ + /** Amount converted to the budget default currency at write time. */ defaultCurrencyAmount: decimalNumber().describe( - 'Amount converted to the user default currency at write time.' + 'Amount converted to the budget default currency at write time.' ), - /** User default currency used when the transaction was converted. */ + /** Budget default currency used when the transaction was converted. */ defaultCurrency: CurrencyCodeSchema.describe( - 'User default currency used when the transaction was converted.' + 'Budget default currency used when the transaction was converted.' ), /** Exchange rate used for the default-currency amount. */ exchangeRate: decimalNumber().describe( @@ -1302,6 +1650,10 @@ export const TransactionSchema = object({ tags: array(TransactionTagSchema).describe( 'Tags assigned to this transaction.' ), + /** User that originally created this transaction. */ + createdBy: TransactionCreatorSchema.describe( + 'User that originally created this transaction.' + ), /** Original scanner image metadata when this transaction came from a scan. */ scanAttachment: object({ /** Scan session identifier. */ @@ -1335,6 +1687,8 @@ export const TransactionSchema = object({ }).schemaName('Transaction'); export const CreateTransactionBodySchema = object({ + /** Budget identifier. Defaults to the authenticated user Main budget. */ + budgetId: BudgetIdSchema, /** Category identifier selected for the transaction. */ categoryId: number() .required('category is required') @@ -1411,6 +1765,8 @@ export const UpdateTransactionBodySchema = ); export const TransactionListQuerySchema = object({ + /** Budget identifier. Defaults to the authenticated user Main budget. */ + budgetId: BudgetIdSchema, /** Full text search applied to category name and note. */ search: string() .optional() @@ -1483,6 +1839,8 @@ export const TransactionListQuerySchema = object({ }).schemaName('TransactionListQuery'); export const TransactionExportQuerySchema = object({ + /** Budget identifier. Defaults to the authenticated user Main budget. */ + budgetId: BudgetIdSchema, /** Full text search applied to category name and note. */ search: string() .optional() @@ -1675,6 +2033,8 @@ export const TransactionScanDraftSchema = object({ }).schemaName('TransactionScanDraft'); export const TransactionScanBodySchema = object({ + /** Budget identifier. Defaults to the authenticated user Main budget. */ + budgetId: BudgetIdSchema, /** Raw uploaded image bytes encoded as base64, without a data URL prefix. */ imageBase64: string() .required('image is required') @@ -1853,6 +2213,8 @@ export const TransactionScanImageResponseSchema = object({ }).schemaName('TransactionScanImageResponse'); export const DashboardQuerySchema = object({ + /** Budget identifier. Defaults to the authenticated user Main budget. */ + budgetId: BudgetIdSchema, /** Reporting period. */ period: PeriodSchema.default('day').describe('Reporting period.'), /** Date used to choose the reporting period. */ @@ -1872,6 +2234,8 @@ export const DashboardQuerySchema = object({ }).schemaName('DashboardQuery'); export const PeriodWindowQuerySchema = object({ + /** Budget identifier. Defaults to the authenticated user Main budget. */ + budgetId: BudgetIdSchema, /** Reporting period. */ period: PeriodSchema.default('day').describe('Reporting period.'), /** Date used to choose the center reporting period. */ @@ -1892,6 +2256,8 @@ export const PeriodWindowQuerySchema = object({ }).schemaName('PeriodWindowQuery'); export const DashboardWindowQuerySchema = object({ + /** Budget identifier. Defaults to the authenticated user Main budget. */ + budgetId: BudgetIdSchema, /** Reporting period. */ period: PeriodSchema.default('day').describe('Reporting period.'), /** Date used to choose the center reporting period. */ @@ -1921,6 +2287,8 @@ export const DashboardWindowQuerySchema = object({ }).schemaName('DashboardWindowQuery'); export const StatsQuerySchema = object({ + /** Budget identifier. Defaults to the authenticated user Main budget. */ + budgetId: BudgetIdSchema, /** Stats trend grouping. */ groupBy: StatsGroupBySchema.default('day').describe( 'Stats trend grouping.' @@ -1945,6 +2313,8 @@ export const StatsQuerySchema = object({ }).schemaName('StatsQuery'); export const StatsTagReportQuerySchema = object({ + /** Budget identifier. Defaults to the authenticated user Main budget. */ + budgetId: BudgetIdSchema, /** Reporting period. */ period: PeriodSchema.default('day').describe('Reporting period.'), /** Date used to choose the reporting period. */ @@ -1962,6 +2332,8 @@ export const StatsTagReportQuerySchema = object({ }).schemaName('StatsTagReportQuery'); export const CategoryTrendQuerySchema = object({ + /** Budget identifier. Defaults to the authenticated user Main budget. */ + budgetId: BudgetIdSchema, /** Category trend timeframe. */ range: CategoryTrendRangeSchema.default('last-12-months').describe( 'Category trend timeframe.' @@ -2027,7 +2399,8 @@ export const DashboardVendorTotalSchema = object({ /** Selected-period bucket totals for lightweight vendor charts. */ trend: array(decimalNumber()).describe( 'Selected-period bucket totals for lightweight vendor charts.' - ) + ), + ...ContributorFields }).schemaName('DashboardVendorTotal'); export const DashboardCategoryTotalSchema = object({ @@ -2070,7 +2443,8 @@ export const DashboardCategoryTotalSchema = object({ /** Selected-period bucket totals for lightweight category charts. */ trend: array(decimalNumber()).describe( 'Selected-period bucket totals for lightweight category charts.' - ) + ), + ...ContributorFields }).schemaName('DashboardCategoryTotal'); export const DashboardCategoryVendorTotalSchema = object({ @@ -2127,7 +2501,8 @@ export const DashboardCategoryVendorTotalSchema = object({ /** Selected-period bucket totals for lightweight charts. */ trend: array(decimalNumber()).describe( 'Selected-period bucket totals for lightweight charts.' - ) + ), + ...ContributorFields }).schemaName('DashboardCategoryVendorTotal'); export const StatsTrendPointSchema = object({ @@ -2135,13 +2510,13 @@ export const StatsTrendPointSchema = object({ bucket: string().describe('Stable date or month bucket key.'), /** Short label shown on charts. */ label: string().describe('Short label shown on charts.'), - /** Income total in the user's default currency. */ + /** Income total in the budget default currency. */ incomeTotal: decimalNumber().describe( - "Income total in the user's default currency." + 'Income total in the budget default currency.' ), - /** Expense total in the user's default currency. */ + /** Expense total in the budget default currency. */ expenseTotal: decimalNumber().describe( - "Expense total in the user's default currency." + 'Expense total in the budget default currency.' ), /** Income minus expense total for the bucket. */ netTotal: decimalNumber().describe( @@ -2172,9 +2547,9 @@ export const StatsCategoryTotalSchema = object({ ), /** Transaction direction. */ type: CategoryTypeSchema.describe('Transaction direction.'), - /** Category total in the user's default currency on the reported side. */ + /** Category total in the budget default currency on the reported side. */ total: decimalNumber().describe( - "Category total in the user's default currency on the reported side." + 'Category total in the budget default currency on the reported side.' ), /** Share of the matching income or expense total, as a percentage. */ share: decimalNumber().describe( @@ -2234,9 +2609,9 @@ export const StatsTagTrendPointSchema = object({ bucket: string().describe('Stable date or month bucket key.'), /** Short label shown on charts. */ label: string().describe('Short label shown on charts.'), - /** Expense total in the user's default currency. */ + /** Expense total in the budget default currency. */ expenseTotal: decimalNumber().describe( - "Expense total in the user's default currency." + 'Expense total in the budget default currency.' ), /** Number of expense transactions in the bucket. */ transactionCount: number().describe( @@ -2253,9 +2628,9 @@ export const StatsTagTotalSchema = object({ tagName: string().describe('Tag name shown in reports.'), /** Tag bucket kind. */ kind: StatsTagKindSchema.describe('Tag bucket kind.'), - /** Tag total in the user's default currency. */ + /** Tag total in the budget default currency. */ total: decimalNumber().describe( - "Tag total in the user's default currency." + 'Tag total in the budget default currency.' ), /** Share of selected period expenses, as a percentage. */ share: decimalNumber().describe( @@ -2292,9 +2667,9 @@ export const StatsTagVendorTotalSchema = object({ vendorPrimaryColor: string() .optional() .describe('Vendor primary color, when available.'), - /** Vendor total in the user's default currency. */ + /** Vendor total in the budget default currency. */ total: decimalNumber().describe( - "Vendor total in the user's default currency." + 'Vendor total in the budget default currency.' ), /** Number of selected-period expense transactions for this vendor. */ transactionCount: number().describe( @@ -2311,9 +2686,9 @@ export const StatsTagDetailSchema = object({ tagName: string().describe('Selected tag name shown in reports.'), /** Selected tag bucket kind. */ kind: StatsTagKindSchema.describe('Selected tag bucket kind.'), - /** Selected tag total in the user's default currency. */ + /** Selected tag total in the budget default currency. */ total: decimalNumber().describe( - "Selected tag total in the user's default currency." + 'Selected tag total in the budget default currency.' ), /** Selected tag share of period expenses, as a percentage. */ share: decimalNumber().describe( @@ -2550,9 +2925,9 @@ export const CategoryTrendPointSchema = object({ to: date() .coerce() .describe('Bucket end timestamp, clipped to the selected range.'), - /** Category total in the user's default currency on the reported side. */ + /** Category total in the budget default currency on the reported side. */ total: decimalNumber().describe( - "Category total in the user's default currency on the reported side." + 'Category total in the budget default currency on the reported side.' ), /** Number of transactions in the bucket. */ transactionCount: number().describe('Number of transactions in the bucket.') @@ -2591,9 +2966,9 @@ export const CategoryTrendResponseSchema = object({ to: date().coerce().describe('Selected range end timestamp.'), /** Currency used for totals. */ currency: CurrencyCodeSchema.describe('Currency used for totals.'), - /** Category total in the user's default currency on the reported side. */ + /** Category total in the budget default currency on the reported side. */ total: decimalNumber().describe( - "Category total in the user's default currency on the reported side." + 'Category total in the budget default currency on the reported side.' ), /** Number of selected-range transactions in the category. */ transactionCount: number().describe( @@ -2669,6 +3044,28 @@ export type CurrencyConversionQuery = InferType< typeof CurrencyConversionQuerySchema >; export type CurrencyConversion = InferType; +export type UserAvatarSummary = InferType; +export type BudgetRole = InferType; +export type BudgetPermissions = InferType; +export type Budget = InferType; +export type BudgetMember = InferType; +export type BudgetAccessRow = InferType; +export type CreateBudgetBody = InferType; +export type UpdateBudgetBody = InferType; +export type ListBudgetsQuery = InferType; +export type InviteBudgetMemberBody = InferType< + typeof InviteBudgetMemberBodySchema +>; +export type UpdateBudgetMemberBody = InferType< + typeof UpdateBudgetMemberBodySchema +>; +export type AcceptBudgetInvitationBody = InferType< + typeof AcceptBudgetInvitationBodySchema +>; +export type BudgetInvitationResponse = InferType< + typeof BudgetInvitationResponseSchema +>; +export type UserAvatarUploadBody = InferType; export type CategoryKind = InferType; export type Category = InferType; export type CategoryListQuery = InferType; diff --git a/pr-env.sh b/pr-env.sh index 873393f..0b4c56e 100755 --- a/pr-env.sh +++ b/pr-env.sh @@ -379,8 +379,64 @@ sync_repo() { git -C "$CHECKOUT_DIR" clean -fdx -e .env } +docker_volume_created_epoch() { + local volume_name="$1" + local created_at + + created_at="$( + docker volume inspect "$volume_name" --format '{{.CreatedAt}}' \ + 2>/dev/null || true + )" + [[ -n "$created_at" ]] || return 1 + + date -u -d "$created_at" '+%s' 2>/dev/null +} + +database_initialized() { + local volume_name="${COMPOSE_PROJECT}_postgres_data" + local marker_epoch + local volume_epoch + + if [[ ! -f "$DB_INITIALIZED_FILE" ]]; then + return 1 + fi + + marker_epoch="$(stat -c '%Y' "$DB_INITIALIZED_FILE" 2>/dev/null || true)" + if [[ ! "$marker_epoch" =~ ^[0-9]+$ ]]; then + log "Could not read ${DB_INITIALIZED_FILE} mtime; reinitializing PR database" + return 1 + fi + + volume_epoch="$(docker_volume_created_epoch "$volume_name" || true)" + if [[ ! "$volume_epoch" =~ ^[0-9]+$ ]]; then + log "Could not read ${volume_name} creation time; reinitializing PR database" + return 1 + fi + + if (( marker_epoch < volume_epoch )); then + log "${DB_INITIALIZED_FILE} predates ${volume_name}; reinitializing PR database" + return 1 + fi + + return 0 +} + +reset_pr_database() { + local pr_container="$1" + + log "Resetting PR database ${POSTGRES_DB} before restore" + docker exec -i -e "PGPASSWORD=${POSTGRES_PASSWORD}" "$pr_container" \ + psql -v ON_ERROR_STOP=1 \ + -U "$POSTGRES_USER" \ + -d "$POSTGRES_DB" < { await signIn(page); @@ -12,4 +13,3 @@ test('authenticate seeded test user', async ({ page }) => { mkdirSync(dirname(authStorageState), { recursive: true }); await page.context().storageState({ path: authStorageState }); }); - diff --git a/tests/e2e/workflows.spec.ts b/tests/e2e/workflows.spec.ts index 9fe5dae..1bc2ccf 100644 --- a/tests/e2e/workflows.spec.ts +++ b/tests/e2e/workflows.spec.ts @@ -703,6 +703,9 @@ test.describe('authenticated app workflows', () => { const mostPopular = uniqueName('E2E popular most'); const used = uniqueName('E2E popular used'); const unused = uniqueName('E2E popular unused'); + const recentOccurredAt = dateTimeLocalValue( + new Date(Date.now() - 24 * 60 * 60 * 1000) + ); await createCategory(page, mostPopular, 'expense'); await createCategory(page, used, 'expense'); @@ -713,21 +716,24 @@ test.describe('authenticated app workflows', () => { mostPopular, 'expense', '12.34', - uniqueName('E2E note') + uniqueName('E2E note'), + recentOccurredAt ); await createTransaction( page, mostPopular, 'expense', '12.34', - uniqueName('E2E note') + uniqueName('E2E note'), + recentOccurredAt ); await createTransaction( page, used, 'expense', '12.34', - uniqueName('E2E note') + uniqueName('E2E note'), + recentOccurredAt ); await page.goto('/dashboard');