Skip to content
76 changes: 75 additions & 1 deletion dev/apollo-federation/supergraph.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,11 @@ type BankAccount
"""ERPNext bank account identifier"""
id: ID
isDefault: Boolean!

"""
The account's in-flight update request when it needs the user's attention — Pending (awaiting review) or Rejected (declined). Null once approved/closed, or when none exists.
"""
pendingUpdate: BankAccountUpdateRequest
}

input BankAccountInput
Expand All @@ -396,6 +401,58 @@ input BankAccountInput
currency: String!
}

"""
A pending request to change the details of an approved bank account, awaiting admin review.
"""
type BankAccountUpdateRequest
@join__type(graph: PUBLIC)
{
"""Proposed new account number"""
accountNumber: String!

"""Proposed new account type"""
accountType: String!

"""Proposed new bank branch"""
bankBranch: String!

"""Proposed new bank name"""
bankName: String!

"""Account currency (unchanged from the current account)"""
currency: String!

"""Reviewer note, set when status is Rejected"""
rejectionReason: String

"""Pending | Approved | Rejected | Closed"""
status: String!
}

input BankAccountUpdateRequestInput
@join__type(graph: PUBLIC)
{
accountNumber: AccountNumber!
accountType: String!

"""ERPNext identifier of the account to update"""
bankAccountId: ID!
bankBranch: String!
bankName: String!

"""Must match the account's current currency (currency is locked)"""
currency: String!
}

type BankAccountUpdateRequestPayload
@join__type(graph: PUBLIC)
{
errors: [Error]

"""Status of the created request (Pending on success)"""
status: String
}

type BridgeAddExternalAccountPayload
@join__type(graph: PUBLIC)
{
Expand Down Expand Up @@ -458,6 +515,20 @@ type BridgeDeleteExternalAccountPayload
externalAccount: BridgeExternalAccount
}

input BridgeExchangePlaidPublicTokenInput
@join__type(graph: PUBLIC)
{
linkToken: String!
publicToken: String!
}

type BridgeExchangePlaidPublicTokenPayload
@join__type(graph: PUBLIC)
{
errors: [Error!]!
message: String
}

