Skip to content

Commit e4eb4b0

Browse files
parameshjavapkorrakuti-mvrkclaude
authored
Support bank transaction id in transactions (#17)
Co-authored-by: Paramesh Korrakuti <pkorrakuti@mavvrik.ai> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent fa1d2df commit e4eb4b0

13 files changed

Lines changed: 352 additions & 34 deletions

File tree

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
# Bank Transaction ID — Design Spec
2+
3+
**Date:** 2026-05-29
4+
**Status:** Approved (design), pending implementation plan
5+
6+
## Problem
7+
8+
Transactions need to record the **bank's own transaction reference** (UPI / NEFT
9+
UTR / cheque number) — a human-supplied identifier distinct from the app's
10+
internal `transaction_id`.
11+
12+
While scoping this, we found an existing defect: the user-facing **"Submit a
13+
payment"** form has a required "Transaction ID" text input (`name="transaction_id"`,
14+
placeholder `e.g., TXN-001`) that writes the user's free-text value **straight
15+
into `transactions.transaction_id`** — the column that is supposed to
16+
auto-generate as `YYYYMMDD-NNN` via the `set_transaction_id()` trigger. Because
17+
the field is never empty, the trigger's auto-generation is bypassed.
18+
19+
Result today:
20+
- Admin-created transactions → canonical `YYYYMMDD-NNN` IDs.
21+
- User-submitted (then approved) transactions → non-canonical IDs like `TXN-001`.
22+
23+
## Goal
24+
25+
Introduce a dedicated, optional `bank_transaction_id` column captured on **every**
26+
transaction (admin-created, user-submitted, and editable later), surfaced in the
27+
transactions table / dashboard. Restore `transaction_id` to always
28+
auto-generating cleanly.
29+
30+
## Decisions (confirmed with user)
31+
32+
- **Field rules:** optional everywhere, **not** unique. Blank is allowed (cash /
33+
historical / adjustment rows).
34+
- **Existing data:** backfill non-canonical `transaction_id` values into the new
35+
column; **leave the old `transaction_id` values untouched** (do not re-number
36+
live data).
37+
- **Scope:** all entry points — admin create form, user submit form, admin
38+
pending approve/edit, and the existing edit-transaction flow. Show it in the
39+
transactions table.
40+
41+
## Affected surface (verified file paths)
42+
43+
- DB: `scripts/prod/migrations/001_init_schema.sql` (reference only),
44+
new migration `scripts/prod/migrations/033_add_bank_transaction_id.sql`
45+
- View: `scripts/prod/migrations/003_views.sql` (`dashboard_transactions`)
46+
- Actions: `src/lib/actions/transactions.ts`
47+
(`createTransaction`, `updateTransaction`),
48+
`src/lib/actions/payments.ts` (`submitPayment`, `approvePayment`)
49+
- Forms / UI:
50+
- `src/app/(app)/admin/transactions/new/new-transaction-form.tsx`
51+
- `src/app/(app)/admin/transactions/[transaction_id]/edit-transaction-form.tsx`
52+
- `src/app/(app)/dashboard/submit-payment-form.tsx`
53+
- `src/app/(app)/admin/pending/pending-payment-row.tsx`
54+
- `src/components/transactions-table.tsx`
55+
56+
## Design
57+
58+
### 1. Migration `033_add_bank_transaction_id.sql`
59+
60+
```sql
61+
-- New optional reference column on both tables
62+
alter table public.transactions add column if not exists bank_transaction_id text;
63+
alter table public.pending_payments add column if not exists bank_transaction_id text;
64+
65+
-- Submitted payments no longer need a user-supplied canonical ID;
66+
-- transaction_id is generated by the trigger at approval time.
67+
alter table public.pending_payments alter column transaction_id drop not null;
68+
69+
-- Backfill: move non-canonical transaction_id values into the new column.
70+
-- Canonical format is YYYYMMDD-NNN (8 digits, dash, 3 digits).
71+
update public.transactions
72+
set bank_transaction_id = transaction_id
73+
where bank_transaction_id is null
74+
and transaction_id !~ '^\d{8}-\d{3}$';
75+
76+
-- pending_payments.transaction_id is always user-typed today: copy it across,
77+
-- then clear non-canonical values so future approvals auto-generate cleanly.
78+
update public.pending_payments
79+
set bank_transaction_id = coalesce(bank_transaction_id, transaction_id)
80+
where transaction_id is not null;
81+
82+
update public.pending_payments
83+
set transaction_id = null
84+
where transaction_id is not null
85+
and transaction_id !~ '^\d{8}-\d{3}$';
86+
```
87+
88+
Note: `transactions.transaction_id` keeps its `unique not null` constraint and
89+
the `set_transaction_id()` trigger — both already fill it on insert when null.
90+
We do **not** re-number existing rows.
91+
92+
### 2. View `dashboard_transactions`
93+
94+
Add `t.bank_transaction_id` to the select list. No other view changes.
95+
96+
### 3. Server actions
97+
98+
- **`createTransaction`** — read `bank_transaction_id` from FormData
99+
(`(value as string)?.trim() || null`), add to the insert payload. Continue
100+
leaving `transaction_id` unset (trigger fills it).
101+
- **`updateTransaction`** — read and include `bank_transaction_id` in the update
102+
payload.
103+
- **`submitPayment`** — read `bank_transaction_id` instead of `transaction_id`.
104+
Stop inserting `transaction_id` entirely (column is now nullable). Insert
105+
`bank_transaction_id`. The field is optional → no longer enforce required.
106+
- **`approvePayment`** — carry `bank_transaction_id` from the pending row (admin
107+
may edit it). Insert the transaction with `transaction_id` left null so the
108+
trigger generates the canonical ID. Mirror the final `bank_transaction_id`
109+
back onto the pending row alongside the existing audit fields. Drop the
110+
`finalTxnId = rawTxnId || payment.transaction_id` logic for `transaction_id`.
111+
112+
### 4. Forms / UI
113+
114+
- **`new-transaction-form.tsx`** — add an optional "Bank Transaction ID" text
115+
input (`name="bank_transaction_id"`, placeholder e.g. `UPI/NEFT ref`) near the
116+
description. Keep the "Transaction ID is auto-generated as YYYYMMDD-NNN" note.
117+
- **`edit-transaction-form.tsx`** — same input, `defaultValue` pre-filled from
118+
the transaction's `bank_transaction_id`.
119+
- **`submit-payment-form.tsx`** — relabel the existing "Transaction ID" field to
120+
"Bank Transaction ID (optional)", change `name` to `bank_transaction_id`,
121+
remove `required`, update placeholder.
122+
- **`pending-payment-row.tsx`** — display `payment.bank_transaction_id` (read
123+
mode) and provide an editable input `name="bank_transaction_id"`
124+
(`defaultValue={payment.bank_transaction_id}`) in edit mode. The raw
125+
`transaction_id` is no longer user-editable here.
126+
- **`transactions-table.tsx`** — render `bank_transaction_id` beneath the
127+
`transaction_id` cell when present (`font-mono text-xs text-muted`).
128+
129+
### 5. Types
130+
131+
Extend the row/record TypeScript types that flow from the actions/views into the
132+
table and forms to include `bank_transaction_id: string | null`
133+
(`dashboard_transactions` row type, the edit form's transaction prop, the
134+
pending payment row type).
135+
136+
## Out of scope
137+
138+
- Re-numbering existing `transaction_id` values.
139+
- Uniqueness / format validation on `bank_transaction_id`.
140+
- Any change to the donation / loan-interest specific flows beyond passing the
141+
new field through the shared actions.
142+
143+
## Testing
144+
145+
- Unit: `createTransaction` / `updateTransaction` include `bank_transaction_id`
146+
in their payloads; blank input → `null`.
147+
- Unit: `submitPayment` writes `bank_transaction_id` and does **not** set
148+
`transaction_id`; `approvePayment` leaves `transaction_id` null (trigger fills)
149+
and carries the bank ref.
150+
- Manual: create / edit / submit / approve a transaction with and without a bank
151+
ref; confirm canonical `transaction_id` and the bank ref render in the table.
152+
- `npm run build`, `npm run lint`, `npm test` must pass.
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
-- =============================================================================
2+
-- 033 — Bank transaction reference (`bank_transaction_id`).
3+
--
4+
-- Adds a dedicated, optional free-text column for the BANK's own transaction
5+
-- reference (UPI / NEFT UTR / cheque number) on both `transactions` and
6+
-- `pending_payments`. This is distinct from `transactions.transaction_id`, the
7+
-- app's internal identifier auto-generated as `YYYYMMDD-NNN` by the
8+
-- `set_transaction_id()` trigger.
9+
--
10+
-- Why: the user-facing "Submit a payment" form had a required "Transaction ID"
11+
-- input that wrote the user's free-text value straight into
12+
-- `transactions.transaction_id` (via the pending row). Because that column was
13+
-- never empty on those rows, the auto-generate trigger was bypassed, so
14+
-- user-submitted transactions ended up with non-canonical IDs (e.g. `TXN-001`)
15+
-- while admin-created ones got proper `YYYYMMDD-NNN` IDs.
16+
--
17+
-- This migration:
18+
-- 1. Adds `bank_transaction_id text` to both tables (nullable, not unique).
19+
-- 2. Drops the NOT NULL on `pending_payments.transaction_id` so submitted
20+
-- payments no longer need a user-supplied canonical ID — the trigger fills
21+
-- `transactions.transaction_id` at approval time.
22+
-- 3. Backfills: copies non-canonical `transaction_id` values into the new
23+
-- column. Existing `transactions.transaction_id` values are LEFT UNTOUCHED
24+
-- (we do not re-number live data). On `pending_payments` the non-canonical
25+
-- `transaction_id` values are cleared after being copied, so future
26+
-- approvals auto-generate a clean canonical ID.
27+
-- 4. Recreates `dashboard_transactions` to expose the new column (appended at
28+
-- the tail — `create or replace view` only allows appending columns).
29+
--
30+
-- Canonical format is `YYYYMMDD-NNN`: 8 digits, a dash, 3 digits. The regex
31+
-- `^\d{8}-\d{3}$` identifies it; anything else is treated as a user reference.
32+
--
33+
-- Re-runnable.
34+
-- =============================================================================
35+
36+
begin;
37+
38+
-- 1) New optional reference column on both tables.
39+
alter table public.transactions
40+
add column if not exists bank_transaction_id text;
41+
alter table public.pending_payments
42+
add column if not exists bank_transaction_id text;
43+
44+
-- 2) Submitted payments no longer carry a user-supplied canonical id.
45+
alter table public.pending_payments
46+
alter column transaction_id drop not null;
47+
48+
-- 3a) Backfill transactions: move any non-canonical transaction_id into the new
49+
-- column. Leave the transaction_id value itself in place.
50+
update public.transactions
51+
set bank_transaction_id = transaction_id
52+
where bank_transaction_id is null
53+
and transaction_id !~ '^\d{8}-\d{3}$';
54+
55+
-- 3b) Backfill pending_payments: copy the user-typed transaction_id across,
56+
-- then clear the non-canonical value so future approvals auto-generate.
57+
update public.pending_payments
58+
set bank_transaction_id = coalesce(bank_transaction_id, transaction_id)
59+
where transaction_id is not null;
60+
61+
update public.pending_payments
62+
set transaction_id = null
63+
where transaction_id is not null
64+
and transaction_id !~ '^\d{8}-\d{3}$';
65+
66+
-- 4) Recreate dashboard_transactions with bank_transaction_id appended.
67+
-- Column order matches migration 030's shape with the new column at the tail
68+
-- (create or replace view requires existing columns to keep their order).
69+
create or replace view public.dashboard_transactions as
70+
select
71+
t.id,
72+
t.transaction_id,
73+
t.transaction_date,
74+
t.amount,
75+
t.transaction_type,
76+
t.interest_source,
77+
t.description,
78+
t.member_id,
79+
t.loan_id,
80+
t.created_at,
81+
m.name as member_name,
82+
m.slug as member_slug,
83+
t.poll_id,
84+
t.beneficiary_name,
85+
t.bank_transaction_id
86+
from public.transactions t
87+
left join public.members m on m.id = t.member_id;
88+
89+
commit;
90+
91+
notify pgrst, 'reload schema';

