Skip to content

feat(accounts): capability state machine + headline status nomenclature (ENG-516) - #452

Merged
islandbitcoin merged 3 commits into
mainfrom
eng-516/account-capability-state-machine
Jul 17, 2026
Merged

feat(accounts): capability state machine + headline status nomenclature (ENG-516)#452
islandbitcoin merged 3 commits into
mainfrom
eng-516/account-capability-state-machine

Conversation

@islandbitcoin

@islandbitcoin islandbitcoin commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Summary

Backend half of ENG-516 (Account Upgrade Revamp). Retires Pro and International as user-facing tiers, renames Merchant → Business, and models the account as a set of capability flags from which the internal level is derived. Companion frappe-flash-admin PR adopts the new names in the admin panel; mobile presentation lands with ENG-513.

Product decision (2026-07-14): light headline status — the account leads with one word (Trial → Verified → Business) with capability badges as supporting detail.

The state machine

Account = { verified, bankPayout, business, usdAccount }

verified (phone + ID)                    → L1
+ bankPayout, individual                 → L2  (business-less Pro — ENG-515)
+ business (name + address) + bankPayout → L3
usdAccount (Bridge KYC)                  → orthogonal flag, any level ≥ L1
  • GraphQL capabilities/statusHeadline resolve from one memoized derivation per account per request (WeakMap keyed on the account object) — one ERPNext lookup no matter how many fields ask; the Bridge-KYC "approved" literal is a typed domain constant (BRIDGE_KYC_APPROVED).
  • src/domain/accounts/capabilities.ts — pure derivation: deriveLevelFromCapabilities, deriveStatusHeadline, deriveCapabilitiesForAccount (read model over stored level + ERPNext bank accounts + bridgeKycStatus, with grandfathering for existing L2/L3 accounts).
  • GraphQL: capabilities: AccountCapabilities! and statusHeadline: AccountStatusHeadline! on ConsumerAccount (public) and AuditedAccount (admin). level stays exposed but documented as internal.
  • Capability transitions: new accountCapabilityUpgradeRequest mutation — the client asks for one capability (BANK_PAYOUT or BUSINESS); the target level is derived server-side and the request flows down the existing ERPNext Account Upgrade Request pipeline unchanged. businessAccountUpgradeRequest (whole-tier) remains for existing clients.
  • Full spec: docs/account-capabilities.md.

Testing

  • TEST="capabilities|request-capability-upgrade|get-account-capabilities" yarn test:unit — 27 tests: derivation matrix, headline mapping, grandfathering, round-trip; requestCapabilityUpgrade routing (unverified / already-has / missing-bank / L2 add-bank / L3 reuse-on-file); getAccountCapabilities ERPNext-failure fallback, no-erpParty skip, and memoization semantics.
  • Full unit suite: 135 suites / 1064 passed.
  • yarn tsc-check clean; SDL regenerated via yarn write-sdl (public + admin + supergraph).

Local testing

query { me { defaultAccount { ... on ConsumerAccount { statusHeadline capabilities { verified bankPayout business usdAccount } } } } }

mutation { accountCapabilityUpgradeRequest(input: {
  capability: BANK_PAYOUT
  fullName: "Test User"
  address: { title: "Home", line1: "1 Main St", city: "Kingston", state: "St Andrew", country: "Jamaica" }
  bankAccount: { bankName: "NCB", bankBranch: "Half Way Tree", accountType: "Savings", currency: "JMD", accountNumber: "123456789" }
}) { id status errors { message } } }

🤖 Generated with Claude Code

@linear

linear Bot commented Jul 15, 2026

Copy link
Copy Markdown

ENG-516

@islandbitcoin

Copy link
Copy Markdown
Contributor Author

Review — capability state machine (ENG-516)

Domain derivation module + the 15-test matrix are solid. Four items to action before merge:

1. 🔴 CI is red — new mutation not registered in scope-map.ts.
api-key-scope-enforcement.spec.ts › "maps every authed root field" fails: accountCapabilityUpgradeRequest is unmapped (deny-by-default net). Add accountCapabilityUpgradeRequest: "BLOCKED" to src/domain/api-keys/scope-map.ts (matches its sibling businessAccountUpgradeRequest). The full yarn test:unit is red — the scoped TEST=capabilities run hid it; run the full suite before green-claiming.

2. 🟠 An unverified (L0) account can mint an L2 upgrade request.
request-capability-upgrade.ts computes level = deriveLevelFromCapabilities(...) — correctly L0 when !verified — but only special-cases === L3; L0/L1/L2 all fall through to an L2 request. The only downstream guard, AccountUpgradeRequest.validate() (AccountUpgradeRequest.ts:16), is requestedLevel <= currentLevel → reject, so 2 > 0 passes. Net: a phone-only, un-ID'd account can request bank-payout → L2, skipping the verified prerequisite the state machine is built on. Add if (!targetCapabilities.verified) return new ValidationError(...) before creating the request. (Pre-existing at the validate layer, but this handler is the one that derives L0 and then ignores it.)

