Skip to main content

Overview

Transactions are the core of Home Account’s financial tracking system. All transaction data is end-to-end encrypted on the client side before being sent to the server, ensuring your financial details remain private.
Transactions support both encrypted and unencrypted modes for backward compatibility during migration. The system automatically handles encryption when an account is unlocked.

Transaction Structure

Each transaction contains the following fields:
backend/models/transactions/index.ts
export interface Transaction {
  id: string
  account_id: string
  subcategory_id: string | null
  date: Date
  description: string
  amount: number
  bank_category: string | null
  bank_subcategory: string | null
  created_at: Date
  updated_at?: Date
  // Encrypted fields (optional during transition)
  description_encrypted?: string
  amount_encrypted?: string
  amount_sign?: 'positive' | 'negative' | 'zero'
  bank_category_encrypted?: string | null
  bank_subcategory_encrypted?: string | null
}

Creating Transactions

Client-Side: Automatic Encryption

When creating transactions from the frontend, encryption happens automatically if the account is unlocked:
frontend/lib/queries/transactions.ts
export function useCreateTransaction() {
  const getAccountKey = useCryptoStore((s) => s.getAccountKey)

  return useMutation({
    mutationFn: async (data: CreateTransactionData) => {
      const accountKey = getAccountKey(data.account_id)

      // If encryption is enabled (account unlocked), encrypt the data
      if (accountKey) {
        const encryptedData = {
          account_id: data.account_id,
          date: data.date,
          subcategory_id: data.subcategory_id,
          // Encrypted fields
          description_encrypted: await encrypt(data.description, accountKey),
          amount_encrypted: await encrypt(data.amount.toString(), accountKey),
          amount_sign: getAmountSign(data.amount),
          bank_category_encrypted: data.bank_category
            ? await encrypt(data.bank_category, accountKey)
            : null,
          bank_subcategory_encrypted: data.bank_subcategory
            ? await encrypt(data.bank_subcategory, accountKey)
            : null,
        }
        return transactionsApi.createEncrypted(encryptedData)
      }

      // Fallback: send unencrypted (for migration period)
      return transactionsApi.create(data)
    },
  })
}

Server-Side: Handling Encrypted Data

The backend controller detects whether data is encrypted and routes accordingly:
backend/controllers/transactions/transaction-controller.ts
export const createTransaction = asyncHandler(async (req: Request, res: Response) => {
  const isEncrypted = 'description_encrypted' in req.body

  if (isEncrypted) {
    const validationResult = createEncryptedTransactionSchema.safeParse(req.body)
    if (!validationResult.success) {
      const firstError = validationResult.error.issues[0]
      throw new AppError(firstError?.message || 'Datos inválidos', 400)
    }

    const transaction = await TransactionRepository.createEncrypted(
      req.user!.id,
      validationResult.data
    )

    res.status(201).json({
      success: true,
      transaction,
    })
    return
  }

  // Handle unencrypted data...
})

API Endpoint

POST /api/transactions
Content-Type: application/json
X-CSRF-Token: <token>

{
  "account_id": "uuid",
  "date": "2026-03-05",
  "description_encrypted": "base64-encrypted-data",
  "amount_encrypted": "base64-encrypted-data",
  "amount_sign": "negative",
  "subcategory_id": "uuid"
}

Viewing Transactions

Fetching with Automatic Decryption

Transactions are automatically decrypted on the client side when fetched:
frontend/lib/queries/transactions.ts
export function useTransactions(params: TransactionParams, options?: UseTransactionsOptions) {
  const getAccountKey = useCryptoStore((s) => s.getAccountKey)
  const isCryptoReady = useCryptoReady(params.account_id)

  return useQuery({
    queryKey: transactionKeys.list(params),
    queryFn: async () => {
      const response = await transactionsApi.getAll(params)

      // Decrypt if account is unlocked and data is encrypted
      const accountKey = getAccountKey(params.account_id)
      if (accountKey && response.transactions.length > 0) {
        const decryptedTxs = await Promise.all(
          response.transactions.map((t) =>
            decryptTransaction(t as Transaction & Partial<EncryptedTransaction>, accountKey)
          )
        )
        return { ...response, transactions: decryptedTxs }
      }

      return response
    },
    // CRITICAL: Only fetch when crypto is ready
    enabled: (options?.enabled ?? true) && !!params.account_id && isCryptoReady,
  })
}

Pagination and Filtering

The API supports extensive filtering options:
backend/controllers/transactions/transaction-controller.ts
export const getTransactions = asyncHandler(async (req: Request, res: Response) => {
  const {
    account_id,
    start_date,
    end_date,
    subcategory_id,
    min_amount,
    max_amount,
    search,
    type,
    limit,
    offset,
  } = validationResult.data

  const { transactions, total } = await TransactionRepository.getByAccountIdWithPagination(
    {
      account_id,
      startDate: start_date,
      endDate: end_date,
      subcategory_id,
      minAmount: min_amount,
      maxAmount: max_amount,
      search,
      type,
      limit,
      offset,
    },
    req.user!.id
  )

  res.status(200).json({
    success: true,
    transactions,
    total,
    limit: limit || 50,
    offset: offset || 0,
  })
})
  • account_id (required): The account to query
  • start_date: ISO date string (e.g., “2026-01-01”)
  • end_date: ISO date string
  • subcategory_id: Filter by subcategory
  • min_amount: Minimum transaction amount
  • max_amount: Maximum transaction amount
  • search: Text search in descriptions
  • type: Filter by “income”, “expense”, or “all”
  • limit: Results per page (default: 50)
  • offset: Pagination offset (default: 0)

Updating Transactions

Update Endpoint

Updates follow the same encryption pattern as creation:
backend/controllers/transactions/transaction-controller.ts
export const updateTransaction = asyncHandler(async (req: Request, res: Response) => {
  const { id } = req.params
  const isEncrypted = 'description_encrypted' in req.body || 'amount_encrypted' in req.body

  if (isEncrypted) {
    const validationResult = updateEncryptedTransactionSchema.safeParse(req.body)
    if (!validationResult.success) {
      const firstError = validationResult.error.issues[0]
      throw new AppError(firstError?.message || 'Datos inválidos', 400)
    }

    const transaction = await TransactionRepository.updateEncrypted(
      id,
      req.user!.id,
      validationResult.data
    )

    if (!transaction) {
      throw new AppError('Transacción no encontrada', 404)
    }

    res.status(200).json({
      success: true,
      transaction,
    })
    return
  }

  // Handle unencrypted updates...
})

Bulk Updates

Update multiple transactions at once by their IDs:
backend/controllers/transactions/transaction-controller.ts
export const bulkUpdateByIds = asyncHandler(async (req: Request, res: Response) => {
  const validationResult = bulkUpdateByIdsSchema.safeParse(req.body)
  if (!validationResult.success) {
    const firstError = validationResult.error.issues[0]
    throw new AppError(firstError?.message || 'Datos inválidos', 400)
  }

  const { account_id, transaction_ids, subcategory_id } = validationResult.data

  const updatedCount = await TransactionRepository.bulkUpdateByIds(
    account_id,
    req.user!.id,
    transaction_ids,
    subcategory_id
  )

  res.status(200).json({
    success: true,
    updatedCount,
    transaction_ids,
    subcategory_id,
  })
})

Deleting Transactions

Delete Endpoint

backend/controllers/transactions/transaction-controller.ts
export const deleteTransaction = asyncHandler(async (req: Request, res: Response) => {
  const { id } = req.params
  const deleted = await TransactionRepository.delete(id, req.user!.id)

  if (!deleted) {
    throw new AppError('Transacción no encontrada', 404)
  }

  res.status(200).json({
    success: true,
    message: 'Transacción eliminada correctamente',
  })
})
Deleting a transaction is permanent and cannot be undone. Ensure you have proper confirmation flows in your UI.

API Routes

All transaction routes require authentication:
backend/routes/transactions/transaction-routes.ts
const router: Router = Router()

router.use(authenticateToken)

// GETs - CRUD básico (los stats/summary se calculan client-side con E2E)
router.get('/', getTransactions)
router.get('/:id', getTransactionById)

// Mutaciones
router.post('/', checkCSRF, createTransaction)
router.put('/bulk-update-by-ids', checkCSRF, bulkUpdateByIds)
router.put('/:id', checkCSRF, updateTransaction)
router.delete('/:id', checkCSRF, deleteTransaction)
All mutation endpoints (POST, PUT, DELETE) require CSRF tokens for security. GET requests do not require CSRF tokens.

Encryption Flow

1

User Unlocks Account

When a user unlocks their account, the account encryption key is loaded into the crypto store on the client side.
2

Transaction Creation

The user creates a transaction. The frontend detects the account is unlocked and encrypts sensitive fields (description, amount, bank categories) using the account key.
3

Server Storage

The server receives encrypted data and stores it directly in the database without ever seeing the plaintext values.
4

Transaction Retrieval

When fetching transactions, the server returns encrypted data. The client automatically decrypts it using the account key from the crypto store.
5

Account Lock

When the user locks their account, the key is cleared from memory, and encrypted transactions can no longer be decrypted until the account is unlocked again.

Client-Side Statistics

Because transaction data is encrypted, statistics are calculated on the client side after decryption:
frontend/lib/queries/transactions.ts
function calculateStatsFromTransactions(transactions: Transaction[]): StatsResponse['stats'] {
  const incomeTransactions = transactions.filter((t) => Number(t.amount) > 0)
  const income = incomeTransactions.reduce((sum, t) => sum + Number(t.amount), 0)

  const expenses = transactions
    .filter((t) => Number(t.amount) < 0)
    .reduce((sum, t) => sum + Math.abs(Number(t.amount)), 0)

  const balance = income - expenses

  return {
    income,
    expenses,
    balance,
    transactionCount: transactions.length,
    incomeByType: {},
  }
}

export function useTransactionStats(
  accountId: string,
  startDate: string,
  endDate: string,
  options?: UseTransactionStatsOptions & { subcategory_id?: string }
) {
  const {
    data: txData,
    isLoading,
    error,
  } = useTransactions(
    {
      account_id: accountId,
      start_date: startDate,
      end_date: endDate,
      subcategory_id: options?.subcategory_id,
      limit: 10000,
    },
    {
      enabled: options?.enabled,
    }
  )

  const stats = txData?.transactions ? calculateStatsFromTransactions(txData.transactions) : null

  return {
    data: stats ? { success: true, stats } : options?.initialData,
    isLoading,
    error,
  }
}

Best Practices

Always Use React Query Hooks

Use useTransactions(), useCreateTransaction(), etc. These hooks handle encryption/decryption automatically.

Check Crypto Status

Before fetching transactions, ensure the account is unlocked using useCryptoReady(accountId).

Handle Large Datasets

For statistics and summaries, set a high limit (10000) to fetch all transactions for accurate calculations.

Sanitize User Input

The backend automatically sanitizes descriptions and categories using sanitizeForStorage() to prevent XSS attacks.

Build docs developers (and LLMs) love