From 94473e2bc35a2e9305e72f54293903c36fb9a2a0 Mon Sep 17 00:00:00 2001 From: Vandana Date: Wed, 15 Jul 2026 11:58:16 -0700 Subject: [PATCH 1/3] feat(accounts): capability state machine + headline status nomenclature (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 --- dev/apollo-federation/supergraph.graphql | 59 +++++++ docs/account-capabilities.md | 124 +++++++++++++ src/app/accounts/get-account-capabilities.ts | 44 +++++ src/app/accounts/index.ts | 2 + .../accounts/request-capability-upgrade.ts | 87 +++++++++ src/domain/accounts/capabilities.ts | 79 +++++++++ src/domain/accounts/index.ts | 1 + src/domain/accounts/index.types.d.ts | 13 ++ src/domain/accounts/primitives.ts | 6 +- src/graphql/admin/schema.graphql | 25 +++ src/graphql/admin/types/object/account.ts | 18 +- src/graphql/public/mutations.ts | 2 + .../account-capability-upgrade-request.ts | 78 ++++++++ .../business-account-upgrade-request.ts | 6 +- src/graphql/public/schema.graphql | 49 +++++ .../public/types/object/consumer-account.ts | 20 +++ .../types/object/account-capabilities.ts | 28 +++ .../types/scalar/account-status-headline.ts | 14 ++ .../unit/domain/accounts/capabilities.spec.ts | 167 ++++++++++++++++++ 19 files changed, 817 insertions(+), 5 deletions(-) create mode 100644 docs/account-capabilities.md create mode 100644 src/app/accounts/get-account-capabilities.ts create mode 100644 src/app/accounts/request-capability-upgrade.ts create mode 100644 src/domain/accounts/capabilities.ts create mode 100644 src/graphql/public/root/mutation/account-capability-upgrade-request.ts create mode 100644 src/graphql/shared/types/object/account-capabilities.ts create mode 100644 src/graphql/shared/types/scalar/account-status-headline.ts create mode 100644 test/flash/unit/domain/accounts/capabilities.spec.ts diff --git a/dev/apollo-federation/supergraph.graphql b/dev/apollo-federation/supergraph.graphql index b50589828..9c8d30139 100644 --- a/dev/apollo-federation/supergraph.graphql +++ b/dev/apollo-federation/supergraph.graphql @@ -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) { @@ -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) { @@ -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 @@ -851,6 +904,10 @@ 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! @@ -858,6 +915,7 @@ type ConsumerAccount implements Account """List the quiz questions of the consumer account""" quiz: [Quiz!]! realtimePrice: RealtimePrice! + statusHeadline: AccountStatusHeadline! """ A list of all transactions associated with walletIds optionally passed. @@ -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! diff --git a/docs/account-capabilities.md b/docs/account-capabilities.md new file mode 100644 index 000000000..504d45b2e --- /dev/null +++ b/docs/account-capabilities.md @@ -0,0 +1,124 @@ +# 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. + +## 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). diff --git a/src/app/accounts/get-account-capabilities.ts b/src/app/accounts/get-account-capabilities.ts new file mode 100644 index 000000000..46538fb8b --- /dev/null +++ b/src/app/accounts/get-account-capabilities.ts @@ -0,0 +1,44 @@ +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 +} + +// 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 = async ( + account: Account, +): Promise => { + 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) } +} diff --git a/src/app/accounts/index.ts b/src/app/accounts/index.ts index 7249b4008..f29ff4e21 100644 --- a/src/app/accounts/index.ts +++ b/src/app/accounts/index.ts @@ -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" diff --git a/src/app/accounts/request-capability-upgrade.ts b/src/app/accounts/request-capability-upgrade.ts new file mode 100644 index 000000000..cd182a34e --- /dev/null +++ b/src/app/accounts/request-capability-upgrade.ts @@ -0,0 +1,87 @@ +import { AccountsRepository } from "@services/mongoose" +import { ValidationError } from "@domain/shared" +import { + AccountLevel, + RequestableCapability, + deriveLevelFromCapabilities, +} from "@domain/accounts" + +import type { BankAccount } from "@services/frappe/models/BankAccount" + +import { createUpgradeRequest, type Address } from "./business-account-upgrade-request" +import { getAccountCapabilities } from "./get-account-capabilities" + +export type CapabilityUpgradeRequest = { + capability: RequestableCapability + fullName: string + address: Address + terminalsRequested: number + bankAccount?: BankAccount + idDocument?: string +} + +// ENG-516: "add a capability" transition. Instead of the client picking a +// whole tier (the retired Pro/Merchant/International nomenclature), it asks +// for one capability; the target internal level is derived from the account's +// current capabilities plus the requested one, and the request travels down +// the existing ERPNext Account Upgrade Request pipeline for human review. +export const requestCapabilityUpgrade = async ( + accountId: AccountId, + input: CapabilityUpgradeRequest, +): Promise> => { + const account = await AccountsRepository().findById(accountId) + if (account instanceof Error) return account + + const { capabilities } = await getAccountCapabilities(account) + + if (capabilities[input.capability]) { + return new ValidationError(`Account already has the ${input.capability} capability`) + } + + // Per-capability requirements. Business also needs a bank account on file + // (it is part of the L3 requirements) but not re-submitted if one exists. + if (input.capability === RequestableCapability.BankPayout && !input.bankAccount) { + return new ValidationError("Bank account details are required for bank payout") + } + if ( + input.capability === RequestableCapability.Business && + !input.bankAccount && + !capabilities.bankPayout + ) { + return new ValidationError( + "Bank account details are required to set up a business account", + ) + } + + const targetCapabilities = { + ...capabilities, + [input.capability]: true, + // A submitted bank account also satisfies bankPayout on the way to business. + bankPayout: capabilities.bankPayout || input.bankAccount !== undefined, + } + const level = deriveLevelFromCapabilities(targetCapabilities) + + const base = { + accountId, + fullName: input.fullName, + address: input.address, + terminalsRequested: input.terminalsRequested, + idDocument: input.idDocument ?? "", + } + + if (level === AccountLevel.Three) { + return createUpgradeRequest(accountId, { + ...base, + level: AccountLevel.Business, + // When omitted, the bank account is already on file in ERPNext + // (capabilities.bankPayout was checked above). + bankAccount: input.bankAccount as BankAccount, + }) + } + + return createUpgradeRequest(accountId, { + ...base, + level: AccountLevel.Two, + bankAccount: input.bankAccount, + }) +} diff --git a/src/domain/accounts/capabilities.ts b/src/domain/accounts/capabilities.ts new file mode 100644 index 000000000..b2f9cfeac --- /dev/null +++ b/src/domain/accounts/capabilities.ts @@ -0,0 +1,79 @@ +import { AccountLevel } from "./primitives" + +// ENG-516: account capability state machine. +// +// The account is modelled as a set of capability flags; the numeric backend +// level (L0–L3) is derived from them and is internal-only — it is never shown +// to the user as a tier they pick. User-facing nomenclature leads with a +// single headline status (Trial → Verified → Business, the "light headline +// status" product decision of 2026-07-14) with capability badges as +// supporting detail. "Pro" and "International" are retired as labels; +// "Merchant" is renamed "Business"; the USD account (Bridge) is an +// orthogonal capability, not a tier. + +export const AccountStatusHeadline = { + Trial: "TRIAL", + Verified: "VERIFIED", + Business: "BUSINESS", +} as const + +// Capabilities a user can request through the upgrade flow. usdAccount is +// excluded: it is granted by the Bridge KYC flow, and verified is granted by +// the identity flow, not by an upgrade request. +export const RequestableCapability = { + BankPayout: "bankPayout", + Business: "business", +} as const + +// Derive the internal account level from capability flags. +// +// verified (phone + ID) → L1 +// + bankPayout (bank on file), individual → L2 (business-less Pro, ENG-515) +// + business (name + address) + bankPayout → L3 +// +// usdAccount (Bridge KYC) is orthogonal and does not affect the level. +// business without bankPayout is an incomplete business setup and does not +// reach L3 — the bank account is part of the L3 requirements. +export const deriveLevelFromCapabilities = ( + capabilities: AccountCapabilities, +): AccountLevel => { + if (!capabilities.verified) return AccountLevel.Zero + if (capabilities.business && capabilities.bankPayout) return AccountLevel.Three + if (capabilities.bankPayout) return AccountLevel.Two + return AccountLevel.One +} + +// The single user-facing status word (light headline status). Levels 1 and 2 +// both read "Verified" — bank payout shows as a capability badge, not a tier. +export const deriveStatusHeadline = ( + capabilities: AccountCapabilities, +): AccountStatusHeadline => { + const level = deriveLevelFromCapabilities(capabilities) + if (level >= AccountLevel.Three) return AccountStatusHeadline.Business + if (level >= AccountLevel.One) return AccountStatusHeadline.Verified + return AccountStatusHeadline.Trial +} + +// Read-model derivation for existing accounts. The stored level is the +// current source of truth for verified/business (accounts were only ever +// levelled up through flows that satisfied those requirements), so legacy +// accounts are grandfathered: +// verified — stored level ≥ 1 +// business — stored level ≥ 3 +// bankPayout — an approved bank account on file (ERPNext); legacy L2/L3 +// accounts imply one even if the lookup is unavailable +// usdAccount — Bridge KYC approved +export const deriveCapabilitiesForAccount = ({ + level, + hasBankAccountOnFile, + bridgeKycStatus, +}: { + level: AccountLevel + hasBankAccountOnFile: boolean + bridgeKycStatus?: string +}): AccountCapabilities => ({ + verified: level >= AccountLevel.One, + bankPayout: hasBankAccountOnFile || level >= AccountLevel.Two, + business: level >= AccountLevel.Three, + usdAccount: bridgeKycStatus === "approved", +}) diff --git a/src/domain/accounts/index.ts b/src/domain/accounts/index.ts index 125784a51..3045753ca 100644 --- a/src/domain/accounts/index.ts +++ b/src/domain/accounts/index.ts @@ -18,6 +18,7 @@ export * from "./errors" export * from "./limits-checker" export * from "./account-validator" export * from "./primitives" +export * from "./capabilities" const UUIDV4 = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i diff --git a/src/domain/accounts/index.types.d.ts b/src/domain/accounts/index.types.d.ts index 8cadb3b5b..50ca8c586 100644 --- a/src/domain/accounts/index.types.d.ts +++ b/src/domain/accounts/index.types.d.ts @@ -9,6 +9,19 @@ type AccountLevel = type AccountStatus = (typeof import("./index").AccountStatus)[keyof typeof import("./index").AccountStatus] +type AccountStatusHeadline = + (typeof import("./index").AccountStatusHeadline)[keyof typeof import("./index").AccountStatusHeadline] + +type RequestableCapability = + (typeof import("./index").RequestableCapability)[keyof typeof import("./index").RequestableCapability] + +type AccountCapabilities = { + readonly verified: boolean + readonly bankPayout: boolean + readonly business: boolean + readonly usdAccount: boolean +} + type AccountLimitsRange = (typeof import("./index").AccountLimitsRange)[keyof typeof import("./index").AccountLimitsRange] diff --git a/src/domain/accounts/primitives.ts b/src/domain/accounts/primitives.ts index c31cf2c89..0b4cb4b09 100644 --- a/src/domain/accounts/primitives.ts +++ b/src/domain/accounts/primitives.ts @@ -3,8 +3,12 @@ export const AccountLevel = { One: 1, Two: 2, Three: 3, - // Semantic aliases + // Semantic aliases. "Business" is the current name (ENG-516); "Pro" and + // "Merchant" are retired nomenclature kept only for existing call sites. + Business: 3, + /** @deprecated retired nomenclature (ENG-516) — L2 is bank-payout-capable, no tier name */ Pro: 2, + /** @deprecated renamed to Business (ENG-516) */ Merchant: 3, } as const diff --git a/src/graphql/admin/schema.graphql b/src/graphql/admin/schema.graphql index fb9d55404..dabac6868 100644 --- a/src/graphql/admin/schema.graphql +++ b/src/graphql/admin/schema.graphql @@ -1,3 +1,19 @@ +type AccountCapabilities { + """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! +} + type AccountDetailPayload { accountDetails: AuditedAccount errors: [Error] @@ -18,6 +34,12 @@ enum AccountStatus { PENDING } +enum AccountStatusHeadline { + BUSINESS + TRIAL + VERIFIED +} + input AccountUpdateLevelInput { erpParty: String level: AccountLevel! @@ -34,6 +56,8 @@ input AccountUpdateStatusInput { Accounts are core to the Galoy architecture. they have users, and own wallets """ type AuditedAccount { + capabilities: AccountCapabilities! + """ GPS coordinates for the account that can be used to place the related business on a map """ @@ -46,6 +70,7 @@ type AuditedAccount { npub: String owner: AuditedUser! status: AccountStatus! + statusHeadline: AccountStatusHeadline! title: String username: Username uuid: String! diff --git a/src/graphql/admin/types/object/account.ts b/src/graphql/admin/types/object/account.ts index cc94b3fe0..48a9e9e39 100644 --- a/src/graphql/admin/types/object/account.ts +++ b/src/graphql/admin/types/object/account.ts @@ -1,4 +1,4 @@ -import { Wallets, Users, Merchants } from "@app" +import { Accounts, Wallets, Users, Merchants } from "@app" import { GT } from "@graphql/index" import Coordinates from "@graphql/shared/types/object/coordinates" import Timestamp from "@graphql/shared/types/scalar/timestamp" @@ -8,6 +8,8 @@ import Wallet from "@graphql/shared/types/abstract/wallet" import { mapError } from "@graphql/error-map" import AccountLevel from "@graphql/shared/types/scalar/account-level" +import AccountCapabilities from "@graphql/shared/types/object/account-capabilities" +import AccountStatusHeadline from "@graphql/shared/types/scalar/account-status-headline" import Merchant from "@graphql/shared/types/object/merchant" import AccountStatus from "../scalar/account-status" @@ -24,6 +26,20 @@ const Account: GraphQLObjectType = GT.Object({ username: { type: Username }, npub: { type: GT.String }, level: { type: GT.NonNull(AccountLevel) }, + capabilities: { + type: GT.NonNull(AccountCapabilities), + resolve: async (source) => { + const result = await Accounts.getAccountCapabilities(source) + return result.capabilities + }, + }, + statusHeadline: { + type: GT.NonNull(AccountStatusHeadline), + resolve: async (source) => { + const result = await Accounts.getAccountCapabilities(source) + return result.statusHeadline + }, + }, status: { type: GT.NonNull(AccountStatus) }, title: { type: GT.String }, wallets: { diff --git a/src/graphql/public/mutations.ts b/src/graphql/public/mutations.ts index b43216a57..1810877f1 100644 --- a/src/graphql/public/mutations.ts +++ b/src/graphql/public/mutations.ts @@ -27,6 +27,7 @@ import UserUpdateUsernameMutation from "@graphql/public/root/mutation/user-updat import AccountUpdateDefaultWalletIdMutation from "@graphql/public/root/mutation/account-update-default-wallet-id" import AccountUpdateDisplayCurrencyMutation from "@graphql/public/root/mutation/account-update-display-currency" import BusinessAccountUpgradeRequestMutation from "@graphql/public/root/mutation/business-account-upgrade-request" +import AccountCapabilityUpgradeRequestMutation from "@graphql/public/root/mutation/account-capability-upgrade-request" import UserContactUpdateAliasMutation from "@graphql/public/root/mutation/user-contact-update-alias" import UserQuizQuestionUpdateCompletedMutation from "@graphql/public/root/mutation/user-quiz-question-update-completed" import OnChainPaymentSendMutation from "@graphql/public/root/mutation/onchain-payment-send" @@ -117,6 +118,7 @@ export const mutationFields = { accountUpdateDefaultWalletId: AccountUpdateDefaultWalletIdMutation, accountUpdateDisplayCurrency: AccountUpdateDisplayCurrencyMutation, businessAccountUpgradeRequest: BusinessAccountUpgradeRequestMutation, + accountCapabilityUpgradeRequest: AccountCapabilityUpgradeRequestMutation, bankAccountUpdateRequest: BankAccountUpdateRequestMutation, accountEnableNotificationCategory: AccountEnableNotificationCategoryMutation, accountDisableNotificationCategory: AccountDisableNotificationCategoryMutation, diff --git a/src/graphql/public/root/mutation/account-capability-upgrade-request.ts b/src/graphql/public/root/mutation/account-capability-upgrade-request.ts new file mode 100644 index 000000000..37098469c --- /dev/null +++ b/src/graphql/public/root/mutation/account-capability-upgrade-request.ts @@ -0,0 +1,78 @@ +import { Accounts } from "@app" +import { RequestableCapability } from "@domain/accounts" +import { GT } from "@graphql/index" +import { apolloErrorResponse, mapToGqlErrorList } from "@graphql/error-map" +import IError from "@graphql/shared/types/abstract/error" +import { SetDocTypeValueError } from "@services/frappe/errors" +import { InternalServerError } from "@graphql/error" + +import { + AddressInput, + BankAccountInput, + parseBankAccountInput, +} from "./business-account-upgrade-request" + +// ENG-516: clients request one capability instead of picking a whole tier. +// The target internal level is derived server-side by the capability state +// machine; Pro/International/Merchant nomenclature is retired. +const AccountCapability = GT.Enum({ + name: "AccountCapability", + values: { + BANK_PAYOUT: { value: RequestableCapability.BankPayout }, + BUSINESS: { value: RequestableCapability.Business }, + }, +}) + +const AccountCapabilityUpgradeRequestInput = GT.Input({ + name: "AccountCapabilityUpgradeRequestInput", + fields: () => ({ + capability: { type: GT.NonNull(AccountCapability) }, + fullName: { type: GT.NonNull(GT.String) }, + address: { type: GT.NonNull(AddressInput) }, + terminalsRequested: { type: GT.Int, defaultValue: 0 }, + bankAccount: { type: BankAccountInput }, + idDocument: { type: GT.String }, + }), +}) + +const Response = GT.Object({ + name: "AccountCapabilityUpgradeRequestPayload", + fields: () => ({ + errors: { + type: GT.List(IError), + }, + id: { + type: GT.String, + }, + status: { + type: GT.String, + }, + }), +}) + +const AccountCapabilityUpgradeRequestMutation = GT.Field({ + extensions: { + complexity: 150, + }, + type: GT.NonNull(Response), + args: { + input: { type: GT.NonNull(AccountCapabilityUpgradeRequestInput) }, + }, + resolve: async (_, args, { domainAccount }: { domainAccount: Account }) => { + const { bankAccount, ...rest } = args.input + const result = await Accounts.requestCapabilityUpgrade(domainAccount.id, { + ...rest, + bankAccount: bankAccount ? parseBankAccountInput(bankAccount) : undefined, + }) + if (result instanceof SetDocTypeValueError) + return apolloErrorResponse( + new InternalServerError({ + message: "Pending upgrade request(s) failed to update.", + }), + ) + if (result instanceof Error) return { errors: mapToGqlErrorList(result) } + else return result + }, +}) + +export default AccountCapabilityUpgradeRequestMutation diff --git a/src/graphql/public/root/mutation/business-account-upgrade-request.ts b/src/graphql/public/root/mutation/business-account-upgrade-request.ts index dcd766b61..b4b80d05f 100644 --- a/src/graphql/public/root/mutation/business-account-upgrade-request.ts +++ b/src/graphql/public/root/mutation/business-account-upgrade-request.ts @@ -7,7 +7,7 @@ import IError from "@graphql/shared/types/abstract/error" import { SetDocTypeValueError } from "@services/frappe/errors" import { InternalServerError } from "@graphql/error" -const BankAccountInput = GT.Input({ +export const BankAccountInput = GT.Input({ name: "BankAccountInput", fields: () => ({ bankName: { type: GT.NonNull(GT.String) }, @@ -25,7 +25,7 @@ type BankAccountInputType = { currency: string accountNumber: string } -const parseBankAccountInput = ({ +export const parseBankAccountInput = ({ bankName, bankBranch, accountType, @@ -39,7 +39,7 @@ const parseBankAccountInput = ({ bank_account_no: accountNumber, }) -const AddressInput = GT.Input({ +export const AddressInput = GT.Input({ name: "AddressInput", fields: () => ({ title: { type: GT.NonNull(GT.String) }, diff --git a/src/graphql/public/schema.graphql b/src/graphql/public/schema.graphql index b25627017..5d74ff85b 100644 --- a/src/graphql/public/schema.graphql +++ b/src/graphql/public/schema.graphql @@ -25,6 +25,42 @@ interface Account { wallets: [Wallet!]! } +type AccountCapabilities { + """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 { + BANK_PAYOUT + BUSINESS +} + +input AccountCapabilityUpgradeRequestInput { + address: AddressInput! + bankAccount: BankAccountInput + capability: AccountCapability! + fullName: String! + idDocument: String + terminalsRequested: Int = 0 +} + +type AccountCapabilityUpgradeRequestPayload { + errors: [Error] + id: String + status: String +} + type AccountDeletePayload { errors: [Error!]! success: Boolean! @@ -84,6 +120,12 @@ type AccountLimits { """Bank account number. Accepts String or Int and coerces to String.""" scalar AccountNumber +enum AccountStatusHeadline { + BUSINESS + TRIAL + VERIFIED +} + input AccountUpdateDefaultWalletIdInput { walletId: WalletId! } @@ -667,6 +709,7 @@ type CentAmountPayload { type ConsumerAccount implements Account { callbackEndpoints: [CallbackEndpoint!]! + capabilities: AccountCapabilities! """ return CSV stream, base64 encoded, of the list of transactions in the wallet @@ -675,6 +718,10 @@ 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! @@ -682,6 +729,7 @@ type ConsumerAccount implements Account { """List the quiz questions of the consumer account""" quiz: [Quiz!]! realtimePrice: RealtimePrice! + statusHeadline: AccountStatusHeadline! """ A list of all transactions associated with walletIds optionally passed. @@ -1210,6 +1258,7 @@ type MobileVersions { } type Mutation { + accountCapabilityUpgradeRequest(input: AccountCapabilityUpgradeRequestInput!): AccountCapabilityUpgradeRequestPayload! accountDelete: AccountDeletePayload! accountDisableNotificationCategory(input: AccountDisableNotificationCategoryInput!): AccountUpdateNotificationSettingsPayload! accountDisableNotificationChannel(input: AccountDisableNotificationChannelInput!): AccountUpdateNotificationSettingsPayload! diff --git a/src/graphql/public/types/object/consumer-account.ts b/src/graphql/public/types/object/consumer-account.ts index 483b00518..58a5b04d9 100644 --- a/src/graphql/public/types/object/consumer-account.ts +++ b/src/graphql/public/types/object/consumer-account.ts @@ -28,6 +28,8 @@ import DisplayCurrency from "@graphql/shared/types/scalar/display-currency" import { listEndpoints } from "@app/callback" import AccountLevel from "../../../shared/types/scalar/account-level" +import AccountCapabilities from "../../../shared/types/object/account-capabilities" +import AccountStatusHeadline from "../../../shared/types/scalar/account-status-headline" import { TransactionConnection } from "../../../shared/types/object/transaction" @@ -87,9 +89,27 @@ const ConsumerAccount = GT.Object({ level: { type: GT.NonNull(AccountLevel), + description: + "Internal account level, derived from capabilities (ENG-516). Present capabilities and statusHeadline instead of this.", resolve: (source) => source.level, }, + capabilities: { + type: GT.NonNull(AccountCapabilities), + resolve: async (source) => { + const result = await Accounts.getAccountCapabilities(source) + return result.capabilities + }, + }, + + statusHeadline: { + type: GT.NonNull(AccountStatusHeadline), + resolve: async (source) => { + const result = await Accounts.getAccountCapabilities(source) + return result.statusHeadline + }, + }, + realtimePrice: { type: GT.NonNull(RealtimePrice), resolve: async (source) => { diff --git a/src/graphql/shared/types/object/account-capabilities.ts b/src/graphql/shared/types/object/account-capabilities.ts new file mode 100644 index 000000000..7213ddb18 --- /dev/null +++ b/src/graphql/shared/types/object/account-capabilities.ts @@ -0,0 +1,28 @@ +import { GT } from "@graphql/index" + +// ENG-516: capability flags replace tier nomenclature (Pro/International are +// retired). Levels stay internal; clients present these flags as badges. +const AccountCapabilities = GT.Object({ + name: "AccountCapabilities", + fields: () => ({ + verified: { + type: GT.NonNull(GT.Boolean), + description: "Phone and identity verified.", + }, + bankPayout: { + type: GT.NonNull(GT.Boolean), + description: "An approved bank account is on file for payouts.", + }, + business: { + type: GT.NonNull(GT.Boolean), + description: "Business profile (name and address) on file.", + }, + usdAccount: { + type: GT.NonNull(GT.Boolean), + description: + "USD account and routing number available (Bridge KYC approved). Orthogonal to the other capabilities.", + }, + }), +}) + +export default AccountCapabilities diff --git a/src/graphql/shared/types/scalar/account-status-headline.ts b/src/graphql/shared/types/scalar/account-status-headline.ts new file mode 100644 index 000000000..d677bf93b --- /dev/null +++ b/src/graphql/shared/types/scalar/account-status-headline.ts @@ -0,0 +1,14 @@ +import { GT } from "@graphql/index" + +// ENG-516 "light headline status": the account leads with one word — +// Trial → Verified → Business — with capability badges as supporting detail. +const AccountStatusHeadline = GT.Enum({ + name: "AccountStatusHeadline", + values: { + TRIAL: { value: "TRIAL" }, + VERIFIED: { value: "VERIFIED" }, + BUSINESS: { value: "BUSINESS" }, + }, +}) + +export default AccountStatusHeadline diff --git a/test/flash/unit/domain/accounts/capabilities.spec.ts b/test/flash/unit/domain/accounts/capabilities.spec.ts new file mode 100644 index 000000000..597503faf --- /dev/null +++ b/test/flash/unit/domain/accounts/capabilities.spec.ts @@ -0,0 +1,167 @@ +import { + AccountLevel, + AccountStatusHeadline, + deriveCapabilitiesForAccount, + deriveLevelFromCapabilities, + deriveStatusHeadline, +} from "@domain/accounts" + +const caps = (overrides: Partial): AccountCapabilities => ({ + verified: false, + bankPayout: false, + business: false, + usdAccount: false, + ...overrides, +}) + +describe("deriveLevelFromCapabilities", () => { + it("gives L0 when not verified, regardless of other flags", () => { + expect(deriveLevelFromCapabilities(caps({}))).toEqual(AccountLevel.Zero) + expect( + deriveLevelFromCapabilities(caps({ bankPayout: true, business: true })), + ).toEqual(AccountLevel.Zero) + }) + + it("gives L1 for verified only", () => { + expect(deriveLevelFromCapabilities(caps({ verified: true }))).toEqual( + AccountLevel.One, + ) + }) + + it("gives L2 for verified + bank payout without business (business-less Pro)", () => { + expect( + deriveLevelFromCapabilities(caps({ verified: true, bankPayout: true })), + ).toEqual(AccountLevel.Two) + }) + + it("gives L3 for verified + business + bank payout", () => { + expect( + deriveLevelFromCapabilities( + caps({ verified: true, bankPayout: true, business: true }), + ), + ).toEqual(AccountLevel.Three) + }) + + it("does not reach L3 for business without a bank account", () => { + expect(deriveLevelFromCapabilities(caps({ verified: true, business: true }))).toEqual( + AccountLevel.One, + ) + }) + + it("ignores usdAccount — Bridge is orthogonal to the level", () => { + expect( + deriveLevelFromCapabilities(caps({ verified: true, usdAccount: true })), + ).toEqual(AccountLevel.One) + expect( + deriveLevelFromCapabilities( + caps({ verified: true, bankPayout: true, usdAccount: true }), + ), + ).toEqual(AccountLevel.Two) + }) +}) + +describe("deriveStatusHeadline (light headline status)", () => { + it("reads Trial when not verified", () => { + expect(deriveStatusHeadline(caps({}))).toEqual(AccountStatusHeadline.Trial) + }) + + it("reads Verified for L1 and L2 — bank payout is a badge, not a tier", () => { + expect(deriveStatusHeadline(caps({ verified: true }))).toEqual( + AccountStatusHeadline.Verified, + ) + expect(deriveStatusHeadline(caps({ verified: true, bankPayout: true }))).toEqual( + AccountStatusHeadline.Verified, + ) + }) + + it("reads Business only for a complete business setup", () => { + expect( + deriveStatusHeadline(caps({ verified: true, bankPayout: true, business: true })), + ).toEqual(AccountStatusHeadline.Business) + expect(deriveStatusHeadline(caps({ verified: true, business: true }))).toEqual( + AccountStatusHeadline.Verified, + ) + }) + + it("is unaffected by usdAccount", () => { + expect(deriveStatusHeadline(caps({ verified: true, usdAccount: true }))).toEqual( + AccountStatusHeadline.Verified, + ) + }) +}) + +describe("deriveCapabilitiesForAccount (read model)", () => { + it("maps a fresh L0 account to no capabilities", () => { + expect( + deriveCapabilitiesForAccount({ + level: AccountLevel.Zero, + hasBankAccountOnFile: false, + }), + ).toEqual(caps({})) + }) + + it("maps stored levels to grandfathered flags", () => { + expect( + deriveCapabilitiesForAccount({ + level: AccountLevel.One, + hasBankAccountOnFile: false, + }), + ).toEqual(caps({ verified: true })) + + expect( + deriveCapabilitiesForAccount({ + level: AccountLevel.Two, + hasBankAccountOnFile: false, + }), + ).toEqual(caps({ verified: true, bankPayout: true })) + + expect( + deriveCapabilitiesForAccount({ + level: AccountLevel.Three, + hasBankAccountOnFile: true, + }), + ).toEqual(caps({ verified: true, bankPayout: true, business: true })) + }) + + it("flags bankPayout from an actual bank account on file at L1", () => { + expect( + deriveCapabilitiesForAccount({ + level: AccountLevel.One, + hasBankAccountOnFile: true, + }), + ).toEqual(caps({ verified: true, bankPayout: true })) + }) + + it("flags usdAccount only for approved Bridge KYC", () => { + expect( + deriveCapabilitiesForAccount({ + level: AccountLevel.One, + hasBankAccountOnFile: false, + bridgeKycStatus: "approved", + }), + ).toEqual(caps({ verified: true, usdAccount: true })) + + expect( + deriveCapabilitiesForAccount({ + level: AccountLevel.One, + hasBankAccountOnFile: false, + bridgeKycStatus: "under_review", + }), + ).toEqual(caps({ verified: true })) + }) + + it("round-trips: derived level matches stored level for consistent accounts", () => { + for (const level of [ + AccountLevel.Zero, + AccountLevel.One, + AccountLevel.Two, + AccountLevel.Three, + ]) { + const derived = deriveCapabilitiesForAccount({ + level, + hasBankAccountOnFile: level >= AccountLevel.Two, + }) + expect(deriveLevelFromCapabilities(derived)).toEqual(level) + } + }) +}) From 783f53dd783b6e8f8222bf01c5cdc0ca7b23fbe8 Mon Sep 17 00:00:00 2001 From: Dread Date: Wed, 15 Jul 2026 22:39:44 -0700 Subject: [PATCH 2/3] fix(accounts): scope-map registration, verified gate, honest bankAccount typing (ENG-516) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/account-capabilities.md | 8 ++ .../business-account-upgrade-request.ts | 4 +- .../accounts/request-capability-upgrade.ts | 17 ++- src/domain/api-keys/scope-map.ts | 1 + .../request-capability-upgrade.spec.ts | 130 ++++++++++++++++++ 5 files changed, 156 insertions(+), 4 deletions(-) create mode 100644 test/flash/unit/app/accounts/request-capability-upgrade.spec.ts diff --git a/docs/account-capabilities.md b/docs/account-capabilities.md index 504d45b2e..b6f096bf6 100644 --- a/docs/account-capabilities.md +++ b/docs/account-capabilities.md @@ -50,6 +50,14 @@ Implementation: `src/domain/accounts/capabilities.ts` 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): diff --git a/src/app/accounts/business-account-upgrade-request.ts b/src/app/accounts/business-account-upgrade-request.ts index b34b05b14..f1a81b3f0 100644 --- a/src/app/accounts/business-account-upgrade-request.ts +++ b/src/app/accounts/business-account-upgrade-request.ts @@ -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 } diff --git a/src/app/accounts/request-capability-upgrade.ts b/src/app/accounts/request-capability-upgrade.ts index cd182a34e..f30e4f51d 100644 --- a/src/app/accounts/request-capability-upgrade.ts +++ b/src/app/accounts/request-capability-upgrade.ts @@ -34,6 +34,17 @@ export const requestCapabilityUpgrade = async ( const { capabilities } = await getAccountCapabilities(account) + // verified (phone + ID) is a prerequisite for every capability upgrade. + // deriveLevelFromCapabilities returns L0 for an unverified account, so a + // request from one would otherwise be turned into a structurally-invalid, + // level-skipping L2 ERPNext request. verified is granted by the identity + // flow, not requestable here. + if (!capabilities.verified) { + return new ValidationError( + "Account must complete identity verification before requesting an upgrade", + ) + } + if (capabilities[input.capability]) { return new ValidationError(`Account already has the ${input.capability} capability`) } @@ -73,9 +84,9 @@ export const requestCapabilityUpgrade = async ( return createUpgradeRequest(accountId, { ...base, level: AccountLevel.Business, - // When omitted, the bank account is already on file in ERPNext - // (capabilities.bankPayout was checked above). - bankAccount: input.bankAccount as BankAccount, + // May be undefined: the bank account is already on file in ERPNext + // (capabilities.bankPayout was checked above), so none is re-submitted. + bankAccount: input.bankAccount, }) } diff --git a/src/domain/api-keys/scope-map.ts b/src/domain/api-keys/scope-map.ts index a5649721a..e806878f4 100644 --- a/src/domain/api-keys/scope-map.ts +++ b/src/domain/api-keys/scope-map.ts @@ -40,6 +40,7 @@ export const apiKeyScopeForField: Readonly> = quizCompleted: "BLOCKED", deviceNotificationTokenCreate: "BLOCKED", businessAccountUpgradeRequest: "BLOCKED", + accountCapabilityUpgradeRequest: "BLOCKED", bankAccountUpdateRequest: "BLOCKED", accountDelete: "BLOCKED", feedbackSubmit: "BLOCKED", diff --git a/test/flash/unit/app/accounts/request-capability-upgrade.spec.ts b/test/flash/unit/app/accounts/request-capability-upgrade.spec.ts new file mode 100644 index 000000000..d3f610a28 --- /dev/null +++ b/test/flash/unit/app/accounts/request-capability-upgrade.spec.ts @@ -0,0 +1,130 @@ +jest.mock("@services/mongoose", () => { + const findById = jest.fn() + return { AccountsRepository: () => ({ findById }) } +}) + +jest.mock("@app/accounts/get-account-capabilities", () => ({ + getAccountCapabilities: jest.fn(), +})) + +jest.mock("@app/accounts/business-account-upgrade-request", () => ({ + createUpgradeRequest: jest.fn(), +})) + +import { AccountsRepository } from "@services/mongoose" +import { getAccountCapabilities } from "@app/accounts/get-account-capabilities" +import { createUpgradeRequest } from "@app/accounts/business-account-upgrade-request" +import { requestCapabilityUpgrade } from "@app/accounts/request-capability-upgrade" +import { AccountLevel, RequestableCapability } from "@domain/accounts" +import { ValidationError } from "@domain/shared" + +import type { BankAccount } from "@services/frappe/models/BankAccount" + +const { findById } = AccountsRepository() as unknown as { findById: jest.Mock } +const getCaps = getAccountCapabilities as unknown as jest.Mock +const createReq = createUpgradeRequest as unknown as jest.Mock + +const ACCOUNT_ID = "acct-1" as AccountId +const account = { id: ACCOUNT_ID, level: AccountLevel.One } as unknown as Account + +const bankAccount = { + bank: "NCB", + branch_code: "Half Way Tree", + account_type: "Savings", + currency: "JMD", + bank_account_no: "123456789", +} as unknown as BankAccount + +const baseInput = { + fullName: "Test User", + address: { + title: "Home", + line1: "1 Main St", + city: "Kingston", + state: "St Andrew", + country: "Jamaica", + }, + terminalsRequested: 0, +} + +const caps = (overrides: Partial = {}): AccountCapabilities => ({ + verified: true, + bankPayout: false, + business: false, + usdAccount: false, + ...overrides, +}) + +describe("requestCapabilityUpgrade", () => { + beforeEach(() => { + jest.clearAllMocks() + findById.mockResolvedValue(account) + getCaps.mockResolvedValue({ capabilities: caps() }) + createReq.mockResolvedValue({ id: "REQ-1", status: "Pending" }) + }) + + it("rejects an unverified account before creating any request", async () => { + getCaps.mockResolvedValue({ capabilities: caps({ verified: false }) }) + + const result = await requestCapabilityUpgrade(ACCOUNT_ID, { + ...baseInput, + capability: RequestableCapability.BankPayout, + bankAccount, + }) + + expect(result).toBeInstanceOf(ValidationError) + expect(createReq).not.toHaveBeenCalled() + }) + + it("rejects a capability the account already has", async () => { + getCaps.mockResolvedValue({ capabilities: caps({ bankPayout: true }) }) + + const result = await requestCapabilityUpgrade(ACCOUNT_ID, { + ...baseInput, + capability: RequestableCapability.BankPayout, + bankAccount, + }) + + expect(result).toBeInstanceOf(ValidationError) + expect(createReq).not.toHaveBeenCalled() + }) + + it("requires bank account details for a bank-payout request", async () => { + const result = await requestCapabilityUpgrade(ACCOUNT_ID, { + ...baseInput, + capability: RequestableCapability.BankPayout, + }) + + expect(result).toBeInstanceOf(ValidationError) + expect(createReq).not.toHaveBeenCalled() + }) + + it("creates an L2 request for a verified account adding bank payout", async () => { + await requestCapabilityUpgrade(ACCOUNT_ID, { + ...baseInput, + capability: RequestableCapability.BankPayout, + bankAccount, + }) + + expect(createReq).toHaveBeenCalledTimes(1) + expect(createReq).toHaveBeenCalledWith( + ACCOUNT_ID, + expect.objectContaining({ level: AccountLevel.Two, bankAccount }), + ) + }) + + it("creates an L3 business request reusing a bank account already on file", async () => { + getCaps.mockResolvedValue({ capabilities: caps({ bankPayout: true }) }) + + await requestCapabilityUpgrade(ACCOUNT_ID, { + ...baseInput, + capability: RequestableCapability.Business, + }) + + expect(createReq).toHaveBeenCalledTimes(1) + expect(createReq).toHaveBeenCalledWith( + ACCOUNT_ID, + expect.objectContaining({ level: AccountLevel.Business, bankAccount: undefined }), + ) + }) +}) From 37fc0965bed71fedaed7ec44ad2586922671caa6 Mon Sep 17 00:00:00 2001 From: Dread Date: Thu, 16 Jul 2026 14:53:53 -0700 Subject: [PATCH 3/3] perf(accounts): one capability derivation per account + typed KYC literal (ENG-516) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> via ReturnType of an async fn; unwrapped. tsc clean; full unit suite 135 suites / 1064 passed. Co-Authored-By: Claude Fable 5 --- src/app/accounts/get-account-capabilities.ts | 19 +++- .../accounts/request-capability-upgrade.ts | 2 +- src/domain/accounts/capabilities.ts | 9 +- .../accounts/get-account-capabilities.spec.ts | 93 +++++++++++++++++++ 4 files changed, 119 insertions(+), 4 deletions(-) create mode 100644 test/flash/unit/app/accounts/get-account-capabilities.spec.ts diff --git a/src/app/accounts/get-account-capabilities.ts b/src/app/accounts/get-account-capabilities.ts index 46538fb8b..07763416a 100644 --- a/src/app/accounts/get-account-capabilities.ts +++ b/src/app/accounts/get-account-capabilities.ts @@ -12,12 +12,29 @@ export type AccountCapabilityPresentation = { 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>() + // 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 = async ( +export const getAccountCapabilities = ( + account: Account, +): Promise => { + 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 => { let hasBankAccountOnFile = false diff --git a/src/app/accounts/request-capability-upgrade.ts b/src/app/accounts/request-capability-upgrade.ts index f30e4f51d..4880a776b 100644 --- a/src/app/accounts/request-capability-upgrade.ts +++ b/src/app/accounts/request-capability-upgrade.ts @@ -28,7 +28,7 @@ export type CapabilityUpgradeRequest = { export const requestCapabilityUpgrade = async ( accountId: AccountId, input: CapabilityUpgradeRequest, -): Promise> => { +): ReturnType => { const account = await AccountsRepository().findById(accountId) if (account instanceof Error) return account diff --git a/src/domain/accounts/capabilities.ts b/src/domain/accounts/capabilities.ts index b2f9cfeac..2f8c952e3 100644 --- a/src/domain/accounts/capabilities.ts +++ b/src/domain/accounts/capabilities.ts @@ -25,6 +25,11 @@ export const RequestableCapability = { Business: "business", } as const +// The one Bridge KYC status that grants the usdAccount capability. Typed +// against the Account union so a status rename upstream is a compile error +// here rather than a silently-false capability. +export const BRIDGE_KYC_APPROVED: NonNullable = "approved" + // Derive the internal account level from capability flags. // // verified (phone + ID) → L1 @@ -70,10 +75,10 @@ export const deriveCapabilitiesForAccount = ({ }: { level: AccountLevel hasBankAccountOnFile: boolean - bridgeKycStatus?: string + bridgeKycStatus?: Account["bridgeKycStatus"] }): AccountCapabilities => ({ verified: level >= AccountLevel.One, bankPayout: hasBankAccountOnFile || level >= AccountLevel.Two, business: level >= AccountLevel.Three, - usdAccount: bridgeKycStatus === "approved", + usdAccount: bridgeKycStatus === BRIDGE_KYC_APPROVED, }) diff --git a/test/flash/unit/app/accounts/get-account-capabilities.spec.ts b/test/flash/unit/app/accounts/get-account-capabilities.spec.ts new file mode 100644 index 000000000..524de2c91 --- /dev/null +++ b/test/flash/unit/app/accounts/get-account-capabilities.spec.ts @@ -0,0 +1,93 @@ +jest.mock("@services/frappe/ErpNext", () => ({ + __esModule: true, + default: { getBankAccountsByCustomer: jest.fn() }, +})) + +jest.mock("@services/logger", () => ({ + baseLogger: { warn: jest.fn() }, +})) + +import ErpNext from "@services/frappe/ErpNext" + +import { getAccountCapabilities } from "@app/accounts/get-account-capabilities" +import { AccountLevel } from "@domain/accounts" + +const getBankAccounts = ErpNext?.getBankAccountsByCustomer as jest.Mock + +const account = (overrides: Partial = {}): Account => + ({ + id: "acct-1" as AccountId, + level: AccountLevel.One, + erpParty: "CUST-1", + ...overrides, + }) as unknown as Account + +describe("getAccountCapabilities", () => { + beforeEach(() => { + jest.clearAllMocks() + getBankAccounts.mockResolvedValue([]) + }) + + it("flags bankPayout from an ERPNext bank account on file", async () => { + getBankAccounts.mockResolvedValue([{ name: "BANK-ACC-1" }]) + + const { capabilities } = await getAccountCapabilities(account()) + + expect(getBankAccounts).toHaveBeenCalledWith("CUST-1") + expect(capabilities.bankPayout).toBe(true) + }) + + it("falls back to the stored level when the ERPNext lookup fails", async () => { + getBankAccounts.mockResolvedValue(new Error("ERPNext unreachable")) + + const l2 = await getAccountCapabilities(account({ level: AccountLevel.Two })) + expect(l2.capabilities.bankPayout).toBe(true) + + const l1 = await getAccountCapabilities(account({ level: AccountLevel.One })) + expect(l1.capabilities.bankPayout).toBe(false) + }) + + it("skips the ERPNext lookup for accounts without an ERP party", async () => { + const { capabilities } = await getAccountCapabilities( + account({ erpParty: undefined, level: AccountLevel.Two }), + ) + + expect(getBankAccounts).not.toHaveBeenCalled() + // Grandfathered from the stored level. + expect(capabilities.bankPayout).toBe(true) + }) + + it("flags usdAccount from approved Bridge KYC", async () => { + const { capabilities } = await getAccountCapabilities( + account({ bridgeKycStatus: "approved" }), + ) + expect(capabilities.usdAccount).toBe(true) + }) + + it("derives the headline from the same capabilities", async () => { + const { capabilities, statusHeadline } = await getAccountCapabilities(account()) + expect(capabilities.verified).toBe(true) + expect(statusHeadline).toBe("VERIFIED") + }) + + it("memoizes per account object — one ERPNext lookup for many field resolutions", async () => { + const source = account() + + const [a, b] = await Promise.all([ + getAccountCapabilities(source), + getAccountCapabilities(source), + ]) + const c = await getAccountCapabilities(source) + + expect(getBankAccounts).toHaveBeenCalledTimes(1) + expect(b).toEqual(a) + expect(c).toEqual(a) + }) + + it("does not share the memo across distinct account objects", async () => { + await getAccountCapabilities(account()) + await getAccountCapabilities(account()) + + expect(getBankAccounts).toHaveBeenCalledTimes(2) + }) +})