type BridgeExternalAccount
@join__type(graph: PUBLIC)
{
Expand All @@ -472,7 +543,8 @@ type BridgeExternalAccountLink
@join__type(graph: PUBLIC)
{
expiresAt: String!
linkUrl: String!
linkToken: String!
linkUrl: String @deprecated(reason: "Use linkToken with the Plaid Link SDK. Hosted-URL linking is being retired; this field is best-effort and may be null.")
}

input BridgeInitiateKycInput
Expand Down Expand Up @@ -1486,11 +1558,13 @@ type Mutation
Rotate an API key: a replacement with a new secret (and keyId) is created with the same name, scopes, and expiry, and the old key is revoked. The new raw key is only shown once.
"""
apiKeyRotate(input: ApiKeyRotateInput!): ApiKeyRotatePayload!
bankAccountUpdateRequest(input: BankAccountUpdateRequestInput!): BankAccountUpdateRequestPayload!
bridgeAddExternalAccount: BridgeAddExternalAccountPayload!
bridgeCancelWithdrawalRequest(input: BridgeCancelWithdrawalRequestInput!): BridgeCancelWithdrawalRequestPayload!
bridgeCreateExternalAccount(input: BridgeCreateExternalAccountInput!): BridgeCreateExternalAccountPayload!
bridgeCreateVirtualAccount: BridgeCreateVirtualAccountPayload!
bridgeDeleteExternalAccount(input: BridgeDeleteExternalAccountInput!): BridgeDeleteExternalAccountPayload!
bridgeExchangePlaidPublicToken(input: BridgeExchangePlaidPublicTokenInput!): BridgeExchangePlaidPublicTokenPayload!
bridgeInitiateKyc(input: BridgeInitiateKycInput!): BridgeInitiateKycPayload!
bridgeInitiateWithdrawal(input: BridgeInitiateWithdrawalInput!): BridgeInitiateWithdrawalPayload!
bridgeRequestWithdrawal(input: BridgeRequestWithdrawalInput!): BridgeRequestWithdrawalPayload!
Expand Down
33 changes: 29 additions & 4 deletions docs/bridge-integration/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ mutation BridgeCreateVirtualAccount {

### `bridgeAddExternalAccount`

Returns a hosted URL for the user to link their external bank account (via Plaid/Bridge).
Returns a Plaid `linkToken` so the app can open the Plaid Link SDK and connect a bank account via Bridge.

**Request:**
```graphql
Expand All @@ -70,16 +70,41 @@ mutation BridgeAddExternalAccount {
message
}
externalAccount {
linkUrl
linkToken
expiresAt
}
}
}
```

**Response:**
- `linkUrl`: URL to the bank linking flow.
- `expiresAt`: Expiration timestamp for the link.
- `linkToken`: Plaid Link token for the client SDK.
- `expiresAt`: Expiration timestamp for the token.
- `linkUrl` *(deprecated)*: Hosted bank-linking URL, served best-effort so already-shipped clients keep working; may be `null`. New clients must use `linkToken`.

---

### `bridgeExchangePlaidPublicToken`

Exchanges the Plaid `publicToken` (from Link `onSuccess`) with Bridge. Call from the app backend path only — Flash keeps the Bridge Api-Key server-side. External accounts are created asynchronously and arrive via webhook.

**Request:**
```graphql
mutation BridgeExchangePlaidPublicToken(
$input: BridgeExchangePlaidPublicTokenInput!
) {
bridgeExchangePlaidPublicToken(input: $input) {
errors {
message
}
message
}
}
```

**Input:**
- `linkToken`: Same token returned by `bridgeAddExternalAccount` (must be exchanged by the same account that requested it; tokens are one-time-use).
- `publicToken`: Token from Plaid Link `onSuccess`.

---

Expand Down
71 changes: 40 additions & 31 deletions docs/bridge-integration/FLOWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ This flow allows users to withdraw USDT from their Flash wallet to their externa

```ascii
User Flash App Flash Backend Bridge.xyz Bank
| (Check for KYC, if complete, skip to 12. | |
| (Check for KYC, if complete, skip to Link Bank.) | |
| 1. Start KYC | | | |
|----------------->| 2. bridgeInitKyc | | |
| |------------------>| 3. Create Customer | |
Expand All @@ -86,56 +86,65 @@ User Flash App Flash Backend Bridge.xyz
| | |<--------------------| |
| 8. Link Bank | | | |
|----------------->| 9. bridgeAddExtAcc| | |
| |------------------>| 10. Get Link URL | |
| |------------------>| 10. plaid_link_reqs | |
| | |-------------------->| |
| | 11. Link URL | | |
| | 11. linkToken | | |
| |<------------------| | |
| 12. Auth Bank | | | |
| 12. Plaid Link | | | |
| SDK / Auth | | | |
|----------------->| | | |
| (Plaid Flow) | | | |
| | | 13. ext_acc.verified| |
| | 13. bridgeExchange| | |
| | PlaidPublicTok| 14. Exchange public | |
| |------------------>| token | |
| | |-------------------->| |
| | 15. Exchange OK | | |
| |<------------------| | |
| | | 16. ext_acc created | |
| | | (async webhook) | |
| | |<--------------------| |
| 14. Withdraw | | | |
|----------------->| 15. bridgeRequest | | |
| 17. Withdraw | | | |
|----------------->| 18. bridgeRequest | | |
| | Withdrawal | | |
| |------------------>| 16. Store pending | |
| | 17. Confirm screen| withdrawal | |
| |------------------>| 19. Store pending | |
| | 20. Confirm screen| withdrawal | |
| |<------------------| | |
| 18. Confirm | | | |
|----------------->| 19. bridgeInitWith| | |
| | (withdrawalId)| 20. Create Transfer | |
| 21. Confirm | | | |
|----------------->| 22. bridgeInitWith| | |
| | (withdrawalId)| 23. Create Transfer | |
| |------------------>|-------------------->| |
| | 21. Pending | | |
| | 24. Pending | | |
|<-----------------| | | |
| | | | 22. Convert USDT |
| | | | 23. Send ACH |
| | | | 25. Convert USDT |
| | | | 26. Send ACH |
| | | |------------------>|
| | | 24. trans.completed | |
| | | 27. trans.completed | |
| | |<--------------------| |
| 25. Funds Arrive | | | |
| 28. Funds Arrive | | | |
|<-------------------------------------------------------------------------------|
```

### Steps

1. **Link Bank**: User chooses to add a bank account.
2. **GraphQL Mutation**: App calls `bridgeAddExternalAccount`.
3. **Link URL**: Flash requests a hosted link URL from Bridge.
4. **Redirect**: App opens the Bridge/Plaid flow.
5. **Authentication**: User logs into their bank and selects an account.
6. **Verification Webhook**: Bridge notifies Flash when the external account is verified.
7. **Request Withdrawal**: User enters amount and selects the linked bank account.
8. **GraphQL Mutation**: App calls `bridgeRequestWithdrawal` with `amount` and `externalAccountId`.
9. **Validation**: Flash checks USDT balance, account level, and external account ownership/verification. A `pending` withdrawal record is stored in MongoDB. If an identical pending request already exists (same account, amount, and bank account), the existing record is reused.
10. **Confirmation Screen**: App fetches the pending withdrawal via `bridgeWithdrawalRequest(id)` and displays amount, bank account, and fees for user review.
11. **User Confirms or Cancels**:
3. **Link Token**: Flash requests a Plaid `link_token` from Bridge (`POST …/plaid_link_requests`), binds it to the caller’s account in Redis (TTL = `link_token_expires_at`), and returns `{ linkToken, expiresAt }`.
4. **Plaid Link SDK**: App opens Plaid Link with `linkToken` (not a hosted Bridge URL).
5. **Authentication**: User logs into their bank and selects an account; Plaid returns a `publicToken` via `onSuccess`.
6. **Exchange Mutation**: App calls `bridgeExchangePlaidPublicToken` with `linkToken` + `publicToken`. Flash verifies the token was issued to this account (and consumes it), then exchanges with Bridge server-side (Api-Key never leaves the backend).
7. **Verification Webhook**: Bridge creates External Accounts asynchronously and notifies Flash (`external_account` webhook). App may poll `bridgeExternalAccounts` until the bank appears as verified.
8. **Request Withdrawal**: User enters amount and selects the linked bank account.
9. **GraphQL Mutation**: App calls `bridgeRequestWithdrawal` with `amount` and `externalAccountId`.
10. **Validation**: Flash checks USDT balance, account level, and external account ownership/verification. A `pending` withdrawal record is stored in MongoDB. If an identical pending request already exists (same account, amount, and bank account), the existing record is reused.
11. **Confirmation Screen**: App fetches the pending withdrawal via `bridgeWithdrawalRequest(id)` and displays amount, bank account, and fees for user review.
12. **User Confirms or Cancels**:
- **Confirm**: App calls `bridgeInitiateWithdrawal` with `withdrawalId`. Flash re-checks balance, then creates a transfer in Bridge from the user's Ethereum USDT address to the external account.
- **Cancel**: App calls `bridgeCancelWithdrawalRequest` with `withdrawalId`. The pending record is marked `cancelled` and a push notification is sent.
12. **Pending State**: After initiation, app shows the withdrawal as "Pending".
13. **Conversion**: Bridge converts USDT from the user's balance to USD.
14. **ACH Transfer**: Bridge sends USD to the user's bank via ACH.
15. **Transfer Webhook**: Bridge sends `transfer.completed` (or failure) webhook to Flash.
16. **Completion**: User receives funds in their bank account (usually 1-3 business days).
13. **Pending State**: After initiation, app shows the withdrawal as "Pending".
14. **Conversion**: Bridge converts USDT from the user's balance to USD.
15. **ACH Transfer**: Bridge sends USD to the user's bank via ACH.
16. **Transfer Webhook**: Bridge sends `transfer.completed` (or failure) webhook to Flash.
17. **Completion**: User receives funds in their bank account (usually 1-3 business days).

## Fee Structure

Expand Down
1 change: 1 addition & 0 deletions src/domain/api-keys/scope-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export const apiKeyScopeForField: Readonly<Record<string, ApiKeyFieldAccess>> =
bridgeInitiateKyc: "BLOCKED",
bridgeCreateVirtualAccount: "BLOCKED",
bridgeAddExternalAccount: "BLOCKED",
bridgeExchangePlaidPublicToken: "BLOCKED",
bridgeCreateExternalAccount: "BLOCKED",
bridgeSetDefaultExternalAccount: "BLOCKED",
bridgeDeleteExternalAccount: "BLOCKED",
Expand Down
7 changes: 7 additions & 0 deletions src/graphql/error-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -662,6 +662,13 @@ export const mapError = (error: ApplicationError): CustomApolloError => {
message,
})

case "BridgeInvalidPlaidTokenError":
message = error.message || "linkToken and publicToken are required"
return bridgeGqlError({
code: "BRIDGE_INVALID_PLAID_TOKEN",
message,
})

case "BridgeError":
message = error.message || "Bridge API error"
return bridgeGqlError({ code: "BRIDGE_ERROR", message })
Expand Down
2 changes: 2 additions & 0 deletions src/graphql/public/mutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ import UpdateExternalWalletMutation from "./root/mutation/update-external-wallet
import BridgeInitiateKycMutation from "./root/mutation/bridge-initiate-kyc"
import BridgeCreateVirtualAccountMutation from "./root/mutation/bridge-create-virtual-account"
import BridgeAddExternalAccountMutation from "./root/mutation/bridge-add-external-account"
import BridgeExchangePlaidPublicTokenMutation from "./root/mutation/bridge-exchange-plaid-public-token"
import BridgeCreateExternalAccountMutation from "./root/mutation/bridge-create-external-account"
import BridgeSetDefaultExternalAccountMutation from "./root/mutation/bridge-set-default-external-account"
import BridgeDeleteExternalAccountMutation from "./root/mutation/bridge-delete-external-account"
Expand Down Expand Up @@ -133,6 +134,7 @@ export const mutationFields = {
bridgeInitiateKyc: BridgeInitiateKycMutation,
bridgeCreateVirtualAccount: BridgeCreateVirtualAccountMutation,
bridgeAddExternalAccount: BridgeAddExternalAccountMutation,
bridgeExchangePlaidPublicToken: BridgeExchangePlaidPublicTokenMutation,
bridgeCreateExternalAccount: BridgeCreateExternalAccountMutation,
bridgeSetDefaultExternalAccount: BridgeSetDefaultExternalAccountMutation,
bridgeDeleteExternalAccount: BridgeDeleteExternalAccountMutation,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { GT } from "@graphql/index"
import { mapAndParseErrorForGqlResponse } from "@graphql/error-map"
import IError from "@graphql/shared/types/abstract/error"
import BridgeExchangePlaidPublicTokenInput from "@graphql/public/types/input/bridge-exchange-plaid-public-token-input"
import { BridgeConfig } from "@config"
import BridgeService from "@services/bridge"
import { BridgeDisabledError, BridgeAccountLevelError } from "@services/bridge/errors"

const BridgeExchangePlaidPublicTokenPayload = GT.Object({
name: "BridgeExchangePlaidPublicTokenPayload",
fields: () => ({
errors: { type: GT.NonNullList(IError) },
message: { type: GT.String },
}),
})

const bridgeExchangePlaidPublicToken = GT.Field({
type: GT.NonNull(BridgeExchangePlaidPublicTokenPayload),
args: {
input: { type: GT.NonNull(BridgeExchangePlaidPublicTokenInput) },
},
resolve: async (
_,
{ input }: { input: { linkToken: string; publicToken: string } },
{ domainAccount }: GraphQLPublicContextAuth,
) => {
if (!BridgeConfig.enabled) {
return { errors: [mapAndParseErrorForGqlResponse(new BridgeDisabledError())] }
}

if (!domainAccount || domainAccount.level <= 0) {
return { errors: [mapAndParseErrorForGqlResponse(new BridgeAccountLevelError())] }
}

const result = await BridgeService.exchangePlaidPublicToken(
domainAccount.id,
input.linkToken,
input.publicToken,
)
if (result instanceof Error) {
return { errors: [mapAndParseErrorForGqlResponse(result)] }
}

return { message: result.message, errors: [] }
},
})

export default bridgeExchangePlaidPublicToken
14 changes: 13 additions & 1 deletion src/graphql/public/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,16 @@ type BridgeDeleteExternalAccountPayload {
externalAccount: BridgeExternalAccount
}

input BridgeExchangePlaidPublicTokenInput {
linkToken: String!
publicToken: String!
}

type BridgeExchangePlaidPublicTokenPayload {
errors: [Error!]!
message: String
}

type BridgeExternalAccount {
accountNumberLast4: String!
bankName: String!
Expand All @@ -460,7 +470,8 @@ type BridgeExternalAccount {

type BridgeExternalAccountLink {
expiresAt: String!
linkUrl: String!
linkToken: String!
linkUrl: String @deprecated(reason: "Use linkToken with the Plaid Link SDK. Hosted-URL linking is being retired; this field is best-effort and may be null.")
}

input BridgeInitiateKycInput {
Expand Down Expand Up @@ -1227,6 +1238,7 @@ type Mutation {
bridgeCreateExternalAccount(input: BridgeCreateExternalAccountInput!): BridgeCreateExternalAccountPayload!
bridgeCreateVirtualAccount: BridgeCreateVirtualAccountPayload!
bridgeDeleteExternalAccount(input: BridgeDeleteExternalAccountInput!): BridgeDeleteExternalAccountPayload!
bridgeExchangePlaidPublicToken(input: BridgeExchangePlaidPublicTokenInput!): BridgeExchangePlaidPublicTokenPayload!
bridgeInitiateKyc(input: BridgeInitiateKycInput!): BridgeInitiateKycPayload!
bridgeInitiateWithdrawal(input: BridgeInitiateWithdrawalInput!): BridgeInitiateWithdrawalPayload!
bridgeRequestWithdrawal(input: BridgeRequestWithdrawalInput!): BridgeRequestWithdrawalPayload!
Expand Down
Loading
Loading