Commit Graph

5 Commits

Author SHA1 Message Date
Víctor Falcón 27919027fe
chore: harden Inertia boundary, CI type-check, and test isolation (#640)
## Summary

Wave 1 hardening: privacy/security quick wins on the Inertia boundary, a
CI safety net, and stricter test isolation. Four focused changes plus
two review fixes, each in its own commit. No dependency or
product-behavior changes.

## Changes (by commit)

1. **Hide sensitive User fields from serialization** — `User::$hidden`
only covered password / 2FA / remember_token, leaving Cashier billing
columns (`stripe_id`, `pm_type`, `pm_last_four`, `trial_ends_at`) and
the legacy `encryption_salt` exposed in every serialized User, including
the Inertia-shared `auth.user` prop. None are read by the frontend and
Cashier keeps reading them server-side, so hiding them is invisible to
the UI and billing.
2. **Advisory frontend type-check in CI + duplicate `currency_code`
fix** — the CI linter job never ran `tsc`, so type errors accumulated
unseen. Adds a `Type Check Frontend` step running `bun run types`. It is
`continue-on-error: true` on purpose: the codebase already carries ~157
pre-existing `tsc --noEmit` errors, so gating on it now would turn CI
red on unrelated code. It surfaces type output today and should be
flipped to blocking once the backlog is cleared. Also removes a
duplicate `currency_code` member on the `User` TS interface (declared
twice, `CurrencyCode` and `string | null`); the TS language server flags
it, `tsc` masks it under `skipLibCheck`.
3. **Move residual-encryption cleanup out of Inertia `share()` into a
queued job** — `share()` is a shared-data provider and must be
read-only, but it ran a `DELETE`+`UPDATE` against the user on every
non-API web GET to purge the leftover encryption salt /
`EncryptedMessage` once a user had no encrypted data left. The existing
`encryption:*` commands do not cover this case (both target users who
*still* have encrypted data; this finalizes users who *finished*
decrypting). The work now goes to a new idempotent
`PurgeResidualEncryptionArtifactsJob` dispatched from `share()`,
preserving the eventual-cleanup semantics without writing during the
render.
4. **Block stray HTTP in the Feature test suite** — adds
`Http::preventStrayRequests()` in a Feature-scoped `beforeEach` so any
unfaked outbound request fails loudly instead of hitting the network. A
representative HTTP-touching subset (open banking,
exchange-rate/currency, AI categorization + AI/stats reports, analytics,
Discord, Stripe, bank logos) was run with the guard active; no test
relied on a real request, so no fakes had to be added.

### Review fixes (after the two-reviewer pass)

5. **Purge check uses `name_iv` source of truth, not the stale
`encrypted` flag** — the destructive purge decided "no encrypted
accounts" from `accounts.encrypted`, a flag the codebase already treats
as unreliable (see the
`align_accounts_encrypted_flag_with_plaintext_names` migration and
`FindsUsersWithLegacyEncryption`, which key off `name_iv`). An account
with an encrypted name but a stale `encrypted=false` flag could have its
key material destroyed. The job's guard now matches the canonical `*_iv`
predicate exactly; the loose flag gate in `share()` is kept only as a
cheap dispatch filter, and the job re-verifies with the safe predicate
before touching anything.
6. **Deduplicate purge dispatches with `ShouldBeUnique`** — `share()`
runs on every web GET, so an affected user re-enqueued the job on each
page load until a worker cleared the salt. The job is now
`ShouldBeUnique` keyed by user id, collapsing repeat dispatches into one
pending job.

## Test plan

- New/updated tests, all green:
- `InertiaSharedDataTest`: `auth.user` omits all sensitive fields; a web
GET no longer mutates the user inline and instead queues the cleanup
(and does not queue it when there is no salt).
- `PurgeResidualEncryptionArtifactsJobTest`: clears salt + message when
no `*_iv` data remains; keeps them when an encrypted transaction, an
encrypted account name, or a stale-flag-but-encrypted-name account
exists; no-op when salt already null.
- `StrayHttpRequestGuardTest`: an unfaked request throws
`StrayRequestException`; a matched fake still resolves.
- Green locally: `vendor/bin/pint --test`, `bun run lint` (0 errors),
`bun run format:check`, `bun run test` (254 frontend tests), targeted
backend
`--filter=InertiaSharedData|PurgeResidualEncryptionArtifactsJob|StrayHttpRequestGuard|Encryption`
(24 tests). Ran ~700 HTTP-touching Feature tests with the stray-request
guard active with no guard-induced failures.
- `bun run types` still reports the ~157 pre-existing errors (unchanged
set; this PR adds none) — that is exactly why the CI step is advisory
for now.

## Reviewer findings — addressed vs deferred

**Addressed**
- 🟠 Destructive purge keyed off the stale `accounts.encrypted` flag
instead of the `name_iv` source of truth → fixed in commit 5 (job now
mirrors `FindsUsersWithLegacyEncryption`; added a regression test for
the stale-flag case).
- 🟡 Per-request dispatch amplification with no dedup → fixed in commit 6
via `ShouldBeUnique`.

**Deferred (with rationale)**
- 🟠 "Three divergent copies of the legacy-encryption query" —
substantively resolved: the job now matches
`FindsUsersWithLegacyEncryption` exactly. The only remaining
`encrypted`-flag use is the `hasEncryptedAccounts` **UI prop** in
`share()`, which is a separate, pre-existing frontend concern; changing
it would alter which accounts the UI treats as encrypted and is out of
scope here. A full extraction into one shared scope would require
restructuring the trait (it builds a `User` query, not a per-model
boolean) and is not a Wave 1 quick win.
- 🟢 Redundant `->fresh()` / null guard in the job — kept deliberately:
it is the idempotency guard that makes the re-check read committed state
on the sync path (the second reviewer credited it as what makes repeat
dispatches safe).
- 🟢 `continue-on-error` shows the type-check step green — acknowledged;
flipping to blocking (or failing on an increase over a committed
baseline) is the follow-up once the ~157-error backlog is cleared.
- 🟢 (Product review) Theoretical one-request SSR/client
`hasEncryptionSetup` diff from async salt clearing — invisible in
practice (the lock button gates on `hasEncryptedAccounts ||
hasEncryptedTransactions`, false in both SSR and client), and `ssr.tsx`
is untouched. No action.

Product-bug reviewer verdict: no user-facing regressions. Hiding the 5
fields does not affect Cashier (raw attribute access),
`EncryptionController`, notifications, or any API/JSON path; the
`currency_code` dedup is runtime-identical; the cleanup timing change is
client-absorbed.
2026-07-04 18:57:58 +00:00
Víctor Falcón f9bf0ea5ff
feat(discord): enrich Stripe event messages and dedupe deliveries (#460)
## Why

The Stripe subscription events posted to the Discord admin feed weren't
useful (only status + raw `cus_123` id) and sometimes arrived duplicated
— including two cancellation messages for the same subscription.

## What changed

**Richer messages** (`PostStripeEventToDiscord`)
- Customer name + email (resolved via the Stripe API), plan + interval
(`€19.99 / month`), status, subscription id.
- A `Changed` field built from Stripe's `previous_attributes` diff (e.g.
`Status: trialing → active`).
- Cancellation reason, trial-end and period-end dates where relevant.
- Invoices now show invoice number + subscription id.

**Deduplication (two causes fixed)**
- *Webhook/queue retries*: guard on the Stripe event id via
`Cache::add(..., 24h)` — first delivery wins, retries skipped.
- *Double cancellation*: Stripe fires `subscription.updated` (cancel
scheduled) **then** `subscription.deleted`. We now label the scheduled
one `🗓️ Cancellation scheduled` distinctly from `👋 Subscription
cancelled`, and only post `subscription.updated` when something
meaningful changed (status, plan, or cancellation), dropping trivial
churn.

**New** `StripeCustomerResolver` service — resolves `Name (email)` from
a customer id, falls back to the id on any error. Injectable so it's
stubbed in tests (no network).

## Tests

9 cases covering rich fields, dedup, scheduled-vs-deleted cancellation,
status-change diff, trivial-update skip, deletion reason, and invoice
amounts. All passing.

Note: this adds one Stripe API call per subscription/invoice event, made
inside the queued listener (no webhook latency).
2026-05-31 12:50:37 +02:00
Víctor Falcón a7c1bd35ef
feat: link loans to existing properties (#275)
## Summary
- add an optional linked property selector when creating loan accounts
in both the main create dialog and onboarding
- validate that only the current user's unlinked real-estate accounts
can be selected and persist the reciprocal link after loan creation
- expose linked property state in shared account props and cover the new
flow with focused loan feature tests

## Testing
- php artisan test --compact tests/Feature/LoanTest.php
- vendor/bin/pint --dirty --format agent
2026-04-13 09:51:13 +01:00
Víctor Falcón 7e958284e3
fix(loans): project monthly balances from actual entries instead of original params (#259)
## Summary

- **Bug**: The `loans:generate-balances` command called
`getBalanceAtDate()` which always recalculated the remaining balance
from the **original loan parameters** (`original_amount`,
`annual_interest_rate`, `loan_term_months`, `start_date`), completely
ignoring existing actual balance entries. When the real balance was
lower than the theoretical amortization schedule (e.g. user made extra
payments), the auto-generated balance would **jump up** instead of
continuing to decrease.
- **Example**: Account `019d0b33-d3c5-7110-9033-51155ac93219` had a real
balance of ~4,489,670 (March 2026), but the command generated 5,700,356
(April 2026) — a jump **up** of ~1.2M cents instead of the expected ~16K
decrease.
- **Fix**: `getBalanceAtDate()` now checks for the latest
`AccountBalance` entry on or before the target date and projects forward
from it, falling back to the theoretical formula only when no entries
exist. This matches the method's own documented behavior and aligns with
how `generateProjection()` already works.

## Testing

Added 4 new tests:
- Projects from existing balance entries instead of original loan params
- Returns existing balance when target date is in the same month
- Generates correct monthly balance when existing entries differ from
theoretical
- All 44 existing loan tests continue to pass
2026-04-04 15:57:29 +01:00
Víctor Falcón bb65bdc16e
feat(accounts): add loan amortization projections for loan accounts (#246)
## Summary

- Adds loan detail tracking (interest rate, term, start date, original
amount) to loan-type accounts, following the existing
`real_estate_details` pattern
- Implements `LoanAmortizationService` with standard amortization math
to automatically project month-to-month balance evolution
- Shows projected future balances as a dashed line on the account
balance chart, visually distinct from historical data
- Adds a scheduled command (`loans:generate-balances`) to auto-generate
monthly balance entries for loan accounts
- Includes 36 tests (10 unit for pure math, 26 feature for CRUD, API
projections, command, and authorization)

## Changes

### Backend
- **Migration**: `loan_details` table (UUID PK, unique `account_id` FK,
interest rate, term, start date, original amount)
- **LoanDetail model** + factory with `Account::loanDetail()` HasOne
relationship
- **LoanAmortizationService**: `calculateMonthlyPayment`,
`calculateRemainingBalance`, `generateProjection`, `projectFromBalance`,
`calculateRemainingMonths`, `getBalanceAtDate`
- **GenerateMonthlyLoanBalances** command: runs monthly on 1st, skips
existing entries
- **LoanDetailController**: PATCH endpoint for editing loan details from
show page
- **Settings\AccountController**: creates/updates `LoanDetail` on
store/update
- **DashboardAnalyticsController**: appends projected data points with
`projected: true` flag
- **Validation**: loan-specific rules in StoreAccountRequest and
UpdateAccountRequest

### Frontend
- **AccountForm**: loan fields (rate, term, start date, original amount)
shown when `type === 'loan'`
- **Create/Edit dialogs**: pass loan data fields in POST/PATCH payloads
- **Show page**: `LoanDetailsCard` component with edit/read modes,
computed monthly payment and remaining months
- **Balance chart**: new `ComposedChart` branch with dashed `Line`
overlay for projected data points
- **Types**: `LoanDetail` interface in `account.ts`
2026-03-26 15:06:09 +01:00