src/app/(app)/admin/pending/pending-payment-row.tsx

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import type { TransactionType } from '@/lib/constants'
1111
interface PendingPayment {
1212
id: string
1313
transaction_date: string
14-
transaction_id: string
14+
bank_transaction_id: string | null
1515
amount: number
1616
transaction_type: string
1717
description: string | null
@@ -67,7 +67,11 @@ export function PendingPaymentRow({
6767
<>
6868
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-gray-500">
6969
<span>{new Date(payment.transaction_date).toLocaleDateString('en-IN')}</span>
70-
<span className="font-mono text-xs">{payment.transaction_id}</span>
70+
{payment.bank_transaction_id && (
71+
<span className="font-mono text-xs" title="Bank reference">
72+
{payment.bank_transaction_id}
73+
</span>
74+
)}
7175
<span className="font-medium text-gray-900">
7276
{formatRupees(payment.amount)}
7377
</span>
@@ -111,12 +115,13 @@ export function PendingPaymentRow({
111115
<div className="grid grid-cols-1 gap-3 rounded-md border border-gray-200 bg-gray-50/40 p-3 sm:grid-cols-2">
112116
<div>
113117
<label className="block text-xs font-medium text-gray-700">
114-
Transaction ID
118+
Bank Transaction ID
115119
</label>
116120
<input
117-
name="transaction_id"
121+
name="bank_transaction_id"
118122
type="text"
119-
defaultValue={payment.transaction_id}
123+
defaultValue={payment.bank_transaction_id ?? ''}
124+
placeholder="UPI ref / NEFT UTR"
120125
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-2 py-1 font-mono text-xs focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
121126
/>
122127
</div>

src/app/(app)/admin/transactions/[transaction_id]/edit-transaction-form.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ type Txn = {
3434
beneficiary_name: string | null
3535
poll_id: string | null
3636
description: string | null
37+
bank_transaction_id: string | null
3738
}
3839

3940
const TYPES_NEEDING_LOAN = new Set(['loan_repayment', 'penalty'])
@@ -254,6 +255,21 @@ export function EditTransactionForm({
254255
</div>
255256
)}
256257

258+
<div className="sm:col-span-2">
259+
<label htmlFor="bank_transaction_id" className="block text-xs font-medium text-gray-700">
260+
Bank Transaction ID{' '}
261+
<span className="font-normal text-gray-400">(optional · UPI/NEFT/cheque reference)</span>
262+
</label>
263+
<input
264+
id="bank_transaction_id"
265+
name="bank_transaction_id"
266+
type="text"
267+
defaultValue={txn.bank_transaction_id ?? ''}
268+
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-1.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
269+
placeholder="e.g. UPI ref / NEFT UTR"
270+
/>
271+
</div>
272+
257273
<div className="sm:col-span-2">
258274
<label htmlFor="description" className="block text-xs font-medium text-gray-700">
259275
Description

src/app/(app)/admin/transactions/[transaction_id]/page.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ export default async function AdminTransactionManagePage({
8383
beneficiary_name: txn.beneficiary_name ?? null,
8484
poll_id: txn.poll_id ?? null,
8585
description: txn.description ?? null,
86+
bank_transaction_id: txn.bank_transaction_id ?? null,
8687
}}
8788
members={members ?? []}
8889
loans={loans}

src/app/(app)/admin/transactions/new/new-transaction-form.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,22 @@ export function NewTransactionForm({
272272
</div>
273273
)}
274274

275+
<div className="sm:col-span-2">
276+
<label htmlFor="bank_transaction_id" className="block text-sm font-medium text-gray-700">
277+
Bank Transaction ID
278+
<span className="ml-1 text-xs font-normal text-gray-400">
279+
(optional · UPI/NEFT/cheque reference)
280+
</span>
281+
</label>
282+
<input
283+
id="bank_transaction_id"
284+
name="bank_transaction_id"
285+
type="text"
286+
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
287+
placeholder="e.g. UPI ref / NEFT UTR"
288+
/>
289+
</div>
290+
275291
<div className="sm:col-span-2">
276292
<label htmlFor="description" className="block text-sm font-medium text-gray-700">
277293
Description (optional)

src/app/(app)/dashboard/contributions/contributions-table.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export type ContributionRow = {
1818
transaction_date: string
1919
description?: string | null
2020
member_name?: string | null
21+
bank_transaction_id?: string | null
2122
}
2223

2324
type SortKey = 'date' | 'member' | 'type' | 'reference' | 'amount'
@@ -50,6 +51,7 @@ export function ContributionsTable({ rows }: { rows: ContributionRow[] }) {
5051
r.member_name ?? '',
5152
typeLabel(r),
5253
r.transaction_id,
54+
r.bank_transaction_id ?? '',
5355
formatDate(r.transaction_date),
5456
String(r.amount),
5557
].join(' '),
@@ -119,7 +121,12 @@ export function ContributionsTable({ rows }: { rows: ContributionRow[] }) {
119121
</td>
120122
<td className="px-4 py-3 text-gray-700">{typeLabel(t)}</td>
121123
<td className="whitespace-nowrap px-4 py-3 font-mono text-xs text-gray-500">
122-
{t.transaction_id}
124+
<div>{t.transaction_id}</div>
125+
{t.bank_transaction_id && (
126+
<div className="text-[11px] text-gray-400" title="Bank reference">
127+
{t.bank_transaction_id}
128+
</div>
129+
)}
123130
</td>
124131
<td className="whitespace-nowrap px-4 py-3 text-right font-semibold tabular-nums text-gray-900">
125132
{formatRupees(t.amount)}

src/app/(app)/dashboard/page.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -441,6 +441,7 @@ function toTxnRow(t: DashboardTxn): TxnRow {
441441
transaction_date: t.transaction_date,
442442
description: t.description,
443443
member_name: t.member_name ?? null,
444+
bank_transaction_id: t.bank_transaction_id ?? null,
444445
}
445446
}
446447

0 commit comments

Comments
 (0)