> **Stacked on #696.** That PR introduced the AI-categorization upgrade
dialog this one generalizes. It targets `main` so CI runs, so until #696
merges this PR's diff also contains #696's commit — review/merge #696
first, then this diff resolves to just its own three commits.
## What & why
The contextual "this is a paid feature → pick a plan → checkout" modal
built for AI categorization is now a **shared component** reused at two
more Pro-feature entry points, and every checkout it starts is
**attributed to the upsell point** so we can measure revenue per point.
### 1. Reusable upgrade modal
Extracted `AiUpgradeDialog` (+ `PlanCard`) out of `settings/billing.tsx`
into a shared `UpgradeDialog`
(`resources/js/components/subscription/upgrade-dialog.tsx`). It reads
`pricing`/`locale` from `usePage`, takes `title` / `description` /
`source`, renders the plan picker, and links to Stripe checkout. Used
at:
| Point | Trigger | Copy |
|---|---|---|
| AI categorization | Manage Plan toggle (unchanged) | "AI
categorization is a paid feature" |
| **Connections** | "Connect Bank" | "Bank connections are a paid
feature" |
| **Connected accounts** | Create Account → "Connected" | "Connected
accounts are a paid feature" |
Both new points already gated on `isFreePlan`, so the modal only shows
to free users. The old plain `UpgradeConnectionDialog` (which just
routed to billing) is deleted.
### 2. Revenue attribution
The upsell `source` is captured two ways:
- **Intent** — a PostHog `upgrade_checkout_started` event (`{ source,
plan }`) fires on click, matching the repo's existing `{ source }` event
convention.
- **Revenue** — `source` rides the checkout URL (`?plan=&source=`), and
`SubscriptionController::checkout` validates it against the new
`App\Enums\UpsellSource` enum and attaches it as Stripe **subscription
metadata** (`withMetadata`). When the subscription webhook lands,
`PersistUpsellSourceFromStripe` (on Cashier's `WebhookHandled`, so the
row already exists) copies it onto a new `subscriptions.upsell_source`
column — **write-once** (`whereNull`), so a later `subscription.updated`
never overwrites the original attribution.
Measuring revenue per point is then a group-by on
`subscriptions.upsell_source` joined to invoices, using existing local
tooling.
### Scoping notes (from review)
- **Paywall & the billing on-page upgrade button are intentionally left
untagged** (`upsell_source = NULL`). "Upsell source" means a
*feature-gate nudge*; the paywall/billing pages are the baseline upgrade
surface, not a nudge — so they form the null/baseline bucket rather than
getting their own enum value.
- The listener is **synchronous** (not `ShouldQueue` like the sibling
Discord listener) on purpose: a single indexed, idempotent `UPDATE`
doesn't warrant a queue-worker dependency for attribution.
- The PostHog event fires just before a full-page navigation; posthog-js
flushes via beacon but the event (analytics only, not the revenue source
of truth) could occasionally be lost. Cheap to harden later if the
funnel proves lossy.
## Tests
- `upgrade-dialog.test.tsx` — renders per-feature copy, checkout link
carries `plan` + `source`, click fires the PostHog event.
- `SubscriptionTest` — checkout tags valid sources as Stripe metadata
and ignores unknown ones.
- `PersistUpsellSourceFromStripeTest` — persists from webhook metadata,
doesn't overwrite an existing attribution, ignores unknown/absent
sources.
## Demo
Free user hitting both new upsell points (Connections → "Connect Bank",
Accounts → "Connected"):
<!-- 📎 PLACEHOLDER: drag in
~/Downloads/upsell-points-connections-accounts.mp4 -->
https://github.com/user-attachments/assets/a27435d9-2296-4dc9-ae7f-753b6f7950f0
> Note: the clip ends at the modal (doesn't cross into Stripe) because
this local dev env has a pre-existing Stripe tax-rate 500 on
`/subscribe/checkout`, unrelated to this change. The `source` reaching
the checkout URL is verified in the browser and by the tests.
## Problem
On **Settings › Manage Plan**, a free (non-subscribed) user who ticked
**"Allow AI categorization"** silently recorded AI consent (`POST
/ai/consent`). That flipped `hasActiveAiConsent()` to `true`, which
caused two things on the **next navigation / refresh**:
1. `EnsureUserIsSubscribed` hard-redirected them to `/subscribe`, on
every gated page.
2. The paywall's `canUseFreePlan` (`!hasBankConnections &&
!hasActiveAiConsent`) became `false`, so the **"Continue for free"**
escape hatch disappeared.
Net effect: enabling AI locked a free user out of the app behind the
paywall, with no explanation and no way back.
## Fix
For a **free user**, ticking the box no longer records consent. Instead
it opens a contextual dialog that explains AI is a paid feature and
offers **Monthly / Annual** plans that start Stripe checkout. Because no
consent is recorded:
- Nothing locks them out — dismissing the dialog ("Maybe later") leaves
the app fully usable.
- **Pro users** keep the existing direct-consent behavior.
- A free user who somehow already has consent can still **untick to
revoke** and escape the lock.
The now-inaccurate "you can give consent now…" note was removed (a free
user can no longer give consent from here).
## Tests
- New `resources/js/pages/settings/billing.test.tsx` covers three paths:
free user → modal opens & **no consent POSTed**; pro user → consent
recorded directly; free user with existing consent → untick **revokes**.
## Demo
**Before** — enabling AI silently locks the free user behind the paywall
(no "Continue for free"):
<!-- 📎 PLACEHOLDER: drag in ~/Downloads/free-user-ai-paywall.mp4 -->
https://github.com/user-attachments/assets/a72790a4-2b2d-4af1-9f8f-4a16f821eaef
**After** — enabling AI shows a contextual subscribe modal; dismissing
it keeps the app working (no lock):
<!-- 📎 PLACEHOLDER: drag in ~/Downloads/free-user-ai-new-behavior.mp4
-->
https://github.com/user-attachments/assets/4a1c76d6-6c4c-4e94-baea-99e7c302b3df
> Note: the "after" clip ends at the modal + "no lock" proof rather than
crossing into Stripe Checkout, because this local dev env has a
pre-existing Stripe misconfig (`No such tax rate 'txr_…'`) that 500s on
`/subscribe/checkout` — unrelated to this change (the dialog's Upgrade
button reuses the same `checkout.url()` as the existing on-page Upgrade
button).
Adds HKD (Hong Kong Dollar) as a selectable currency for both users
(primary/display) and individual accounts, following
`docs/adding-a-currency.md`.
## Changes
- `config/currencies.php` — new entry with `allows_primary => true`,
`allows_account => true`
- `lang/es.json` — Spanish name ("Dólar de Hong Kong")
- `lang/fr.json` — French name ("Dollar de Hong Kong")
## Verification
- Provider covers `hkd` at a sane rate (EUR→HKD ≈ 8.99) via
`@fawazahmed0/currency-api`.
- No custom symbol added — `Intl.NumberFormat` renders "HK$" on its own
(symbol map is optional per the doc).
- Validation, dropdowns, and conversion all derive from config
automatically; no code changes needed.
## What
Adds the **Guatemalan Quetzal (GTQ)** as a selectable currency, both as
a primary/display currency and as an account currency.
Following `docs/adding-a-currency.md`, this is a config-driven change:
validation, the Inertia dropdown props, and conversion all derive from
the config entry automatically.
## Changes
- `config/currencies.php` — new `GTQ` entry (`allows_primary` +
`allows_account` both `true`)
- `lang/es.json` — Spanish name: `Quetzal guatemalteco` (enforced
locale)
- `lang/fr.json` — French name: `Quetzal guatémaltèque` (optional
locale)
## Compatibility with the conversion system (verified)
- **ISO 4217:** `GTQ` is the current code (not a
deprecated/pre-redenomination code — avoids the GHC-style ×10000 trap).
- **Provider coverage:** `@fawazahmed0/currency-api` serves `gtq` at a
sane rate (`1 GTQ ≈ 0.131 USD` → ~7.6 GTQ/USD).
- **Live conversion (tinker):** `100 GTQ → 13.13 USD` and `100 EUR →
873.19 GTQ` — both directions return real converted amounts, not the
unconverted passthrough.
- **Options + translation:** `CurrencyOptions` returns GTQ in both
`primaryOptions()` and `accountOptions()`, and renders `Quetzal
guatemalteco` under the `es` locale.
## Tests
```
php artisan test --compact tests/Feature/CurrencyConversionServiceTest.php tests/Feature/LocalizationTest.php
# 16 passed
```
Skipped the optional custom symbol in `resources/js/utils/currency.ts` —
`getCurrencySymbol` falls back to the code and `Intl.NumberFormat`
renders its own glyph (GHS skipped it too).
## What
Adds **Swedish Krona (SEK)** as a currency available for both user
primary/display currencies and individual account currencies (including
bank accounts).
## Why
Config-driven per `docs/adding-a-currency.md`.
`App\Services\CurrencyOptions` reads `config/currencies.php` and feeds
validation (`in:` rules), the Inertia dropdown props, and conversion —
so a single config entry wires the whole feature.
## Changes
- `config/currencies.php` — SEK entry (`allows_primary: true`,
`allows_account: true`)
- `lang/es.json` — `"Swedish Krona": "Corona sueca"` (enforced locale)
- `lang/fr.json` — `"Swedish Krona": "Couronne suédoise"` (optional)
- `resources/js/utils/currency.ts` — `SEK: 'kr'` short symbol
## Verification
Confirmed the `@fawazahmed0/currency-api` provider covers `sek` at a
sane rate (SEK→EUR ≈ 0.091 → EUR→SEK ≈ 11).
## Test
```bash
php artisan test --compact tests/Feature/CurrencyConversionServiceTest.php tests/Feature/LocalizationTest.php
```
## Why
A balance of exactly **0** could not be saved. Zero is a perfectly valid
balance for any account (an emptied wallet, a paid-off loan, a closed
position), so the app was rejecting legitimate input.
## Root cause
The shared `AmountInput` renders a numeric value of `0` as an **empty
string** (so the field shows the `0.00` placeholder instead of a literal
"0.00"). Several balance forms combined that with the native HTML
`required` attribute — which then rejected the *only* value that renders
empty: `0`. `required` on this input never guarded against a missing
value (the state is always numeric; empty == 0), it only ever blocked
zero.
The backend already accepted `0` (`required|integer`, and Laravel's
`required` treats integer `0` as present), so this was purely a frontend
constraint.
## Changes
- Drop `required` from the balance `AmountInput` in the **Update
balance** dialog and the **Balance history** edit modal.
- Remove the explicit `balanceInCents === 0` guard (and `required`) in
the onboarding **Set balance** step.
- Extend the same fix to the CSV-import **reference balance** field
(identical root cause; the compute path already gates on
`referenceBalance !== null`, so an explicit `0` is a valid anchor).
- Remove the now-orphaned `"Please enter a balance"` translation from
`es.json` / `fr.json`.
- Add feature tests asserting a zero balance can be stored and set as
the current balance.
Transaction and budget amount inputs keep `required` — `0` is not a
meaningful value there, so they are intentionally untouched.
## QA
- **Onboarding balance step**: saving with an empty/zero field now
advances instead of showing "Please enter a balance". ✅
- **Update balance dialog**: set €1,000.00, then overwrote it with €0.00
→ dialog closed and the record persisted. Verified in the DB
(`account_balances.balance = 0`). ✅
- Backend contract locked by new tests in
`AccountBalanceControllerTest`.
## Demo
<!-- PLACEHOLDER: drag the video here -->
_Video to attach: `~/Downloads/allow-zero-balances-demo.mp4`_
## What
Add the Ghanaian Cedi as a supported currency.
## Note on GHC vs GHS
This was requested as "GHC". `GHC` is the **deprecated** pre-2007 code:
the conversion provider (`@fawazahmed0/currency-api`) still exposes it,
but its rate is scaled **×10 000** versus the modern cedi (≈113,749
GHC/USD vs ≈11.37 GHS/USD), because Ghana redenominated in 2007 (1 GHS =
10 000 GHC). Adding GHC would leave real-world balances inflated 10
000×. The current ISO 4217 code is **GHS**, which the provider covers at
the correct rate — so we add GHS.
## Compatibility
Provider covers `ghs` (standard ISO 4217). The conversion service
lowercases codes and fetches `ghs.min.json` — same path RSD/NZD use.
`exchange_rates` stores rates as JSON, so any 3-letter code works.
Validation rules and Inertia currency props auto-derive from config.
## Changes
- `config/currencies.php` — GHS entry (`allows_primary` +
`allows_account`)
- `lang/es.json` — Spanish translation
- `lang/fr.json` — French translation
Follows the RSD addition (#567) exactly; no code changes needed.
## What
Follow-up to #600. Reframes the **pay_now** paywall message so the
emphasis is on *trying it risk-free*, not on being charged today.
**Before**
> You'll be charged €3.99 today
> Changed your mind? Get a full refund yourself from Settings within the
first 3 days — no questions asked.
**After**
> **Try it for 3 days**
> Don't like it? Get a full refund with one tap from Settings — no
questions asked.
> _You pay €3.99 today, refunded in full if you cancel within 3 days._
(muted footnote)
## Why
The upfront-charge headline reads as friction. Leading with "try it /
one-tap refund" frames pay_now as risk-free while the charge stays
**honestly disclosed** as a muted footnote — so it's never a surprise at
refund time (and avoids chargeback risk). Amount and day counts stay
dynamic per plan/variant.
## Notes
- Only the pay_now branch of `TrialTerms` changes; control/reduced
trials untouched.
- Spanish + French translations updated; the two orphaned old keys
removed.
- Lint, tsc (paywall), and `LocalizationTest` pass; no test asserted the
literal copy.
## What
A 3-way experiment on how the paid plan is offered, plus per-variant
measurement. New signups (on/after `SUBSCRIPTION_EXPERIMENT_STARTED_AT`)
are split evenly into:
- **control** — current 15-day trial.
- **reduced_trial** — shorter trial: 3 days monthly, 7 days yearly.
- **pay_now** — charged immediately (no trial), with a self-service
money-back guarantee for the first 3 days.
Earlier users stay **legacy** and keep the 15-day trial. **While
`started_at` is null the experiment is off and everyone behaves like
control — inert until activated via env.**
## How it works
- **Assignment** — `App\Features\SubscriptionExperiment` (Pennant),
deterministic even split by a stable hash of the user id. QA can force a
variant with `feature:enable`.
- **Offer policy** — `ExperimentOffer` is the single source of truth for
trial days per plan, the pay-now flag, the refund window and refund
eligibility; shared by checkout, paywall and billing.
- **Checkout** — trial length comes from the variant (`trialDays(0)` for
pay_now → immediate charge).
- **Onboarding clarity** — the paywall states the exact terms above the
CTA: trial length for the selected plan, or "charged €X today + 3-day
money-back guarantee" for pay_now.
- **Self-service refund (pay_now)** — Settings → Billing, within the
window: refunds the upfront charge, `cancelNow`, revokes bank
connections keeping imported data. `refunded_at` records it and blocks a
second refund. Crash-safe ordering: the refund is stamped before
cancel/disconnect, which run best-effort in a try/catch.
## Measurement
`stats:experiment-funnel` (weekly → Discord): per-variant funnel
(assigned, subscribed, status breakdown, refunds) with a **net-active
rate** gated by each variant's decision window (control 15d / reduced 7d
/ pay_now 3d) so cohorts are read at equal age. Attribution reads the
variant Pennant actually served each user, so the report can't drift
from what users experienced. It also reports **MRR** (monthly run-rate
of mature net-active subs, yearly normalised ÷12) and **ARPU** (MRR ÷
assigned) per variant — ARPU is the revenue metric for the winner
decision. Plus a winner can be pinned org-wide with
`SUBSCRIPTION_EXPERIMENT_FORCE_VARIANT` (env, no deploy).
## Config (env)
- `SUBSCRIPTION_EXPERIMENT_STARTED_AT` — activates the experiment
(launch date). Null = off.
- `SUBSCRIPTION_EXPERIMENT_REDUCED_TRIAL_MONTHLY` (3), `..._YEARLY` (7),
`..._REFUND_WINDOW_DAYS` (3)
## Tests
- **Feature/unit:** assignment, offer policy, checkout wiring, refund
eligibility, the refund action incl. idempotency + crash-safe ordering
(Stripe mocked), and the funnel collector/command. ES + FR translations.
- **Browser** (`tests/Browser/SubscriptionRefundTest.php`): the
self-service refund UX end to end — card visibility + deadline, two-step
confirm, back-out, the refund control disappearing after confirming, and
gating (window passed / non-pay_now hidden). The `RefundSelfServe`
action is doubled so it never hits Stripe but applies the same DB
effect. Screenshots: `refund-card-visible`, `refund-confirm-step`,
`refund-completed`.
- Full non-browser suite green (the one failing `DashboardTest` is
pre-existing on `main` — Inertia 409 from the unbuilt local manifest).
Pint + ESLint + tsc (changed files) clean.
## Two independent reviews — acted on
**Fixed:** refund atomicity/idempotency (major) · funnel attribution now
reads Pennant's served value instead of recomputing, killing
report-vs-runtime drift (major) · pay_now copy shows the exact amount
charged · throttle + block-demo on the refund route · `resolve(?User)`
nullable · French translations.
**Reviewer notes (deferred, low value):**
- `refunded_at` is not cast to Carbon on Cashier's `Subscription` (safe
today — only null-compared; would need a custom Cashier model).
- `ExperimentFunnelCollector` walks users in PHP via `chunkById`; fine
at current volume, can move to grouped SQL if it grows.
## Confidence: 85 / 100
The critical money path is now **verified live against the Stripe
sandbox** (see below), which removes the earlier cap. All gates are
green and the acceptance criteria are met. Held at 85 (not higher)
because the browser UI test runs in CI rather than locally, and the
pay_now *hosted-checkout + webhook* leg reuses the standard Cashier
checkout already proven by the control flow (only `trialDays(0)`
differs) but wasn't re-driven through the hosted page. Given it moves
money + disconnects accounts, a human glance is still warranted before
enabling.
## Sandbox verification (live Stripe test mode)
`php artisan stripe:verify-refund` creates a real immediately-charged
subscription with a test card, runs the actual `RefundSelfServe`, and
checks the Stripe API. Result:
```
PASS subscription active after immediate charge (pay_now, no trial)
PASS canSelfRefund is true before refund
PASS latestPayment() resolves a payment intent
PASS refunded_at is stamped
PASS subscription is canceled
PASS canSelfRefund is false after refund
PASS Stripe charge shows a full refund (refunded=true)
```
The command is committed and guarded to Stripe test keys /
non-production, so it can be re-run before each launch toggle.
## Launch checklist
1. Stripe-sandbox smoke: `php artisan stripe:verify-refund` (done —
passing). Optionally also drive the hosted pay_now checkout once for
monthly + yearly to confirm the webhook leg.
2. Set `SUBSCRIPTION_EXPERIMENT_STARTED_AT` to the launch date (set
once; don't backdate).
3. Watch `stats:experiment-funnel`; a clean cohort baseline lands once
each variant's window matures.
## Summary
Lets existing (non-onboarding) users discover and manage AI
categorization, gated behind a new Pennant feature flag. When a flagged
paid user grants consent, every uncategorized transaction is categorized
in the background with live, on-screen feedback.
Enable per user: `php artisan feature:enable AiConsentSettings
user@example.com`
## What's included
**Feature flag**
- New `AiConsentSettings` Pennant flag (off by default), exposed to the
frontend via the shared `features` prop.
**Billing settings (`/settings/billing`)**
- When the flag is active, a section to grant or revoke AI consent
(checkbox → `POST`/`DELETE /ai/consent`).
**Transactions page**
- A consent prompt rendered as the first row of the transactions table
(new `DataTable` `topRow` slot), styled with the shared gradient
`AiSparkleIcon`. Only shown to paid users with the flag who haven't
consented yet.
- An inline "Enable AI" button records consent without leaving the page.
**Backfill on consent**
- Granting consent dispatches a queued
`CategorizeUncategorizedTransactionsJob` that categorizes all of the
user's uncategorized transactions, **most recent first**, recording
progress in the cache.
- The transactions page polls a new status endpoint (`GET
/ai/categorization/{jobId}/status`): visible uncategorized rows show a
spinner + pulse while the backfill runs, categories stream in via
partial reloads, and a toast tracks `processed / total` until
completion.
## Notes / decisions
- **Polling, not websockets** — the project has no broadcasting
configured; this mirrors the existing `ReEvaluateTransactionRules` job +
status-endpoint pattern.
- The backfill job is kept separate from
`CategorizeOnboardingTransactionsJob` so the onboarding pass stays
progress-free (small, commented duplication).
- Reuses the existing `AiCategorizationGate` (config kill-switch + pro
plan + active consent) — free users and disabled-AI never dispatch the
job.
- Not included (YAGNI): cancelling an in-flight backfill on revoke;
persisting the spinner across full page reloads.
## Testing
- New Pest coverage: feature-flag exposure, billing/transactions consent
props, consent-triggered dispatch + gating, the status endpoint
(200/404), job progress recording, and recent-first ordering.
- `php artisan test --exclude-testsuite=Browser` passes (one unrelated,
pre-existing `DashboardTest` failure on `main`).
- Pint, Prettier and ESLint clean.
## What
Add Serbian Dinar (RSD) as a supported currency.
## Compatibility
Conversion provider `@fawazahmed0/currency-api` covers RSD (standard ISO
4217). Conversion service lowercases codes and fetches `rsd.min.json` -
same path NZD uses. `exchange_rates` table stores rates as JSON, so any
3-letter code works. Validation rules and Inertia currency props
auto-derive from config via `CurrencyOptions`.
## Changes
- `config/currencies.php` - RSD entry (`allows_primary` +
`allows_account`)
- `lang/es.json` - Spanish translation
- `lang/fr.json` - French translation
## Tests
Haven't tested them locally, but should pass
## Why
During the bank connection flow (Enable Banking) a user picks which
accounts to sync and can skip the rest. Until now there was **no way
back**: a skipped account couldn't be enabled later, a synced account
couldn't be moved or turned off, and accounts the bank added afterwards
were invisible. Skipped accounts aren't persisted anywhere —
`pending_accounts_data` is cleared once mapping completes — so the app
simply forgets they exist.
This adds a per-connection **Manage Accounts** screen that closes that
gap.
## What
- **Known accounts render from the DB** (no provider call). For each
synced account the user can:
- **Stop syncing** → the account is unlinked and becomes a regular
manual account. Non-destructive: it keeps all its imported transactions.
- **Change destination** → move syncing to another compatible manual
account (unlinks the previous holder, links the new one with incremental
sync).
- **Load accounts** button re-fetches the consent's account list from
the provider *on demand* (`getSession` + `getAccount` only for uids we
don't already know) to surface newly available / previously skipped
accounts, which can then be created as new accounts or linked to
existing manual ones.
- The provider is **only ever hit on refresh**; every mutation is
DB-only, so the screen still works (read + rearrange) when the consent
has expired.
### Design notes
- No new column / cache: the default view derives entirely from the
`accounts` table, and refresh is on-demand and ephemeral. Re-fetching is
mandatory anyway (existing connections have no stored snapshot, and only
a live call can reveal accounts the bank added later).
- Scoped to **Enable Banking** for now; the approach is column-free so
extending to the crypto/API-key providers later is trivial.
## Endpoints
- `GET open-banking/connections/{connection}/accounts` — manage screen
(`discoveredAccounts` computed only when `?refresh=1`)
- `POST open-banking/connections/{connection}/accounts/map` — create /
link / change destination for one bank account
- `POST open-banking/connections/{connection}/accounts/{account}/unlink`
— stop syncing
## Testing
- New `ConnectionAccountTest` (7 tests): index props, refresh discovery
(excludes already-synced uids), create,
change-destination/unlink-previous, non-destructive unlink keeps
transactions, ownership 403, account-mismatch 404.
- Full `tests/Feature/OpenBanking` suite green (268), plus pint, eslint,
prettier and the enforced `es` localization test.
## Deliberately skipped
- **Subscription gating** on refresh/map — matches the currently ungated
`disconnect`. Easy follow-up if free users shouldn't trigger live
provider calls.
- Other providers (Enable Banking only, as agreed).
## What
A community board where users **propose** a bank/service (name + link)
and **vote** on the integrations they want most, so we can prioritise by
real demand.
## How it works
- **Global board**: a shared list sorted by votes desc. `has_voted` and
the monthly quota are per user.
- **Limit**: 3 combined actions (proposal + vote) per user per calendar
month. Creating a proposal **auto-votes** it → costs 2 actions.
- **Moderation**: proposals start as `pending` (visible only to their
author, with a "Pending review" badge); `approved` ones are visible to
everyone. You can't vote on what you can't see (404). The `php artisan
integration-requests:review` command approves/rejects pending ones.
- **Access**:
- Bottom drawer (Vaul, ~95vh) that opens the same board.
- Entry points: the **connect-bank** dialog (bank-selector step), the
**create-account** dialog, and a global **"Request integration"** item
in the user menu (above Support) → available on any screen.
- The `/integration-requests` URL renders the **dashboard** with the
drawer opened on top (behind auth + verified + onboarded + subscribed).
- **Seed**: a migration that inserts Bitunix, XTB, Kraken, Degiro and
Interactive Brokers as `approved` and self-voted by the earliest-created
user (idempotent, no-op when no users exist).
## Endpoints
- `GET /integration-requests` → dashboard + drawer.
- `GET /integration-requests/data` → board state as JSON (feeds the
drawer).
- `POST /integration-requests` → create a proposal (+ auto-vote).
- `POST /integration-requests/{id}/vote` → toggle vote.
## Tests
- 15 Feature tests (access, status visibility, monthly limit, auto-vote,
vote toggle, review command) plus the user-menu tests. All green;
`pint`/`eslint`/`prettier`/`tsc`/PHPStan clean.
## Notes / follow-ups
- No Pennant flag (ships to everyone). No dedup of duplicate proposals
(moderation covers it).
- Seeding 5 integrations to one user uses up that user's monthly quota
(only affects that account via the UI).
## What
Adds a **Support** entry to the user dropdown menu (below Roadmap) for
reporting any kind of error.
Clicking it opens a modal that:
1. **Leads with the community** — a primary Discord button so users can
message us directly.
2. **Offers email as a fallback** — opens a
`mailto:support@whisper.money` pre-filled with a bug-report prompt plus
diagnostics: **user UUID, app version, locale, current page URL, and
browser user-agent**.
## Why
Give users a clear, low-friction path to report bugs and get help, while
steering them to the community first.
## Changes
- `resources/js/components/support-dialog.tsx` (new) — the modal +
mailto builder.
- `resources/js/components/user-menu-content.tsx` — Support menu item +
`onOpenSupport` prop.
- `resources/js/components/nav-user.tsx` — dialog state; rendered as a
sibling of the dropdown (Radix unmounts dropdown content on close).
- `resources/js/components/user-menu-content.test.tsx` — updated + new
click test.
- `lang/es.json`, `lang/fr.json` — 7 translation keys each.
## Notes
- Diagnostic labels (User ID, Browser, etc.) are intentionally left
untranslated so the support team reads them in English.
- No backend route needed — uses a `mailto:` link.
## Testing
- `bun run test resources/js/components/user-menu-content.test.tsx` ✅
- `vendor/bin/pest tests/Feature/LocalizationTest.php` ✅ (es enforced)
- eslint + prettier ✅
## What
French translation rendered the **Discord** brand name as "Discorde".
Brand names stay as-is — fixed `lang/fr.json` line 581.
## Check
Swept all brand tokens (Discord, Stripe, GitHub, Google, etc.) in
`fr.json`. Only this one was mistranslated; the rest keep the brand
correctly.
Hello,
Here is my pull request for the official french translation.
I can change some traduction if you want
---------
Co-authored-by: Víctor Falcón <victoor89@gmail.com>