Date: 2026-05-18 Branch: alexlazarian/clu-354
Two enhancements to the "Add Expense" quick action form:
- "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.
- "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.
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
expensesfrom the data dict before passing torepo.update() - After the scalar update, append each new expense as a
models.Expenseinstance toinstance.expenseinside the existingasync 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.
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— addlistAllFinancialTransactions,updateFinancialTransactioncomponents/forms/modules/expense/schema.ts— addlinkedExpenseSchema,getLinkedExpenseDefaults,LinkedExpenseFormValuescomponents/forms/modules/expense/ExpenseFormFields.tsx— add invoice number info note; add bidirectional amount auto-fillcomponents/forms/modules/expense/LinkedExpenseFormFields.tsx— new "Yes" path fields componentcomponents/forms/modules/expense/index.ts— export new schema + componentcomponents/forms/lookups/FinancialTransactionCombobox.tsx— new lookup comboboxcomponents/actions/AddExpense.tsx— radio toggle, conditional form mount + submit routing
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.
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
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,
};
}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.
Four fields in order (matching mockup):
- Expense Amount — number input, required
- Financial Transaction —
FinancialTransactionCombobox, required - Expense Type — reuse
ExpenseTypeCombobox, required - Is Reimbursable — checkbox, default checked
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().
- Add
partOfExistingboolean state, defaultfalse - Radio group as first rendered field: "Part of Existing Transaction?" with Yes / No options. Default: No.
- When
partOfExisting === false: existing form +<ExpenseFormFields>+createFinancialTransactionon submit (unchanged path) - When
partOfExisting === true: separateuseForm<LinkedExpenseFormValues>+<LinkedExpenseFormFields>+updateFinancialTransactionon 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.
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
Both paths require all marked fields before submit. Zod schemas enforce:
- "Yes":
transaction_idnon-empty,expense_amountpresent,expense_type_idnon-empty - "No": existing schema unchanged
- Unit test (schema):
linkedExpenseSchemavalidates required fields and rejects missingtransaction_id/expense_amount - Unit test (backend): PATCH with
expensesappends 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)