Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save alexlazarian/7e20fbb6cb9a64e7a9371d768c4fb59d to your computer and use it in GitHub Desktop.

Select an option

Save alexlazarian/7e20fbb6cb9a64e7a9371d768c4fb59d to your computer and use it in GitHub Desktop.
CLU-354: Add Expense Quick Action — Design Spec

CLU-354: Add Expense Quick Action — Transaction Link & Auto-Fill Enhancements

Date: 2026-05-18 Branch: alexlazarian/clu-354


Overview

Two enhancements to the "Add Expense" quick action form:

  1. "Yes" path — a new radio toggle "Part of Existing Transaction?" lets users link an expense to an existing financial transaction instead of creating a new one.
  2. "No" path improvements — an info note below Invoice Number, and auto-fill of all three amount fields (Invoice Amount, Payment Amount, Expense Amount) when any one is entered.

Backend Fix Required (Decision Log)

Issue discovered: The PATCH /api/v2/financial-transactions/{id} endpoint silently ignores the expenses field. Root cause: the Pydantic model field is named expenses but the SQLAlchemy ORM relationship is named expense. update_nested_sqlalchemy_models_v2 calls getattr(instance, "expenses"), gets AttributeError, and skips the field entirely. Additionally, the utility's list-handling logic uses zip() over existing items — it updates in place, not append.

Decision: Fix the PATCH route directly. Inside update_transaction in api_hub/api/v2/routes/financial_transaction.py:

  • Pop expenses from the data dict before passing to repo.update()
  • After the scalar update, append each new expense as a models.Expense instance to instance.expense inside the existing async with db.begin(): block

This is a targeted backend fix that keeps the route-owned transaction pattern intact (per API Hub CLAUDE.md) and unblocks the "Yes" path submit.


Architecture

Files changed

Backend (projects/api-hub):

  • api_hub/api/v2/routes/financial_transaction.py — fix PATCH to append expenses

Frontend (projects/next-js/ui-2.0):

  • lib/services/financial-transactions.ts — add listAllFinancialTransactions, updateFinancialTransaction
  • components/forms/modules/expense/schema.ts — add linkedExpenseSchema, getLinkedExpenseDefaults, LinkedExpenseFormValues
  • components/forms/modules/expense/ExpenseFormFields.tsx — add invoice number info note; add bidirectional amount auto-fill
  • components/forms/modules/expense/LinkedExpenseFormFields.tsx — new "Yes" path fields component
  • components/forms/modules/expense/index.ts — export new schema + component
  • components/forms/lookups/FinancialTransactionCombobox.tsx — new lookup combobox
  • components/actions/AddExpense.tsx — radio toggle, conditional form mount + submit routing

Design Details

1. Backend: PATCH expense appending

In update_transaction, after data = payload.model_dump(exclude_unset=True):

expenses_to_add = data.pop("expenses", None)
async with db.begin():
    existing = await repo.get(transaction_id)
    # ... existing validation ...
    instance = await repo.update(transaction_id, data)
    await db.flush()
    if expenses_to_add:
        for exp in expenses_to_add:
            instance.expense.append(models.Expense(**exp))
    await db.refresh(instance)

update_options already eager-loads expense via selectinload — no lazy-load issue.

Add a test case: PATCH with expenses: [...] on a transaction that already has expenses should result in the new expense being appended, not replacing existing ones.

2. Service layer

listAllFinancialTransactions(api, opts)

  • GET /v2/financial-transactions
  • Accepts { page?, pageSize?, searchTerm? }
  • Returns OffsetPaginatedResult<FinancialTransactionResponse>
  • Same normalization pattern as listFinancialTransactionsByMatter

updateFinancialTransaction(api, transactionId, data)

  • PATCH /v2/financial-transactions/{transactionId}
  • Accepts Partial<FinancialTransactionUpdate>-shaped payload
  • Returns FinancialTransactionResponse

3. Schema additions (expense/schema.ts)

export const linkedExpenseSchema = z.object({
  transaction_id: z.string().min(1, "Required"),
  expense_amount: z.number({ required_error: "Required" }),
  expense_type_id: z.string().min(1, "Required"),
  is_reimbursable: z.boolean(),
});

export type LinkedExpenseFormValues = z.infer<typeof linkedExpenseSchema>;

export function getLinkedExpenseDefaults(): LinkedExpenseFormValues {
  return {
    transaction_id: "",
    expense_amount: undefined as unknown as number,
    expense_type_id: "",
    is_reimbursable: true,
  };
}

4. ExpenseFormFields.tsx changes

Invoice number info note — rendered as a small callout directly below the Invoice Number / Invoice Date row:

"If you do not have an invoice number, use something to identify the document. For example, for a USPS receipt, you can use the tracking number."

Bidirectional amount auto-fill — when invoice_amount, payment_amount, or expense_amount changes and the other two fields have not been manually touched, auto-fill them with the same value. Uses useEffect watching each of the three fields individually. A field is considered "manually touched" if form.getFieldState(name).isTouched is true. Auto-fill uses setValue with shouldDirty: false, shouldTouch: false so it doesn't mark the auto-filled fields as touched. User can still override any field after auto-fill.

5. LinkedExpenseFormFields.tsx

Four fields in order (matching mockup):

  1. Expense Amount — number input, required
  2. Financial Transaction — FinancialTransactionCombobox, required
  3. Expense Type — reuse ExpenseTypeCombobox, required
  4. Is Reimbursable — checkbox, default checked

6. FinancialTransactionCombobox

Follows ExpenseTypeCombobox pattern. Uses useApiClient + useQuery calling listAllFinancialTransactions. Dropdown option label:

{invoice_number ?? "No invoice #"} · {invoice_date ?? "—"} · ${invoice_amount ?? "—"}

Accepts value (selected transaction ID), onChange, placeholder props.

Query key: queryKeys.all.financialTransactions().

7. AddExpense.tsx orchestration

  • Add partOfExisting boolean state, default false
  • Radio group as first rendered field: "Part of Existing Transaction?" with Yes / No options. Default: No.
  • When partOfExisting === false: existing form + <ExpenseFormFields> + createFinancialTransaction on submit (unchanged path)
  • When partOfExisting === true: separate useForm<LinkedExpenseFormValues> + <LinkedExpenseFormFields> + updateFinancialTransaction on submit

Submit payload for "Yes" path:

updateFinancialTransaction(api, values.transaction_id, {
  expenses: [{
    matter_id: context.matterId,
    expense_type_id: values.expense_type_id,
    expense_amount: values.expense_amount,
    is_reimbursable: values.is_reimbursable,
  }]
})

Cache invalidations on success (same as existing): financialTransactions, expenses, expensesPaginated, matter(context.matterId).

Switching between Yes/No resets the respective form to defaults.


Data Flow

AddExpense.tsx
├── partOfExisting = false (default)
│   ├── form: useForm<ExpenseFormValues>(expenseSchema)
│   ├── <ExpenseFormFields> (with info note + amount auto-fill)
│   └── submit → createFinancialTransaction (POST)
│
└── partOfExisting = true
    ├── form: useForm<LinkedExpenseFormValues>(linkedExpenseSchema)
    ├── <LinkedExpenseFormFields>
    │   ├── Expense Amount input
    │   ├── <FinancialTransactionCombobox> → listAllFinancialTransactions (GET)
    │   ├── <ExpenseTypeCombobox>
    │   └── Is Reimbursable checkbox
    └── submit → updateFinancialTransaction (PATCH) with expenses array

Validation

Both paths require all marked fields before submit. Zod schemas enforce:

  • "Yes": transaction_id non-empty, expense_amount present, expense_type_id non-empty
  • "No": existing schema unchanged

Testing

  • Unit test (schema): linkedExpenseSchema validates required fields and rejects missing transaction_id / expense_amount
  • Unit test (backend): PATCH with expenses appends a new expense to an existing transaction; existing expenses are preserved
  • No browser test required for this change (no new route, no auth boundary change)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment