Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions dev/apollo-federation/supergraph.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,50 @@ interface Account
wallets: [Wallet!]!
}

type AccountCapabilities
@join__type(graph: PUBLIC)
{
"""An approved bank account is on file for payouts."""
bankPayout: Boolean!

"""Business profile (name and address) on file."""
business: Boolean!

"""
USD account and routing number available (Bridge KYC approved). Orthogonal to the other capabilities.
"""
usdAccount: Boolean!

"""Phone and identity verified."""
verified: Boolean!
}

enum AccountCapability
@join__type(graph: PUBLIC)
{
BANK_PAYOUT @join__enumValue(graph: PUBLIC)
BUSINESS @join__enumValue(graph: PUBLIC)
}

input AccountCapabilityUpgradeRequestInput
@join__type(graph: PUBLIC)
{
address: AddressInput!
bankAccount: BankAccountInput
capability: AccountCapability!
fullName: String!
idDocument: String
terminalsRequested: Int = 0
}

type AccountCapabilityUpgradeRequestPayload
@join__type(graph: PUBLIC)
{
errors: [Error]
id: String
status: String
}

type AccountDeletePayload
@join__type(graph: PUBLIC)
{
Expand Down Expand Up @@ -126,6 +170,14 @@ type AccountLimits
scalar AccountNumber
@join__type(graph: PUBLIC)

enum AccountStatusHeadline
@join__type(graph: PUBLIC)
{
BUSINESS @join__enumValue(graph: PUBLIC)
TRIAL @join__enumValue(graph: PUBLIC)
VERIFIED @join__enumValue(graph: PUBLIC)
}

input AccountUpdateDefaultWalletIdInput
@join__type(graph: PUBLIC)
{
Expand Down Expand Up @@ -843,6 +895,7 @@ type ConsumerAccount implements Account
@join__type(graph: PUBLIC)
{
callbackEndpoints: [CallbackEndpoint!]!
capabilities: AccountCapabilities!

"""
return CSV stream, base64 encoded, of the list of transactions in the wallet
Expand All @@ -851,13 +904,18 @@ type ConsumerAccount implements Account
defaultWalletId: WalletId!
displayCurrency: DisplayCurrency!
id: ID!

"""
Internal account level, derived from capabilities (ENG-516). Present capabilities and statusHeadline instead of this.
"""
level: AccountLevel!
limits: AccountLimits!
notificationSettings: NotificationSettings!

"""List the quiz questions of the consumer account"""
quiz: [Quiz!]!
realtimePrice: RealtimePrice!
statusHeadline: AccountStatusHeadline!

"""
A list of all transactions associated with walletIds optionally passed.
Expand Down Expand Up @@ -1536,6 +1594,7 @@ type MobileVersions
type Mutation
@join__type(graph: PUBLIC)
{
accountCapabilityUpgradeRequest(input: AccountCapabilityUpgradeRequestInput!): AccountCapabilityUpgradeRequestPayload!
accountDelete: AccountDeletePayload!
accountDisableNotificationCategory(input: AccountDisableNotificationCategoryInput!): AccountUpdateNotificationSettingsPayload!
accountDisableNotificationChannel(input: AccountDisableNotificationChannelInput!): AccountUpdateNotificationSettingsPayload!
Expand Down
132 changes: 132 additions & 0 deletions docs/account-capabilities.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
# Account capabilities and nomenclature (ENG-516)

Part of the Account Upgrade Revamp. This is the spec agreed across mobile
(ENG-513), backend, and frappe-flash-admin; business-less Pro (ENG-515)
builds on the state machine defined here.

## Nomenclature

| Internal level | Old user-facing label | New user-facing presentation |
|---|---|---|
| L0 | Trial | **Trial** |
| L1 | Personal | **Verified** |
| L2 | Pro | **Verified** + *Bank payout* badge |
| L3 | Merchant | **Business** |
| — | International | *USD account* badge (capability, not a tier) |

- "Pro" is retired (vague). "International" is retired (it's just a USD
account). "Merchant" is renamed **Business**.
- Levels (L1/L2/L3) are internal-only — derived from capabilities, never
shown as a tier the user picks.
- Product decision (2026-07-14): **light headline status**. The account leads
with one word — Trial → Verified → Business — with capability badges as
supporting detail (not the pure capability-badge approach).

## Capability model

```
Account = { verified, bankPayout, business, usdAccount }
```

| Capability | Meaning | Source of truth today |
|---|---|---|
| `verified` | phone + ID verified | stored level ≥ 1 (grandfathered) |
| `bankPayout` | approved bank account on file (JM bank) | ERPNext Bank Account records for the account's `erpParty`; stored level ≥ 2 stands in when ERPNext is unavailable |
| `business` | business name + address on file | stored level ≥ 3 (grandfathered) |
| `usdAccount` | USD account + routing number (Bridge) | `bridgeKycStatus === "approved"` |

## State machine (derived level)

```
verified (phone + ID) → L1
+ bankPayout, individual → L2 (business-less Pro — ENG-515)
+ business (name + address) + bankPayout → L3
usdAccount → orthogonal flag, any level ≥ L1
```

Implementation: `src/domain/accounts/capabilities.ts`
(`deriveLevelFromCapabilities`, `deriveStatusHeadline`,
`deriveCapabilitiesForAccount`). The stored `account.level` remains the
operational value used by limits and permissions; transitions recompute it
through the state machine.

> **Note — derived level vs stored `level`.** The read model can imply a
> level higher than the stored one: an L1 account with an approved bank
> account on file resolves `bankPayout: true`, which derives to L2. Both the
> stored `level` and the derived `capabilities`/`statusHeadline` are exposed
> over GraphQL, so a client may observe `level: 1` alongside `bankPayout: true`.
> Treat `capabilities` as the source of truth for what the account can do;
> `level` is internal and retained for backward compatibility.

## GraphQL surface

On `ConsumerAccount` (public) and `AuditedAccount` (admin):

```graphql
type AccountCapabilities {
verified: Boolean!
bankPayout: Boolean!
business: Boolean!
usdAccount: Boolean!
}

enum AccountStatusHeadline {
TRIAL
VERIFIED
BUSINESS
}

# on the account types:
capabilities: AccountCapabilities!
statusHeadline: AccountStatusHeadline!
```

Clients should present `statusHeadline` as the headline and `capabilities`
as badges. `level` stays exposed for compatibility but should not be
displayed as a tier.

## Capability transitions

Clients request a single capability instead of a whole tier:

```graphql
enum AccountCapability {
BANK_PAYOUT
BUSINESS
}

input AccountCapabilityUpgradeRequestInput {
capability: AccountCapability!
fullName: String!
address: AddressInput!
terminalsRequested: Int = 0
bankAccount: BankAccountInput # required for BANK_PAYOUT; required for
# BUSINESS unless bankPayout is already held
idDocument: String
}

mutation {
accountCapabilityUpgradeRequest(input: …) {
errors { message }
id
status
}
}
```

The server derives the target level from current capabilities plus the
requested one and posts the existing ERPNext **Account Upgrade Request**
doctype — the human review flow in frappe-flash-admin is unchanged.
`businessAccountUpgradeRequest` (whole-tier, takes a `level`) remains for
existing clients but is superseded by this mutation.

`verified` is granted by the identity flow and `usdAccount` by the Bridge
KYC flow; neither goes through the upgrade-request pipeline.

## Out of scope here

- Mobile presentation (hub + flows): ENG-513.
- Business-less Pro end-to-end flow: ENG-515.
- Persisting capability flags on the account record (today they are a read
model over existing data; storage can move here later without changing the
GraphQL contract).
4 changes: 3 additions & 1 deletion src/app/accounts/business-account-upgrade-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,9 @@ type MerchantUpgradeRequest = {
fullName: string
address: Address
terminalsRequested: number
bankAccount: BankAccount
// Optional: a business (L3) upgrade may reuse a bank account already on file
// in ERPNext (ENG-516 capability flow), in which case none is re-submitted.
bankAccount?: BankAccount
idDocument: string
}

Expand Down
61 changes: 61 additions & 0 deletions src/app/accounts/get-account-capabilities.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import ErpNext from "@services/frappe/ErpNext"
import { baseLogger } from "@services/logger"

import {
AccountLevel,
deriveCapabilitiesForAccount,
deriveStatusHeadline,
} from "@domain/accounts"

export type AccountCapabilityPresentation = {
capabilities: AccountCapabilities
statusHeadline: AccountStatusHeadline
}

// `capabilities` and `statusHeadline` resolve as separate GraphQL fields off
// the same source account, and the derivation costs an ERPNext round-trip.
// Memoize per account object so one request does one lookup no matter how
// many fields (or resolvers) ask; keyed weakly, so there is no cross-request
// staleness — every request fetches a fresh account object.
const inflight = new WeakMap<Account, Promise<AccountCapabilityPresentation>>()

// Read model for ENG-516: derive the account's capability flags and the
// user-facing headline status from what is actually on file. The bank-payout
// lookup goes to ERPNext; when ERPNext is unreachable (or the account has no
// ERP party yet) the stored level stands in via the grandfathering rule in
// deriveCapabilitiesForAccount, so the field degrades rather than errors.
export const getAccountCapabilities = (
account: Account,
): Promise<AccountCapabilityPresentation> => {
const cached = inflight.get(account)
if (cached) return cached
const derivation = deriveAccountCapabilities(account)
inflight.set(account, derivation)
return derivation
}

const deriveAccountCapabilities = async (
account: Account,
): Promise<AccountCapabilityPresentation> => {
let hasBankAccountOnFile = false
if (account.erpParty && ErpNext) {
const bankAccounts = await ErpNext.getBankAccountsByCustomer(account.erpParty)
if (bankAccounts instanceof Error) {
baseLogger.warn(
{ err: bankAccounts, accountId: account.id },
"getAccountCapabilities: bank account lookup failed, falling back to stored level",
)
hasBankAccountOnFile = account.level >= AccountLevel.Two
} else {
hasBankAccountOnFile = bankAccounts.length > 0
}
}

const capabilities = deriveCapabilitiesForAccount({
level: account.level,
hasBankAccountOnFile,
bridgeKycStatus: account.bridgeKycStatus,
})

return { capabilities, statusHeadline: deriveStatusHeadline(capabilities) }
}
2 changes: 2 additions & 0 deletions src/app/accounts/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export * from "./update-account-level"
export * from "./business-account-upgrade-request"
export * from "./bank-account-update-request"
export * from "./get-account-upgrade-request"
export * from "./get-account-capabilities"
export * from "./request-capability-upgrade"
export * from "./get-supported-banks"
export * from "./update-account-status"
export * from "./update-business-map-info"
Expand Down
Loading
Loading