3. 🟡 input.bankAccount as BankAccount (request-capability-upgrade.ts:78) casts away undefined.
On the business-with-on-file-bank path input.bankAccount is undefined; the cast satisfies createUpgradeRequest's L3 type (bankAccount required) but sends empty bank fields to ERPNext. No crash (validate() uses this.bankAccount?.…), but a grandfathered account whose ERPNext bank is missing/null → an incomplete L3 request. Make the L3 bankAccount optional in the type (honest) or confirm a real bank exists before deriving business.

4. 🟡 requestCapabilityUpgrade has no tests.
The 15 tests cover the pure functions; the mutation orchestration — where #2 and #3 live — is untested. Add: unverified → rejected, business with on-file bank (no bankAccount submitted), and already-has-capability.

Minor: the read-model derived level can exceed the still-exposed stored level (an L1 account with a bank on file reads bankPayout: true → implies L2). Known per your "consistent accounts" round-trip test; worth a doc note since both fields ship over GraphQL.

Blocking: #1 and #2.

forge0x and others added 3 commits July 16, 2026 19:45
…re (ENG-516)

Retire Pro/International as user-facing tiers; rename Merchant to Business.
Model the account as capability flags {verified, bankPayout, business,
usdAccount} and derive the internal level (L1-L3) from them. Expose
capabilities + statusHeadline (light headline status: Trial / Verified /
Business) on ConsumerAccount and AuditedAccount, and add the
accountCapabilityUpgradeRequest mutation so clients request a single
capability instead of a whole tier. Spec in docs/account-capabilities.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…unt typing (ENG-516)

Review follow-ups on the capability state machine:

- Register accountCapabilityUpgradeRequest in the API-key scope map (BLOCKED,
  like businessAccountUpgradeRequest). The deny-by-default completeness test
  was red because the new mutation was unmapped.
- requestCapabilityUpgrade now rejects unverified accounts: the state machine
  derives L0 for !verified, so a request from one would otherwise become a
  level-skipping L2 ERPNext request (validate() only guards requested<=current).
- Type the L3 upgrade's bankAccount as optional and drop the `as BankAccount`
  cast — a business upgrade legitimately reuses a bank already on file, and
  validate() already reads it via optional chaining.
- Add unit tests for requestCapabilityUpgrade (unverified, already-has,
  missing-bank, L2 add-bank, L3 reuse-on-file).
- Doc note on derived-vs-stored level divergence.

