## 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>