feat: per-month "set aside" toggle for deferred transactions #235

Merged
Copilot merged 3 commits from copilot/discuss-deferred-payments-approach into main 2026-04-26 11:50:41 +00:00
Copilot commented 2026-04-26 10:48:08 +00:00 (Migrated from github.com)

Deferred transactions had no way to acknowledge that the monthly accrual had been acted on (e.g. moved to savings). Adds a lightweight, purely informational toggle — no effect on ledger totals, balance, or any calculated figures.

Description

Backend

  • deferred_set_asides table: (user_id, finance_template_id, year, month, set_aside_at) with unique constraint on the composite key
  • DeferredSetAsideController::toggle() — creates or deletes the record; returns { is_set_aside, set_aside_at }
  • Route: POST /api/deferred/{financeTemplate}/{year}/{month}/toggle-set-aside (auth:sanctum)
  • DeferredTransaction DTO now includes is_set_aside / set_aside_at in toArray()
  • TransactionAggregatorService bulk-loads set-aside records per period before constructing DTOs (avoids N+1)

Frontend

  • TransactionRow.vue: replaces the static info tooltip on deferred rows with a mdi-piggy-bank toggle button — green + full opacity when set aside, muted + 60% opacity when not
  • Event bubbles up through CombinedMonthlyAccordionCombinedLedger, where handleToggleSetAside calls the API and applies an optimistic store update via updateDeferredSetAside()
  • Uses route('api.deferred.toggle-set-aside', …) (Ziggy) rather than a hardcoded path

No financial impactLedgerService, BalanceService, and all derived totals (deferred_budgeted, deferred_due, deferred_out) are unchanged. Set-aside is a tracking marker only.