Verified: tsc --noEmit clean; full unit suite 134 suites / 1057 passed
(api-key-scope-enforcement now green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…eral (ENG-516)

Review follow-ups:

- getAccountCapabilities memoizes per account object (WeakMap): the
  capabilities and statusHeadline GraphQL fields each resolved it
  separately, costing two serial ERPNext round-trips per query — and
  N×2 on admin views that list accounts. One lookup now serves every
  field resolution for the request; no cross-request staleness since
  each request fetches a fresh account object.
- BRIDGE_KYC_APPROVED constant typed against Account["bridgeKycStatus"]
  replaces the bare "approved" magic string in the derivation, so an
  upstream status rename is a compile error instead of a silently-false
  usdAccount.
- getAccountCapabilities unit tests: ERPNext-failure fallback (the
  branch that runs during an ERPNext incident), no-erpParty skip,
  bank-on-file, memoization semantics.
- requestCapabilityUpgrade return annotation was Promise<Promise<...>>
  via ReturnType of an async fn; unwrapped.

tsc clean; full unit suite 135 suites / 1064 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@islandbitcoin
islandbitcoin force-pushed the eng-516/account-capability-state-machine branch from 7a8e960 to 37fc096 Compare July 17, 2026 02:46
@islandbitcoin
islandbitcoin merged commit 7c7abd1 into main Jul 17, 2026
14 of 15 checks passed
islandbitcoin added a commit to lnflash/frappe-flash-admin that referenced this pull request Jul 17, 2026
…, Merchant → Business (ENG-516) (#55)

* feat(admin): adopt capability nomenclature — retire Pro/International, Merchant → Business (ENG-516)

Account Hub and Account Management adopt the light-headline-status naming:
accounts lead with Trial / Verified / Business, with capability badges
(Bank payout, USD account) as supporting detail. Upgrade-request levels
read as the capability being requested. The GraphQL account fragment now
fetches statusHeadline + capabilities from the flash backend (requires
lnflash/flash#452), with a level-based fallback for older backends.
The change-level dialog uses disambiguated internal labels since L1 and
L2 share the Verified headline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(admin): prettier format + disambiguate change-level dialog messages (ENG-516)

Review follow-ups on #55:
- prettier: format account_hub.js (was failing the pre-commit CI hook — the
  new capabilityBadgesHtml chain wasn't prettier-clean).
- Change-level dialog now uses the disambiguated ADMIN_LEVEL_OPTIONS labels in
  its ERP-party and success messages (was ACCOUNT_LEVEL_LABELS, which renders
  "Verified" for both L1 and L2 — misleading, since L1 needs no ERP party).

Verified: prettier --check clean; node --check OK.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Patoo <262265744+patoo0x@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Dread <dread@example.com>
islandbitcoin added a commit to lnflash/flash-mobile that referenced this pull request Jul 18, 2026
…G-516) (#667)

* feat(account): light headline status + capability nomenclature (ENG-516)

Retire Pro and International as user-facing labels; Merchant is now
Business. The account leads with one headline word (Trial / Verified /
Business) with capability badges (Bank payout, USD account) as detail,
per the light-headline-status product decision. New useAccountStatus
hook queries statusHeadline + capabilities from the backend
(lnflash/flash#452) with a level-based fallback for older backends.
Settings row and Account screen show the headline instead of the raw
level; upgrade-flow tier cards adopt the new names. Synced
public-schema.graphql from the backend (additive only) and reran
codegen + typesafe-i18n.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(account): hide Business upgrade card for accounts already at L3 (ENG-516)

The Business (L3) card rendered unconditionally, so a business account
reaching the picker (e.g. via Bank accounts → Upgrade your account when
Bridge KYC isn't approved) was offered Business again. Guard it like
the other tier cards; a business account now only sees the remaining
capability (USD Account).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(bank-accounts): launch Bridge KYC directly from the verify-identity CTA (ENG-516)

The locked card on the Bank accounts screen gates on the usdAccount
capability, so its CTA now opens the Bridge KYC flow (details modal →
KYC webview) instead of the tier picker. KYC initiation is extracted
from AccountType into a shared useBridgeKyc hook used by both screens;
the CTA falls back to the picker when the Bridge feature flag is off.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(account): capabilities hub replaces tier picker (ENG-513)

Rebuild AccountType into the "Do more with Flash" capabilities hub:
a light status pill (Trial/Verified/Business) from useAccountStatus, a
Verify hero card, and independent capability rows (Bank cash-out,
US-dollar account, Business) grouped under "Ways to get paid" / "Grow".
Each row routes into its existing setup flow — no guided interview.
Rows key off the capabilities object (level-derived fallback for older
backends); the internal L1/L2/L3 ladder stays hidden. Drops the linear
ProgressSteps, which no longer models a non-linear capability menu.

Adds hub i18n strings under AccountUpgrade + regenerated i18n types.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(account): review fixes — Bridge KYC L1 floor, locked USD row, string sweep (ENG-513/516)

- Bank accounts verify CTA now enforces the L1 floor: unverified users
  route through the verify wizard; verified users launch Bridge KYC
  directly. The fallback to the upgrade picker is gone.
- Hub USD row is always visible: locked (not hidden) when Bridge is
  remotely disabled or the account is unverified; an "In review" state
  restores the pending-KYC affordance the old card's orange border gave.
- useAccountStatus is the single source of capability truth: the
  level+KYC fallback moved to account-status-derivation.ts (unit-tested)
  and AccountType's duplicate derivation is removed.
- String sweep: dead tier keys deleted (personal/pro/merchant/
  international + descs, accountType, successUpgrade/successRequest);
  success + validation copy speaks capability language instead of
  "upgraded to BANK PAYOUT"; account screens and Bridge KYC alerts are
  localized; the hub's nav header is untitled (the screen self-titles).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(i18n): translate capability-hub strings into all locales (ENG-513/516)

The translation-drift CI check requires every locale to carry all keys
in en.json. Adds the 24 new AccountUpgrade hub keys and 3 BridgeKyc
alert keys to all 23 locale files, refreshes the renamed BridgeKyc
title/description (was untranslated "International Transfer"
everywhere), and prunes the 11 tier keys deleted from en — including
long-corrupted values like es "pro": "Pro Pro Pro …".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(account): sharpen hub copy from device user-test (ENG-513)

- Verify: "Phone + ID" was wrong — verification is phone + OTP only.
  Now "Just your phone number · unlocks higher limits in a minute".
- USD row: title "US-Dollar Virtual Bank Account"; description leads
  with what you get — "Your own US account & routing number — receive
  ACH, wires & payroll".
- Business: adds the rewards program — "Get on the Flash map & reward
  your customers".

All 23 locales updated in step.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(account): explain the locked USD row + decrowd it (ENG-513)

When the USD row is locked by the L1 floor, its description now reads
"Verify your account first to unlock" instead of leaving the lock
unexplained (kill-switch locks keep the normal description). The row
also decrowds: title shortened to "USD Virtual Bank Account" and the
locked state shows only a lock icon, so the title no longer wraps.
All 23 locales updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(account): drop unused affordances from the KYC + status hooks

startBridgeKyc's boolean return had no remaining consumers once the
picker fallback was removed — make it void and fix the stale comment.
use-account-status re-exported the derivation helpers nobody imports
from it; the derivation module is their canonical home.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(account): cover useAccountStatus source selection + drop dead Screen prop

Two review items: a renderHook test asserting backend
statusHeadline/capabilities beat the level+KYC fallback (and that the
fallback engages when the fields are absent), and removal of
keyboardShouldPersistTaps from the hub Screen — it has no text inputs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Vandana <forge@getflash.io>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Dread <dread@example.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants