feat: add linked transactions

This commit is contained in:
2026-07-11 16:45:34 -03:00
parent 22fda07f71
commit 0277b1464f
10 changed files with 328 additions and 51 deletions
+1
View File
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Added
- Icons in the categories to improve viewing and usage.
- Pre-set of categories to help users get started.
- Linked transactions allowing to propagate changes or deletion.
### Docker images
- `docker.io/rmcampos/ledger-backend:v2026.07.11.82`
+83 -20
View File
@@ -1,6 +1,74 @@
# Ledger — Outstanding Work
## Status as of end of session (2026-07-09, latest session)
## Status as of end of session (2026-07-11, latest session)
**Done, not yet committed: §8 series tracking + scope-aware edit/delete**
(requested 2026-07-11, planned via plan mode, plan saved at
`/home/ricardo/.claude/plans/idempotent-hugging-cupcake.md`). Resolves §8's
long-standing gap (repeat/installment rows only shared a cosmetic
`seriesInfo` string, no real grouping key) and adds the "apply to future
occurrences?" prompt on edit/delete the user asked for.
- Backend: two new nullable columns on `Transaction``seriesId` (Java
`UUID.randomUUID().toString()`, `VARCHAR(36)`, `V8__transaction_series_id.sql`,
indexed) and `seriesRepeat` (the `RepeatFrequency` the batch was created
with, `V9__transaction_series_repeat.sql`) — both set once per batch in
`TransactionResource#create`, identically across every generated row, both
null for one-off transactions (no backfill for pre-existing rows — they
keep their `seriesInfo` display string and fall back to plain single-row
edit/delete, per §8's already-accepted tradeoff).
- New `EditScope` enum (`THIS`/`FUTURE`). `PUT /transactions/{id}` gained an
optional `scope` body field (default `THIS`, backward compatible);
`DELETE /transactions/{id}` gained an optional `?scope=` query param
(default `THIS`). `FUTURE` is only honored when the target row has a
non-null `seriesId`.
- Edit "this and future": `description`/`category`/`billDueDate` flat-set
across every row from the edited occurrence onward (via new
`seriesFrom(anchor)`, `occurredOn`-then-`id` ordered). `occurredOn` is
**never** propagated — only the anchor row's own date changes.
`amount` on an `INSTALLMENTS` series re-splits the entered total evenly
across the remaining rows (reuses the existing `splitAmount` helper,
same remainder-on-last-row rule as creation); non-INSTALLMENTS series
flat-set every affected row to the entered amount.
- Delete "this and future": deletes every row from the target occurrence
onward in the same series.
- Every delete (single or batch) calls new `renumberSeries()`, which
relabels remaining rows' `seriesInfo` contiguously (closing gaps like
`1/12,2/12,4/12,5/12``1/11,2/11,3/11...`), and clears
`seriesId`/`seriesInfo`/`seriesRepeat` entirely if a series collapses to
one remaining row (it silently reverts to a plain non-series
transaction — no scope UI shown for it anymore).
- Frontend (`Transactions.jsx` and its line-for-line duplicate block in
`CardBills.jsx`, both updated identically): the in-row edit form shows a
"This occurrence only" / "This and future" radio pair — and, when the
latter is picked on an `INSTALLMENTS` row, a one-line re-split hint —
only when `t.seriesId` is set; the delete confirm panel swaps its single
"Delete" button for "Delete this only" / "Delete this & future" under the
same condition. Non-series transactions render zero new UI, byte-for-byte
unchanged from before. `TransactionsApi.remove` gained a defaulted
`scope` param (`'THIS'`); `update`'s payload just gained one more
optional key.
Verified via curl (fresh signup, INSTALLMENTS batch creation confirms
shared `seriesId`; `PUT .../2?scope=FUTURE` on occurrence 2 of 4 re-split a
new $90 total across occurrences 2-4 leaving occurrence 1 untouched, and
only occurrence 2's own date changed; `DELETE .../1?scope=THIS` renumbered
the remaining three from `2/4,3/4,4/4` to `1/3,2/3,3/3`; a further
`DELETE ...?scope=FUTURE` collapsing a series to one row correctly nulled
out its `seriesId`/`seriesInfo`/`seriesRepeat`; a separate uneven-total
re-split ($100 over 3) landed `33.33/33.33/33.34`, remainder on the last
row as designed; a plain non-series transaction created/deleted with no
`scope` param at all, confirming backward compatibility) and via a fresh
Playwright pass (`playwright-core` + cached Chromium installed ad hoc into
scratchpad, no OS-level deps available so `--with-deps` wasn't used) on
both `Transactions.jsx` and `CardBills.jsx`: scope radios and the
INSTALLMENTS hint appear only on series rows, the re-split renders
correctly in the row list after saving, deleting "this only" renumbers the
badge in place, and a non-series row (`Coffee`) shows the exact pre-change
single-button edit/delete UI with no radios. No console errors in any
pass.
## Status as of end of session (2026-07-09, previous session)
**Product.md audit → 5 fix chunks, all done, each reviewed/committed
individually by the user as we went.** User wrote `Product.md` (repo root,
@@ -645,27 +713,22 @@ possibly reference the row (Budget's and Transaction's case).
feature below — nothing outstanding right now beyond §5's demo-login bug,
§6's backend gaps, and the new §8 item below (neither urgent).
## 8. Recurring-series tracking at the DB level (raised 2026-07-07, not started)
## 8. Recurring-series tracking at the DB level — [x] done (raised 2026-07-07, resolved 2026-07-11)
Today a repeat/installment purchase generates N independent `Transaction`
rows linked only by a cosmetic `seriesInfo` string (e.g. `"3/12"`) — there is
no `series_id`/group column anywhere. Consequence, confirmed with user:
editing or deleting one occurrence only ever touches that single row.
Deleting one leaves a gap in the `seriesInfo` numbering (e.g.
`1/12, 2/12, 4/12, 5/12...`) since nothing renumbers the rest, and there's
no "edit/delete this and all future occurrences" option.
Was: a repeat/installment purchase generated N independent `Transaction`
rows linked only by a cosmetic `seriesInfo` string (e.g. `"3/12"`), no real
`series_id`/group column anywhere — editing/deleting one occurrence only
ever touched that single row, deleting one left a gap in the numbering, no
"edit/delete this and all future occurrences" option.
If this is worth fixing later:
- Add a real `series_id` (e.g. a generated UUID or a self-referential FK to
the first row) on `Transaction`, set at generation time in
`TransactionResource#create`, so occurrences are actually queryable as a
group instead of only sharing a display string.
- Decide the desired bulk behavior once tracked: renumber remaining
`seriesInfo` labels after a delete, and/or add "delete this and all
future" / "edit all remaining" affordances on the frontend.
- Not urgent — no one has hit this in practice yet, purely a known gap from
how §-recurrence was deliberately kept simple (pre-generate flat rows, no
scheduler, no series table — see the entry above).
Now fixed — see the 2026-07-11 session entry above for full detail:
- [x] Real `series_id` (Java-generated UUID, `V8__transaction_series_id.sql`)
set at generation time in `TransactionResource#create`.
- [x] `seriesInfo` renumbers contiguously after any delete; a series
collapsed to one row clears its series fields entirely.
- [x] "This occurrence only" / "This and future" scope choice on both edit
and delete, frontend + backend, including INSTALLMENTS-aware amount
re-splitting.
**Done, not yet committed: Recurrence/installments** (requested 2026-07-07).
- Backend: `Transaction` gained `seriesInfo` (nullable String, e.g. `"3/12"`,
@@ -0,0 +1,6 @@
package com.ledger.transaction;
public enum EditScope {
THIS,
FUTURE
}
@@ -41,6 +41,15 @@ public class Transaction extends PanacheEntityBase {
@Column(name = "series_info")
public String seriesInfo;
/** Opaque token shared by every row generated together in one repeat/installment batch. Not a FK, just a grouping value — see TransactionResource#create. Null for one-off transactions. */
@Column(name = "series_id", length = 36)
public String seriesId;
/** The RepeatFrequency the series was created with, set/cleared in lockstep with seriesId. Drives scope-aware edit behavior — INSTALLMENTS re-splits on "this and future" amount edits, others flat-set. */
@Enumerated(EnumType.STRING)
@Column(name = "series_repeat", length = 20)
public RepeatFrequency seriesRepeat;
/**
* Only meaningful for CREDIT_CARD account transactions — the bill this
* charge was explicitly assigned to. Display/grouping tag only, same
@@ -75,4 +84,8 @@ public class Transaction extends PanacheEntityBase {
public static List<Transaction> findByAccount(Long accountId) {
return list("account.id", io.quarkus.panache.common.Sort.descending("occurredOn"), accountId);
}
public static List<Transaction> findBySeries(String seriesId) {
return list("seriesId", io.quarkus.panache.common.Sort.ascending("occurredOn").and("id"), seriesId);
}
}
@@ -50,6 +50,8 @@ public class TransactionResource {
RepeatFrequency repeat = request.repeat != null ? request.repeat : RepeatFrequency.NONE;
int count = repeat == RepeatFrequency.NONE ? 1 : requireOccurrences(request.occurrences);
BigDecimal[] amounts = splitAmount(request.amount, repeat, count);
String seriesId = count > 1 ? java.util.UUID.randomUUID().toString() : null;
RepeatFrequency seriesRepeat = count > 1 ? repeat : null;
List<Transaction> created = new ArrayList<>();
LocalDate date = request.occurredOn != null ? request.occurredOn : LocalDate.now();
@@ -61,6 +63,8 @@ public class TransactionResource {
txn.amount = amounts[i];
txn.occurredOn = date;
txn.seriesInfo = count > 1 ? (i + 1) + "/" + count : null;
txn.seriesId = seriesId;
txn.seriesRepeat = seriesRepeat;
txn.billDueDate = repeat == RepeatFrequency.NONE ? request.billDueDate : null;
txn.persist();
created.add(txn);
@@ -113,11 +117,12 @@ public class TransactionResource {
throw new WebApplicationException("Cannot directly edit a credit card bill transaction", 400);
}
txn.description = request.description;
txn.amount = request.amount;
txn.occurredOn = request.occurredOn != null ? request.occurredOn : txn.occurredOn;
txn.category = request.categoryId != null ? Category.findById(request.categoryId) : null;
txn.billDueDate = request.billDueDate;
EditScope scope = request.scope != null ? request.scope : EditScope.THIS;
if (scope == EditScope.FUTURE && txn.seriesId != null) {
applyToSeries(txn, request);
} else {
applyToSingle(txn, request);
}
billSync.recomputeAccountBalance(txn.account);
if (txn.account.kind == AccountKind.CREDIT_CARD) {
@@ -126,22 +131,107 @@ public class TransactionResource {
return txn;
}
private void applyToSingle(Transaction txn, UpdateTransactionRequest request) {
txn.description = request.description;
txn.amount = request.amount;
txn.occurredOn = request.occurredOn != null ? request.occurredOn : txn.occurredOn;
txn.category = request.categoryId != null ? Category.findById(request.categoryId) : null;
txn.billDueDate = request.billDueDate;
}
/**
* "This and future": description/category/billDueDate propagate to every
* row from the anchor onward. occurredOn is never propagated — only the
* anchor's own date changes, and seriesFrom() uses the anchor's original
* date, so moving it can't change which rows count as "future".
*/
private void applyToSeries(Transaction anchor, UpdateTransactionRequest request) {
List<Transaction> rest = seriesFrom(anchor);
Category category = request.categoryId != null ? Category.findById(request.categoryId) : null;
if (anchor.seriesRepeat == RepeatFrequency.INSTALLMENTS) {
BigDecimal[] amounts = splitAmount(request.amount, RepeatFrequency.INSTALLMENTS, rest.size());
for (int i = 0; i < rest.size(); i++) {
Transaction t = rest.get(i);
t.description = request.description;
t.category = category;
t.billDueDate = request.billDueDate;
t.amount = amounts[i];
}
} else {
for (Transaction t : rest) {
t.description = request.description;
t.category = category;
t.billDueDate = request.billDueDate;
t.amount = request.amount;
}
}
anchor.occurredOn = request.occurredOn != null ? request.occurredOn : anchor.occurredOn;
}
/** This row and every row scheduled on/after it within the same series — occurredOn primary, id tie-break for same-day rows. */
private List<Transaction> seriesFrom(Transaction anchor) {
return Transaction.list(
"seriesId = ?1 and (occurredOn > ?2 or (occurredOn = ?2 and id >= ?3))",
io.quarkus.panache.common.Sort.ascending("occurredOn").and("id"),
anchor.seriesId, anchor.occurredOn, anchor.id);
}
@DELETE
@Path("/{id}")
@Transactional
public void delete(@PathParam("id") Long id) {
public void delete(@PathParam("id") Long id, @QueryParam("scope") @DefaultValue("THIS") EditScope scope) {
Transaction txn = requireOwnedTransaction(id);
if (txn.linkedCard != null) {
throw new WebApplicationException("Cannot directly delete a credit card bill transaction", 400);
}
Account account = txn.account;
txn.delete();
String seriesId = txn.seriesId;
if (scope == EditScope.FUTURE && seriesId != null) {
for (Transaction t : seriesFrom(txn)) {
t.delete();
}
} else {
txn.delete();
}
if (seriesId != null) {
renumberSeries(seriesId);
}
billSync.recomputeAccountBalance(account);
if (account.kind == AccountKind.CREDIT_CARD) {
billSync.sync(account);
}
}
/**
* Keeps seriesInfo contiguous after any delete (single-row or "this and
* future" batch): e.g. 1/12,2/12,4/12,5/12... becomes 1/11,2/11,3/11...
* A series collapsed down to one remaining row is no longer meaningfully
* a series — clear its series fields so it falls back to plain
* single-row edit/delete and drops the seriesInfo badge.
*/
private void renumberSeries(String seriesId) {
List<Transaction> remaining = Transaction.findBySeries(seriesId);
if (remaining.isEmpty()) {
return;
}
if (remaining.size() == 1) {
Transaction only = remaining.get(0);
only.seriesInfo = null;
only.seriesId = null;
only.seriesRepeat = null;
return;
}
int total = remaining.size();
for (int i = 0; i < total; i++) {
remaining.get(i).seriesInfo = (i + 1) + "/" + total;
}
}
private Account requireOwnedAccount(Long accountId) {
User user = currentUser.require();
Account account = Account.findById(accountId);
@@ -243,5 +333,7 @@ public class TransactionResource {
public BigDecimal amount;
public LocalDate occurredOn;
public LocalDate billDueDate;
/** THIS (default when omitted) or FUTURE. FUTURE is only honored when the target transaction has a non-null seriesId — see TransactionResource#update. */
public EditScope scope;
}
}
@@ -0,0 +1,2 @@
ALTER TABLE transactions ADD COLUMN series_id VARCHAR(36);
CREATE INDEX idx_transactions_series_id ON transactions (series_id) WHERE series_id IS NOT NULL;
@@ -0,0 +1 @@
ALTER TABLE transactions ADD COLUMN series_repeat VARCHAR(20);
+3 -2
View File
@@ -31,8 +31,9 @@ export const TransactionsApi = {
listByAccount: (accountId) =>
client.get(`/transactions/account/${accountId}`).then((r) => r.data),
create: (payload) => client.post('/transactions', payload).then((r) => r.data),
update: (id, payload) => client.put(`/transactions/${id}`, payload).then((r) => r.data),
remove: (id) => client.delete(`/transactions/${id}`),
update: (id, payload) => client.put(`/transactions/${id}`, payload).then((r) => r.data), // payload may include scope: 'THIS' | 'FUTURE'
remove: (id, scope = 'THIS') =>
client.delete(`/transactions/${id}`, { params: { scope } }).then((r) => r.data),
};
export const ImportApi = {
+60 -11
View File
@@ -319,6 +319,7 @@ export default function CardBills() {
const [editAmount, setEditAmount] = useState('');
const [editOccurredOn, setEditOccurredOn] = useState('');
const [editBillDueDate, setEditBillDueDate] = useState('');
const [editScope, setEditScope] = useState('THIS');
const updateMutation = useMutation({
mutationFn: ({ id, payload }) => TransactionsApi.update(id, payload),
@@ -337,20 +338,22 @@ export default function CardBills() {
setEditAmount(formatSignedAmount(t.amount));
setEditOccurredOn(t.occurredOn);
setEditBillDueDate(t.billDueDate || (selectedCard ? isoDate(billDueDateFor(selectedCard, t)) : ''));
setEditScope('THIS');
};
const handleEditSubmit = (e, id) => {
const handleEditSubmit = (e, t) => {
e.preventDefault();
const naturalBill = billOptionsFor(selectedCard, editOccurredOn)[0];
const isOverride = editBillDueDate && (!naturalBill || editBillDueDate !== isoDate(naturalBill));
updateMutation.mutate({
id,
id: t.id,
payload: {
categoryId: editCategoryId ? Number(editCategoryId) : null,
description: editDescription,
amount: parseSignedAmount(editAmount),
occurredOn: editOccurredOn,
billDueDate: isOverride ? editBillDueDate : null,
scope: t.seriesId ? editScope : undefined,
},
});
};
@@ -358,7 +361,7 @@ export default function CardBills() {
const [confirmingId, setConfirmingId] = useState(null);
const deleteMutation = useMutation({
mutationFn: TransactionsApi.remove,
mutationFn: ({ id, scope }) => TransactionsApi.remove(id, scope),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['accounts'] });
queryClient.invalidateQueries({ queryKey: ['transactions', selectedCard?.id] });
@@ -626,7 +629,7 @@ export default function CardBills() {
<div className="txn-row" key={t.id}>
{editingId === t.id ? (
<form
onSubmit={(e) => handleEditSubmit(e, t.id)}
onSubmit={(e) => handleEditSubmit(e, t)}
className="d-flex align-items-center gap-2 flex-wrap w-100"
>
<select
@@ -686,6 +689,33 @@ export default function CardBills() {
Cancel
</button>
</div>
{t.seriesId && (
<div className="d-flex gap-3 w-100" style={{ fontSize: 12 }}>
<label className="d-flex align-items-center gap-1">
<input
type="radio"
name={`edit-scope-${t.id}`}
checked={editScope === 'THIS'}
onChange={() => setEditScope('THIS')}
/>
This occurrence only
</label>
<label className="d-flex align-items-center gap-1">
<input
type="radio"
name={`edit-scope-${t.id}`}
checked={editScope === 'FUTURE'}
onChange={() => setEditScope('FUTURE')}
/>
This and future
</label>
</div>
)}
{t.seriesId && editScope === 'FUTURE' && t.seriesRepeat === 'INSTALLMENTS' && (
<div className="w-100 text-faint" style={{ fontSize: 11.5 }}>
Amount is re-split evenly across this and the remaining installments.
</div>
)}
{updateMutation.isError && (
<div className="w-100" style={{ fontSize: 11.5, color: 'var(--red)' }}>
Could not save changes.
@@ -699,13 +729,32 @@ export default function CardBills() {
This cannot be undone.
</span>
<div className="d-flex gap-2 ms-auto">
<button
className="btn btn-red btn-sm"
disabled={deleteMutation.isPending}
onClick={() => deleteMutation.mutate(t.id)}
>
{deleteMutation.isPending ? 'Deleting…' : 'Delete'}
</button>
{t.seriesId ? (
<>
<button
className="btn btn-red btn-sm"
disabled={deleteMutation.isPending}
onClick={() => deleteMutation.mutate({ id: t.id, scope: 'THIS' })}
>
Delete this only
</button>
<button
className="btn btn-red btn-sm"
disabled={deleteMutation.isPending}
onClick={() => deleteMutation.mutate({ id: t.id, scope: 'FUTURE' })}
>
Delete this & future
</button>
</>
) : (
<button
className="btn btn-red btn-sm"
disabled={deleteMutation.isPending}
onClick={() => deleteMutation.mutate({ id: t.id, scope: 'THIS' })}
>
{deleteMutation.isPending ? 'Deleting…' : 'Delete'}
</button>
)}
<button type="button" className="btn btn-ghost btn-sm" onClick={cancelDelete}>
Cancel
</button>
+60 -11
View File
@@ -305,6 +305,7 @@ export default function Transactions() {
const [editDescription, setEditDescription] = useState('');
const [editAmount, setEditAmount] = useState('');
const [editOccurredOn, setEditOccurredOn] = useState('');
const [editScope, setEditScope] = useState('THIS');
const updateMutation = useMutation({
mutationFn: ({ id, payload }) => TransactionsApi.update(id, payload),
@@ -336,17 +337,19 @@ export default function Transactions() {
setEditDescription(t.description);
setEditAmount(formatSignedAmount(t.amount));
setEditOccurredOn(t.occurredOn);
setEditScope('THIS');
};
const handleEditSubmit = (e, id) => {
const handleEditSubmit = (e, t) => {
e.preventDefault();
updateMutation.mutate({
id,
id: t.id,
payload: {
categoryId: editCategoryId ? Number(editCategoryId) : null,
description: editDescription,
amount: parseSignedAmount(editAmount),
occurredOn: editOccurredOn,
scope: t.seriesId ? editScope : undefined,
},
});
};
@@ -354,7 +357,7 @@ export default function Transactions() {
const [confirmingId, setConfirmingId] = useState(null);
const deleteMutation = useMutation({
mutationFn: TransactionsApi.remove,
mutationFn: ({ id, scope }) => TransactionsApi.remove(id, scope),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['accounts'] });
queryClient.invalidateQueries({ queryKey: ['transactions'] });
@@ -873,7 +876,7 @@ export default function Transactions() {
>
{editingId === t.id ? (
<form
onSubmit={(e) => handleEditSubmit(e, t.id)}
onSubmit={(e) => handleEditSubmit(e, t)}
className="d-flex align-items-center gap-2 flex-wrap w-100"
>
<select
@@ -921,6 +924,33 @@ export default function Transactions() {
Cancel
</button>
</div>
{t.seriesId && (
<div className="d-flex gap-3 w-100" style={{ fontSize: 12 }}>
<label className="d-flex align-items-center gap-1">
<input
type="radio"
name={`edit-scope-${t.id}`}
checked={editScope === 'THIS'}
onChange={() => setEditScope('THIS')}
/>
This occurrence only
</label>
<label className="d-flex align-items-center gap-1">
<input
type="radio"
name={`edit-scope-${t.id}`}
checked={editScope === 'FUTURE'}
onChange={() => setEditScope('FUTURE')}
/>
This and future
</label>
</div>
)}
{t.seriesId && editScope === 'FUTURE' && t.seriesRepeat === 'INSTALLMENTS' && (
<div className="w-100 text-faint" style={{ fontSize: 11.5 }}>
Amount is re-split evenly across this and the remaining installments.
</div>
)}
{updateMutation.isError && (
<div className="w-100" style={{ fontSize: 11.5, color: 'var(--red)' }}>
Could not save changes.
@@ -934,13 +964,32 @@ export default function Transactions() {
This cannot be undone.
</span>
<div className="d-flex gap-2 ms-auto">
<button
className="btn btn-red btn-sm"
disabled={deleteMutation.isPending}
onClick={() => deleteMutation.mutate(t.id)}
>
{deleteMutation.isPending ? 'Deleting…' : 'Delete'}
</button>
{t.seriesId ? (
<>
<button
className="btn btn-red btn-sm"
disabled={deleteMutation.isPending}
onClick={() => deleteMutation.mutate({ id: t.id, scope: 'THIS' })}
>
Delete this only
</button>
<button
className="btn btn-red btn-sm"
disabled={deleteMutation.isPending}
onClick={() => deleteMutation.mutate({ id: t.id, scope: 'FUTURE' })}
>
Delete this & future
</button>
</>
) : (
<button
className="btn btn-red btn-sm"
disabled={deleteMutation.isPending}
onClick={() => deleteMutation.mutate({ id: t.id, scope: 'THIS' })}
>
{deleteMutation.isPending ? 'Deleting…' : 'Delete'}
</button>
)}
<button type="button" className="btn btn-ghost btn-sm" onClick={cancelDelete}>
Cancel
</button>