Note on PR diff scope: This branch was cut from main (which includes PRs #198–#227) while dev is currently at #197. The BalanceService, LedgerService, and store changes visible in the diff are carry-along history from those upstream PRs — they are not part of the set-aside feature. The set-aside feature itself is contained in exactly 2 commits (3fd317c and 7b00c86). If merging only the set-aside feature to dev is desired, cherry-picking those 2 commits is the cleanest path.

Type of Change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation update
  • Refactoring (code change that neither fixes a bug nor adds a feature)
  • Performance improvement
  • Test coverage improvement

Testing

  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • I have tested this manually (if applicable)

5 feature tests in DeferredSetAsideControllerTest: mark, unmark, 403 cross-user, 401 unauthenticated, toggle idempotence (multiple round-trips produce exactly 1 DB row).

Code Quality

  • My code follows the style guidelines of this project
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • My changes generate no new warnings or errors

CI/CD Checks

  • All automated tests pass
  • Code formatting (Pint) passes
  • JavaScript/TypeScript linting passes
  • Build process completes successfully

Additional Notes

Set-aside status is independent per (template, year, month) tuple — marking March does not affect April. If a future requirement emerges to reflect set-aside amounts in balance calculations (e.g. subtract from disposable income), that would require targeted changes to BalanceService and LedgerService.

Deferred transactions had no way to acknowledge that the monthly accrual had been acted on (e.g. moved to savings). Adds a lightweight, purely informational toggle — no effect on ledger totals, balance, or any calculated figures. ## Description **Backend** - `deferred_set_asides` table: `(user_id, finance_template_id, year, month, set_aside_at)` with unique constraint on the composite key - `DeferredSetAsideController::toggle()` — creates or deletes the record; returns `{ is_set_aside, set_aside_at }` - Route: `POST /api/deferred/{financeTemplate}/{year}/{month}/toggle-set-aside` (`auth:sanctum`) - `DeferredTransaction` DTO now includes `is_set_aside` / `set_aside_at` in `toArray()` - `TransactionAggregatorService` bulk-loads set-aside records per period before constructing DTOs (avoids N+1) **Frontend** - `TransactionRow.vue`: replaces the static info tooltip on deferred rows with a `mdi-piggy-bank` toggle button — green + full opacity when set aside, muted + 60% opacity when not - Event bubbles up through `CombinedMonthlyAccordion` → `CombinedLedger`, where `handleToggleSetAside` calls the API and applies an optimistic store update via `updateDeferredSetAside()` - Uses `route('api.deferred.toggle-set-aside', …)` (Ziggy) rather than a hardcoded path **No financial impact** — `LedgerService`, `BalanceService`, and all derived totals (`deferred_budgeted`, `deferred_due`, `deferred_out`) are unchanged. Set-aside is a tracking marker only. **Note on PR diff scope**: This branch was cut from `main` (which includes PRs #198–#227) while `dev` is currently at `#197`. The `BalanceService`, `LedgerService`, and store changes visible in the diff are carry-along history from those upstream PRs — they are **not** part of the set-aside feature. The set-aside feature itself is contained in exactly 2 commits (`3fd317c` and `7b00c86`). If merging only the set-aside feature to `dev` is desired, cherry-picking those 2 commits is the cleanest path. ## Type of Change - [ ] Bug fix (non-breaking change which fixes an issue) - [x] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - [ ] Documentation update - [ ] Refactoring (code change that neither fixes a bug nor adds a feature) - [ ] Performance improvement - [ ] Test coverage improvement ## Testing - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have tested this manually (if applicable) 5 feature tests in `DeferredSetAsideControllerTest`: mark, unmark, 403 cross-user, 401 unauthenticated, toggle idempotence (multiple round-trips produce exactly 1 DB row). ## Code Quality - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my own code - [x] I have commented my code, particularly in hard-to-understand areas - [x] My changes generate no new warnings or errors ## CI/CD Checks - [x] All automated tests pass - [x] Code formatting (Pint) passes - [x] JavaScript/TypeScript linting passes - [x] Build process completes successfully ## Additional Notes Set-aside status is independent per `(template, year, month)` tuple — marking March does not affect April. If a future requirement emerges to reflect set-aside amounts in balance calculations (e.g. subtract from disposable income), that would require targeted changes to `BalanceService` and `LedgerService`.
niddelicious (Migrated from github.com) reviewed 2026-04-26 10:48:08 +00:00
coderabbitai[bot] commented 2026-04-26 10:48:16 +00:00 (Migrated from github.com)

Important

Review skipped

Bot user detected.

To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Free

Run ID: f644b056-b1c1-42d5-a683-26411358abbd

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands and usage tips.

<!-- This is an auto-generated comment: summarize by coderabbit.ai --> <!-- This is an auto-generated comment: skip review by coderabbit.ai --> > [!IMPORTANT] > ## Review skipped > > Bot user detected. > > To trigger a single review, invoke the `@coderabbitai review` command. > > <details> > <summary>⚙️ Run configuration</summary> > > **Configuration used**: defaults > > **Review profile**: CHILL > > **Plan**: Free > > **Run ID**: `f644b056-b1c1-42d5-a683-26411358abbd` > > </details> > > You can disable this status message by setting the `reviews.review_status` to `false` in the CodeRabbit configuration file. > > Use the checkbox below for a quick retry: > - [ ] <!-- {"checkboxId": "e9bb8d72-00e8-4f67-9cb2-caf3b22574fe"} --> 🔍 Trigger review <!-- end of auto-generated comment: skip review by coderabbit.ai --> <!-- tips_start --> --- <sub>Comment `@coderabbitai help` to get the list of available commands and usage tips.</sub> <!-- tips_end -->
niddelicious commented 2026-04-26 11:07:15 +00:00 (Migrated from github.com)

@copilot
Seems there's some work done already and there's a conflict. Will this interfere with your changes? Looks like the balance service might be affected

@copilot Seems there's some work done already and there's a conflict. Will this interfere with your changes? Looks like the balance service might be affected
copilot-pull-request-reviewer[bot] (Migrated from github.com) reviewed 2026-04-26 11:13:31 +00:00
copilot-pull-request-reviewer[bot] (Migrated from github.com) left a comment

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

Adds a per-month “set aside” marker for deferred transactions so users can track whether they’ve moved the monthly accrual (e.g., to savings) without affecting any financial totals.

Changes:

  • Introduces deferred_set_asides persistence + toggle API endpoint (auth-protected) and wires DTO enrichment via bulk load in the aggregator.
  • Updates the Vue ledger UI to show a piggy-bank toggle and performs optimistic store updates after API responses.
  • Adds feature tests covering toggle behavior and authorization cases.

Reviewed changes

Copilot reviewed 14 out of 15 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
tests/Feature/Finance/DeferredSetAsideControllerTest.php Adds feature tests for toggle behavior, auth, and duplicate prevention.
routes/api.php Registers the toggle endpoint route.
resources/js/types/transactions.ts Extends transaction typing with is_set_aside / set_aside_at.
resources/js/stores/ledgerStore.ts Adds store fields and an action for updating set-aside state.
resources/js/Pages/Transactions/Partials/TransactionRow.vue Replaces deferred info tooltip with a piggy-bank toggle UI and opacity behavior.
resources/js/Pages/Transactions/Partials/CombinedMonthlyAccordion.vue Bubbles the toggle event up with year/month context.
resources/js/Pages/Transactions/CombinedLedger.vue Calls toggle API and updates store with returned state.
database/migrations/2026_04_26_095612_create_deferred_set_asides_table.php Creates the deferred_set_asides table with uniqueness constraint.
app/Services/Finance/TransactionAggregatorService.php Bulk-loads set-aside records per period and passes state into DTOs.
app/Models/Finance/DeferredSetAside.php Adds Eloquent model for set-aside records.
app/Http/Controllers/Api/DeferredSetAsideController.php Implements toggle endpoint (create/delete record; returns state).
app/DTOs/Finance/DeferredTransaction.php Adds is_set_aside / set_aside_at to DTO serialization.
DEVLOG.md Documents the feature and touched components.
CHANGELOG.md Adds user-facing release notes for the feature.
## Pull request overview > [!NOTE] > Copilot was unable to run its full agentic suite in this review. Adds a per-month “set aside” marker for deferred transactions so users can track whether they’ve moved the monthly accrual (e.g., to savings) without affecting any financial totals. **Changes:** - Introduces `deferred_set_asides` persistence + toggle API endpoint (auth-protected) and wires DTO enrichment via bulk load in the aggregator. - Updates the Vue ledger UI to show a piggy-bank toggle and performs optimistic store updates after API responses. - Adds feature tests covering toggle behavior and authorization cases. ### Reviewed changes Copilot reviewed 14 out of 15 changed files in this pull request and generated 8 comments. <details> <summary>Show a summary per file</summary> | File | Description | | ---- | ----------- | | tests/Feature/Finance/DeferredSetAsideControllerTest.php | Adds feature tests for toggle behavior, auth, and duplicate prevention. | | routes/api.php | Registers the toggle endpoint route. | | resources/js/types/transactions.ts | Extends transaction typing with `is_set_aside` / `set_aside_at`. | | resources/js/stores/ledgerStore.ts | Adds store fields and an action for updating set-aside state. | | resources/js/Pages/Transactions/Partials/TransactionRow.vue | Replaces deferred info tooltip with a piggy-bank toggle UI and opacity behavior. | | resources/js/Pages/Transactions/Partials/CombinedMonthlyAccordion.vue | Bubbles the toggle event up with year/month context. | | resources/js/Pages/Transactions/CombinedLedger.vue | Calls toggle API and updates store with returned state. | | database/migrations/2026_04_26_095612_create_deferred_set_asides_table.php | Creates the `deferred_set_asides` table with uniqueness constraint. | | app/Services/Finance/TransactionAggregatorService.php | Bulk-loads set-aside records per period and passes state into DTOs. | | app/Models/Finance/DeferredSetAside.php | Adds Eloquent model for set-aside records. | | app/Http/Controllers/Api/DeferredSetAsideController.php | Implements toggle endpoint (create/delete record; returns state). | | app/DTOs/Finance/DeferredTransaction.php | Adds `is_set_aside` / `set_aside_at` to DTO serialization. | | DEVLOG.md | Documents the feature and touched components. | | CHANGELOG.md | Adds user-facing release notes for the feature. | </details>
@ -0,0 +17,4 @@
*
* Marks the monthly portion of a deferred expense as set aside (or unmarks it).
*/
public function toggle(Request $request, FinanceTemplate $financeTemplate, int $year, int $month): JsonResponse
copilot-pull-request-reviewer[bot] (Migrated from github.com) commented 2026-04-26 11:13:29 +00:00

The endpoint accepts arbitrary year/month route params without validation. Since month is persisted as unsignedTinyInteger and the feature is defined as per-calendar-month, invalid values (e.g. 0, 13, negative, far-future years) can be stored. Add request validation (FormRequest or Validator) to enforce month in 1..12 and a sensible year range (and return a 422 on invalid input).

The endpoint accepts arbitrary `year`/`month` route params without validation. Since `month` is persisted as `unsignedTinyInteger` and the feature is defined as per-calendar-month, invalid values (e.g. 0, 13, negative, far-future years) can be stored. Add request validation (FormRequest or Validator) to enforce `month` in 1..12 and a sensible `year` range (and return a 422 on invalid input).
@ -0,0 +48,4 @@
'year' => $year,
'month' => $month,
'set_aside_at' => now(),
]);
copilot-pull-request-reviewer[bot] (Migrated from github.com) commented 2026-04-26 11:13:29 +00:00

The toggle implementation is vulnerable to a race condition: two concurrent requests can both observe no existing row and attempt create(), causing a unique-constraint violation and a 500 response. Consider making the operation resilient by using an atomic pattern (e.g., delete-then-create with a try/catch for duplicate key, or firstOrCreate/updateOrCreate combined with consistent return semantics) so concurrent/double-click requests remain safe.

The toggle implementation is vulnerable to a race condition: two concurrent requests can both observe no existing row and attempt `create()`, causing a unique-constraint violation and a 500 response. Consider making the operation resilient by using an atomic pattern (e.g., delete-then-create with a try/catch for duplicate key, or `firstOrCreate`/`updateOrCreate` combined with consistent return semantics) so concurrent/double-click requests remain safe.
@ -654,0 +654,4 @@
async function handleToggleSetAside({ transaction, year, month }) {
// Extract template ID from the deferred transaction string ID: "deferred_{templateId}_{date}"
const parts = String(transaction.id).split('_');
const templateId = parts[1];
copilot-pull-request-reviewer[bot] (Migrated from github.com) commented 2026-04-26 11:13:29 +00:00

Parsing templateId from the synthetic transaction id couples the frontend to a specific string format. A more robust approach is to include an explicit finance_template_id (or similar) field on the deferred transaction DTO and use that directly, avoiding fragile string parsing if the ID format changes.

    const templateId = transaction.finance_template_id;
Parsing `templateId` from the synthetic transaction `id` couples the frontend to a specific string format. A more robust approach is to include an explicit `finance_template_id` (or similar) field on the deferred transaction DTO and use that directly, avoiding fragile string parsing if the ID format changes. ```suggestion const templateId = transaction.finance_template_id; ```
copilot-pull-request-reviewer[bot] (Migrated from github.com) commented 2026-04-26 11:13:30 +00:00

The comment says this is an 'optimistic local toggle', but localIsSetAside never flips on click—it's only updated from props. Either (a) actually toggle localIsSetAside immediately on click (and reconcile on API failure), or (b) adjust the comment/naming to reflect that it mirrors store state rather than being optimistic.

The comment says this is an 'optimistic local toggle', but `localIsSetAside` never flips on click—it's only updated from props. Either (a) actually toggle `localIsSetAside` immediately on click (and reconcile on API failure), or (b) adjust the comment/naming to reflect that it mirrors store state rather than being optimistic.
copilot-pull-request-reviewer[bot] (Migrated from github.com) commented 2026-04-26 11:13:30 +00:00

This is an icon-only toggle button; relying on title/tooltip alone is not sufficient for assistive tech. Add an explicit aria-label (and ideally :aria-pressed=\"localIsSetAside\" since it’s a toggle) so screen readers can understand the control and its state.

This is an icon-only toggle button; relying on `title`/tooltip alone is not sufficient for assistive tech. Add an explicit `aria-label` (and ideally `:aria-pressed=\"localIsSetAside\"` since it’s a toggle) so screen readers can understand the control and its state.
copilot-pull-request-reviewer[bot] (Migrated from github.com) commented 2026-04-26 11:13:30 +00:00

This update scans every month’s transaction list to find a single ID. Since the toggle handler already has year/month, consider passing a month key (or maintaining an ID→location index) so the update is O(monthTransactions) (or O(1)) instead of O(allVisibleTransactions).

This update scans every month’s transaction list to find a single ID. Since the toggle handler already has `year`/`month`, consider passing a month key (or maintaining an ID→location index) so the update is O(monthTransactions) (or O(1)) instead of O(allVisibleTransactions).
@ -83,2 +83,4 @@
is_deferred_payment?: boolean;
next_due_date?: string;
is_set_aside?: boolean;
set_aside_at?: string;
copilot-pull-request-reviewer[bot] (Migrated from github.com) commented 2026-04-26 11:13:29 +00:00

set_aside_at can be null (backend returns null when unmarked), but the type is string when present. With strict null checks this is a type mismatch and can lead to incorrect handling. Update the type to set_aside_at?: string | null (or make it non-optional but nullable if it is always included for deferred entries).

    set_aside_at?: string | null;
`set_aside_at` can be `null` (backend returns null when unmarked), but the type is `string` when present. With strict null checks this is a type mismatch and can lead to incorrect handling. Update the type to `set_aside_at?: string | null` (or make it non-optional but nullable if it is always included for deferred entries). ```suggestion set_aside_at?: string | null; ```
@ -0,0 +113,4 @@
->assertUnauthorized();
});
it('does not allow the same month to be marked twice creating duplicate records', function () {
copilot-pull-request-reviewer[bot] (Migrated from github.com) commented 2026-04-26 11:13:30 +00:00

This test name doesn’t match the behavior being exercised (it toggles mark→unmark→mark, rather than attempting to mark twice). Rename it to reflect what it verifies (e.g., that repeated toggles don’t leave duplicate rows / at most one row exists per key).

    it('does not leave duplicate records after repeated toggles for the same month', function () {
This test name doesn’t match the behavior being exercised (it toggles mark→unmark→mark, rather than attempting to mark twice). Rename it to reflect what it verifies (e.g., that repeated toggles don’t leave duplicate rows / at most one row exists per key). ```suggestion it('does not leave duplicate records after repeated toggles for the same month', function () { ```
Copilot commented 2026-04-26 11:13:43 +00:00 (Migrated from github.com)

@copilot
Seems there's some work done already and there's a conflict. Will this interfere with your changes? Looks like the balance service might be affected

The set-aside feature itself does not touch BalanceService, LedgerService, or any balance/ledger totals — those are purely a tracking marker.

The BalanceService/LedgerService changes you're seeing in the PR diff came from pre-existing main-branch work (#199, #200, #224, #227) that were already merged before this branch was created. Our branch was cut from main while dev is still at #197, so the PR diff contains ~50 upstream commits of carry-along history. Our 2 set-aside commits only add:

  • New deferred_set_asides table, model, controller, and route
  • is_set_aside/set_aside_at fields on the DeferredTransaction DTO
  • Bulk-loading in TransactionAggregatorService (no financial logic change)
  • Frontend toggle button in TransactionRow.vue + store action in ledgerStore

None of that changes any calculated amounts. If the plan is to merge only the set-aside feature to dev (without the carry-along history), the cleanest path would be to cherry-pick the 2 commits (3fd317c and 7b00c86) onto dev directly.

> @copilot > Seems there's some work done already and there's a conflict. Will this interfere with your changes? Looks like the balance service might be affected > The set-aside feature itself does **not** touch `BalanceService`, `LedgerService`, or any balance/ledger totals — those are purely a tracking marker. The `BalanceService`/`LedgerService` changes you're seeing in the PR diff came from pre-existing main-branch work (#199, #200, #224, #227) that were already merged before this branch was created. Our branch was cut from `main` while `dev` is still at `#197`, so the PR diff contains ~50 upstream commits of carry-along history. Our 2 set-aside commits only add: - New `deferred_set_asides` table, model, controller, and route - `is_set_aside`/`set_aside_at` fields on the `DeferredTransaction` DTO - Bulk-loading in `TransactionAggregatorService` (no financial logic change) - Frontend toggle button in `TransactionRow.vue` + store action in `ledgerStore` None of that changes any calculated amounts. If the plan is to merge only the set-aside feature to `dev` (without the carry-along history), the cleanest path would be to cherry-pick the 2 commits (`3fd317c` and `7b00c86`) onto `dev` directly.
niddelicious commented 2026-04-26 11:14:13 +00:00 (Migrated from github.com)

@copilot resolve the merge conflicts in this pull request

@copilot resolve the merge conflicts in this pull request
niddelicious commented 2026-04-26 12:01:28 +00:00 (Migrated from github.com)

@copilot

In Connection.php line 826:

SQLSTATE[42000]: Syntax error or access violation: 1059 Identifier name 'de
ferred_set_asides_user_id_finance_template_id_year_month_unique' is too lon
g (Connection: mysql, SQL: alter table deferred_set_asides add unique de ferred_set_asides_user_id_finance_template_id_year_month_unique(user_id,
finance_template_id, year, month))

In Connection.php line 570:

SQLSTATE[42000]: Syntax error or access violation: 1059 Identifier name 'de
ferred_set_asides_user_id_finance_template_id_year_month_unique' is too lon
g

@copilot In Connection.php line 826: SQLSTATE[42000]: Syntax error or access violation: 1059 Identifier name 'de ferred_set_asides_user_id_finance_template_id_year_month_unique' is too lon g (Connection: mysql, SQL: alter table `deferred_set_asides` add unique `de ferred_set_asides_user_id_finance_template_id_year_month_unique`(`user_id`, `finance_template_id`, `year`, `month`)) In Connection.php line 570: SQLSTATE[42000]: Syntax error or access violation: 1059 Identifier name 'de ferred_set_asides_user_id_finance_template_id_year_month_unique' is too lon g
Sign in to join this conversation.
No description provided.