Skip to content
Open
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
3 changes: 2 additions & 1 deletion dev/bruno/Flash GraphQL API/collection.bru
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ vars:pre-request {
protocol: http
domain: localhost
port: 4000
bridgeExternalAccountId: ext_plaid_a4a2b7e0175c
}

docs {
Expand All @@ -32,7 +33,7 @@ docs {
```
ERPNEXT_JWT_SECRET=<admin-api-secret> node -e "const c=require('crypto'),b=o=>Buffer.from(JSON.stringify(o)).toString('base64url'),h=b({alg:'HS256',typ:'JWT'}),p=b({userId:'admin',roles:['Accounts Manager']}),s=c.createHmac('sha256',process.env.ERPNEXT_JWT_SECRET).update(h+'.'+p).digest('base64url');console.log(h+'.'+p+'.'+s)"
```

# Extra Resources
If you use Postman, we have a collection you can import to test the API. Download it here: [galoy_graphql_main_api.postman_collection.json](https://github.com/GaloyMoney/galoy/tree/main/src/graphql/docs/galoy_graphql_main_api.postman_collection.json)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
meta {
name: bridgeAddExternalAccount
type: graphql
seq: 38
}

post {
url: {{flashGraphqlUrl}}
body: graphql
auth: inherit
}

headers {
Content-Type: application/json
}

body:graphql {
mutation BridgeAddExternalAccount {
bridgeAddExternalAccount {
errors {
message
code
}
externalAccount {
linkUrl
expiresAt
}
}
}
}

settings {
encodeUrl: false
timeout: 30000
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
meta {
name: bridgeInitiateWithdrawal
type: graphql
seq: 39
}

post {
url: {{flashGraphqlUrl}}
body: graphql
auth: inherit
}

headers {
Content-Type: application/json
}

body:graphql {
mutation BridgeInitiateWithdrawal($input: BridgeInitiateWithdrawalInput!) {
bridgeInitiateWithdrawal(input: $input) {
errors {
message
code
}
withdrawal {
id
amount
currency
status
createdAt
}
}
}
}

body:graphql:vars {
{
"input": {
"amount": "10.00",
"externalAccountId": "{{bridgeExternalAccountId}}"
}
}
}

settings {
encodeUrl: false
timeout: 30000
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
meta {
name: bridgeExternalAccounts
type: graphql
seq: 19
}

post {
url: {{flashGraphqlUrl}}
body: graphql
auth: inherit
}

headers {
Content-Type: application/json
}

body:graphql {
query BridgeExternalAccounts {
bridgeExternalAccounts {
id
bankName
accountNumberLast4
status
}
}
}

settings {
encodeUrl: false
timeout: 30000
}
32 changes: 32 additions & 0 deletions dev/bruno/Flash GraphQL API/token/queries/bridgeWithdrawals.bru
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
meta {
name: bridgeWithdrawals
type: graphql
seq: 20
}

post {
url: {{flashGraphqlUrl}}
body: graphql
auth: inherit
}

headers {
Content-Type: application/json
}

body:graphql {
query BridgeWithdrawals {
bridgeWithdrawals {
id
amount
currency
status
createdAt
}
}
}

settings {
encodeUrl: false
timeout: 30000
}
3 changes: 2 additions & 1 deletion src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -661,8 +661,9 @@ export const configSchema = {
kyc: { type: "string" },
deposit: { type: "string" },
transfer: { type: "string" },
external_account: { type: "string" },
},
required: ["kyc", "deposit", "transfer"],
required: ["kyc", "deposit", "transfer", "external_account"],
},
timestampSkewMs: { type: "integer" },
replaySecret: { type: "string" },
Expand Down
1 change: 1 addition & 0 deletions src/config/schema.types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ type BridgeWebhookPublicKeys = {
kyc: string
deposit: string
transfer: string
external_account: string
}

type BridgeWebhook = {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { GT } from "@graphql/index"
import { mapAndParseErrorForGqlResponse } from "@graphql/error-map"
import IError from "@graphql/shared/types/abstract/error"
import BridgeExternalAccount from "@graphql/public/types/object/bridge-external-account"
import BridgeExternalAccountLink from "@graphql/public/types/object/bridge-external-account-link"
import { BridgeConfig } from "@config"
import BridgeService from "@services/bridge"
import { BridgeDisabledError, BridgeAccountLevelError } from "@services/bridge/errors"
Expand All @@ -10,7 +10,7 @@ const BridgeAddExternalAccountPayload = GT.Object({
name: "BridgeAddExternalAccountPayload",
fields: () => ({
errors: { type: GT.NonNullList(IError) },
externalAccount: { type: BridgeExternalAccount },
externalAccount: { type: BridgeExternalAccountLink },
}),
})

Expand Down
11 changes: 11 additions & 0 deletions src/graphql/public/types/object/bridge-external-account-link.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { GT } from "@graphql/index"

const BridgeExternalAccountLink = GT.Object({
name: "BridgeExternalAccountLink",
fields: () => ({
linkUrl: { type: GT.NonNull(GT.String) },
expiresAt: { type: GT.NonNull(GT.String) },
}),
})

export default BridgeExternalAccountLink
2 changes: 1 addition & 1 deletion src/graphql/public/types/object/bridge-external-account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { GT } from "@graphql/index"
const BridgeExternalAccount = GT.Object({
name: "BridgeExternalAccount",
fields: () => ({
id: { type: GT.NonNullID },
id: { type: GT.NonNullID, resolve: (obj) => obj.bridgeExternalAccountId },
bankName: { type: GT.NonNull(GT.String) },
accountNumberLast4: { type: GT.NonNull(GT.String) },
status: { type: GT.NonNull(GT.String) },
Expand Down
2 changes: 2 additions & 0 deletions src/services/bridge/webhook-server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { verifyBridgeSignature } from "./middleware/verify-signature"
import { kycHandler } from "./routes/kyc"
import { depositHandler } from "./routes/deposit"
import { transferHandler } from "./routes/transfer"
import { externalAccountHandler } from "./routes/external-account"
import { replayAuthMiddleware, replayHandler } from "./routes/replay"

type RawBodyRequest = express.Request & { rawBody?: string }
Expand Down Expand Up @@ -43,6 +44,7 @@ export const startBridgeWebhookServer = () => {
app.post("/kyc", verifyBridgeSignature("kyc"), kycHandler)
app.post("/deposit", verifyBridgeSignature("deposit"), depositHandler)
app.post("/transfer", verifyBridgeSignature("transfer"), transferHandler)
app.post("/external-account", verifyBridgeSignature("external_account"), externalAccountHandler)
app.post("/internal/replay", replayAuthMiddleware, replayHandler)

if (!BridgeConfig.webhook.replaySecret && !process.env.BRIDGE_WEBHOOK_REPLAY_SECRET) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { baseLogger } from "@services/logger"

type RawBodyRequest = Request & { rawBody?: string }

export const verifyBridgeSignature = (publicKeyType: "kyc" | "deposit" | "transfer") => {
export const verifyBridgeSignature = (publicKeyType: "kyc" | "deposit" | "transfer" | "external_account") => {
return (req: Request, res: Response, next: NextFunction) => {
const signature = req.headers["x-webhook-signature"] as string

Expand Down
80 changes: 80 additions & 0 deletions src/services/bridge/webhook-server/routes/external-account.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/**
* Bridge External Account Webhook Handler
* Handles external_account.created and external_account.updated events from Bridge.xyz
*
* Fires after a user completes the Plaid bank-linking flow.
* Persists the linked account to MongoDB so it appears in bridgeExternalAccounts queries.
*
* Status mapping:
* active: true → "verified"
* active: false → "failed"
*/

import { Request, Response } from "express"
import { AccountsRepository } from "@services/mongoose/accounts"
import { LockService } from "@services/lock"
import { baseLogger } from "@services/logger"
import { toBridgeCustomerId } from "@domain/primitives/bridge"
import * as BridgeAccountsRepo from "@services/mongoose/bridge-accounts"

const toStatus = (active: boolean | undefined): "pending" | "verified" | "failed" => {
if (active === true) return "verified"
if (active === false) return "failed"
return "pending"
}

export const externalAccountHandler = async (req: Request, res: Response) => {
const { event_id, event_object } = req.body
const { id, customer_id, bank_name, last_4, active } = event_object ?? {}

if (!id || !customer_id || !event_id) {
return res.status(400).json({ error: "Invalid payload" })
}

try {
const bridgeCustomerId = toBridgeCustomerId(customer_id)
const account = await AccountsRepository().findByBridgeCustomerId(bridgeCustomerId)
if (account instanceof Error) {
baseLogger.warn(
{ customer_id, event_id },
"Account not found for Bridge customer — may be a timing issue, Bridge will retry",
)
return res.status(503).json({ error: "Account not ready" })
}

const lockKey = `bridge-external-account:${event_id}`
const lockResult = await LockService().lockIdempotencyKey(lockKey as IdempotencyKey)
if (lockResult instanceof Error) {
baseLogger.info({ customer_id, event_id, id }, "Duplicate Bridge external account webhook")
return res.status(200).json({ status: "already_processed" })
}

const status = toStatus(active)

const result = await BridgeAccountsRepo.createExternalAccount({
accountId: String(account.id),
bridgeExternalAccountId: id,
bankName: bank_name ?? "Unknown",
accountNumberLast4: last_4 ?? "0000",
status,
})

if (result instanceof Error) {
baseLogger.error(
{ accountId: account.id, event_id, id, error: result },
"Failed to persist Bridge external account",
)
return res.status(500).json({ error: "Failed to persist external account" })
}

baseLogger.info(
{ accountId: account.id, bridgeExternalAccountId: id, status },
"Bridge external account persisted",
)

return res.status(200).json({ status: "success" })
} catch (error) {
baseLogger.error({ error, customer_id, event_id }, "Error processing Bridge external account webhook")
return res.status(500).json({ error: "Internal server error" })
}
}
6 changes: 5 additions & 1 deletion src/services/bridge/webhook-server/routes/replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,16 @@ import {
} from "../transfer-direction"

import { depositHandler } from "./deposit"
import { externalAccountHandler } from "./external-account"
import { kycHandler } from "./kyc"
import { transferHandler } from "./transfer"
type RouteKey = "kyc" | "deposit" | "transfer"
type RouteKey = "kyc" | "deposit" | "transfer" | "external_account"

const HANDLERS: Record<RouteKey, (req: Request, res: Response) => Promise<Response>> = {
kyc: kycHandler,
deposit: depositHandler,
transfer: transferHandler,
external_account: externalAccountHandler,
}

const DEPOSIT_EVENT_TYPES = new Set([
Expand All @@ -39,6 +41,7 @@ const DEPOSIT_EVENT_TYPES = new Set([
const toRouteKey = (bridgeEventType: string): RouteKey | null => {
if (bridgeEventType.startsWith("kyc")) return "kyc"
if (bridgeEventType.startsWith("transfer")) return "transfer"
if (bridgeEventType.startsWith("external_account")) return "external_account"
if (DEPOSIT_EVENT_TYPES.has(bridgeEventType)) return "deposit"
return null
}
Expand Down Expand Up @@ -114,6 +117,7 @@ const toHandlerBody = ({
event_id: eventId,
event_object: eventObject,
}

}

export const replayAuthMiddleware = (req: Request, res: Response, next: () => void) => {
Expand Down
8 changes: 3 additions & 5 deletions src/services/bridge/webhook-server/routes/transfer.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/**
* Bridge Transfer Webhook Handler
* Handles transfer.completed and transfer.failed events from Bridge.xyz
* Handles transfer webhook events from Bridge.xyz (transfer.completed, transfer.updated.status_transitioned)
*/

import { Request, Response } from "express"
Expand Down Expand Up @@ -65,7 +65,7 @@ export const transferHandler = async (req: Request, res: Response) => {
event === "transfer.payment_processed" ||
state === "payment_processed"

const isFailure = event === "transfer.failed" || TERMINAL_FAILURE_STATES.has(state)
const isFailure = TERMINAL_FAILURE_STATES.has(state)

if (!isCompletion && !isFailure) {
baseLogger.info({ transfer_id, state, event }, "Bridge transfer event not handled")
Expand Down Expand Up @@ -118,9 +118,7 @@ export const transferHandler = async (req: Request, res: Response) => {
const failureReason =
state === "refund_failed"
? (return_reason as string | undefined)
: event === "transfer.failed"
? (reason as string | undefined)
: ((reason as string | undefined) ?? (return_reason as string | undefined))
: ((reason as string | undefined) ?? (return_reason as string | undefined))

const result = await BridgeAccountsRepo.updateWithdrawalStatus(
bridgeTransferId,
Expand Down
Loading