diff --git a/dev/bruno/Flash GraphQL API/collection.bru b/dev/bruno/Flash GraphQL API/collection.bru index 40b0cbaea..97ea30b6a 100644 --- a/dev/bruno/Flash GraphQL API/collection.bru +++ b/dev/bruno/Flash GraphQL API/collection.bru @@ -10,6 +10,7 @@ vars:pre-request { protocol: http domain: localhost port: 4000 + bridgeExternalAccountId: ext_plaid_a4a2b7e0175c } docs { @@ -32,7 +33,7 @@ docs { ``` ERPNEXT_JWT_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) } diff --git a/dev/bruno/Flash GraphQL API/token/mutations/bridgeAddExternalAccount.bru b/dev/bruno/Flash GraphQL API/token/mutations/bridgeAddExternalAccount.bru new file mode 100644 index 000000000..9535124f1 --- /dev/null +++ b/dev/bruno/Flash GraphQL API/token/mutations/bridgeAddExternalAccount.bru @@ -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 +} diff --git a/dev/bruno/Flash GraphQL API/token/mutations/bridgeInitiateWithdrawal.bru b/dev/bruno/Flash GraphQL API/token/mutations/bridgeInitiateWithdrawal.bru new file mode 100644 index 000000000..77df58a56 --- /dev/null +++ b/dev/bruno/Flash GraphQL API/token/mutations/bridgeInitiateWithdrawal.bru @@ -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 +} diff --git a/dev/bruno/Flash GraphQL API/token/queries/bridgeExternalAccounts.bru b/dev/bruno/Flash GraphQL API/token/queries/bridgeExternalAccounts.bru new file mode 100644 index 000000000..4fae717a8 --- /dev/null +++ b/dev/bruno/Flash GraphQL API/token/queries/bridgeExternalAccounts.bru @@ -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 +} diff --git a/dev/bruno/Flash GraphQL API/token/queries/bridgeWithdrawals.bru b/dev/bruno/Flash GraphQL API/token/queries/bridgeWithdrawals.bru new file mode 100644 index 000000000..397404f13 --- /dev/null +++ b/dev/bruno/Flash GraphQL API/token/queries/bridgeWithdrawals.bru @@ -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 +} diff --git a/src/config/schema.ts b/src/config/schema.ts index c4e6077c2..11f0b9a53 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -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" }, diff --git a/src/config/schema.types.d.ts b/src/config/schema.types.d.ts index 20c1952a4..ed94535f1 100644 --- a/src/config/schema.types.d.ts +++ b/src/config/schema.types.d.ts @@ -35,6 +35,7 @@ type BridgeWebhookPublicKeys = { kyc: string deposit: string transfer: string + external_account: string } type BridgeWebhook = { diff --git a/src/graphql/public/root/mutation/bridge-add-external-account.ts b/src/graphql/public/root/mutation/bridge-add-external-account.ts index 0c4a2fd19..1c3ccfbe5 100644 --- a/src/graphql/public/root/mutation/bridge-add-external-account.ts +++ b/src/graphql/public/root/mutation/bridge-add-external-account.ts @@ -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" @@ -10,7 +10,7 @@ const BridgeAddExternalAccountPayload = GT.Object({ name: "BridgeAddExternalAccountPayload", fields: () => ({ errors: { type: GT.NonNullList(IError) }, - externalAccount: { type: BridgeExternalAccount }, + externalAccount: { type: BridgeExternalAccountLink }, }), }) diff --git a/src/graphql/public/types/object/bridge-external-account-link.ts b/src/graphql/public/types/object/bridge-external-account-link.ts new file mode 100644 index 000000000..5d51c664d --- /dev/null +++ b/src/graphql/public/types/object/bridge-external-account-link.ts @@ -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 diff --git a/src/graphql/public/types/object/bridge-external-account.ts b/src/graphql/public/types/object/bridge-external-account.ts index 890733978..92cae18ad 100644 --- a/src/graphql/public/types/object/bridge-external-account.ts +++ b/src/graphql/public/types/object/bridge-external-account.ts @@ -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) }, diff --git a/src/services/bridge/webhook-server/index.ts b/src/services/bridge/webhook-server/index.ts index 2cf97522a..e89060179 100644 --- a/src/services/bridge/webhook-server/index.ts +++ b/src/services/bridge/webhook-server/index.ts @@ -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 } @@ -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) { diff --git a/src/services/bridge/webhook-server/middleware/verify-signature.ts b/src/services/bridge/webhook-server/middleware/verify-signature.ts index 03b75494f..447ff9aaf 100644 --- a/src/services/bridge/webhook-server/middleware/verify-signature.ts +++ b/src/services/bridge/webhook-server/middleware/verify-signature.ts @@ -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 diff --git a/src/services/bridge/webhook-server/routes/external-account.ts b/src/services/bridge/webhook-server/routes/external-account.ts new file mode 100644 index 000000000..6618f5a31 --- /dev/null +++ b/src/services/bridge/webhook-server/routes/external-account.ts @@ -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" }) + } +} diff --git a/src/services/bridge/webhook-server/routes/replay.ts b/src/services/bridge/webhook-server/routes/replay.ts index 33b499bd9..009414c28 100644 --- a/src/services/bridge/webhook-server/routes/replay.ts +++ b/src/services/bridge/webhook-server/routes/replay.ts @@ -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 Promise> = { kyc: kycHandler, deposit: depositHandler, transfer: transferHandler, + external_account: externalAccountHandler, } const DEPOSIT_EVENT_TYPES = new Set([ @@ -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 } @@ -114,6 +117,7 @@ const toHandlerBody = ({ event_id: eventId, event_object: eventObject, } + } export const replayAuthMiddleware = (req: Request, res: Response, next: () => void) => { diff --git a/src/services/bridge/webhook-server/routes/transfer.ts b/src/services/bridge/webhook-server/routes/transfer.ts index a24a32fdc..6330ad94a 100644 --- a/src/services/bridge/webhook-server/routes/transfer.ts +++ b/src/services/bridge/webhook-server/routes/transfer.ts @@ -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" @@ -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") @@ -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, diff --git a/src/services/mongoose/bridge-accounts.ts b/src/services/mongoose/bridge-accounts.ts index c045295be..4afd2c974 100644 --- a/src/services/mongoose/bridge-accounts.ts +++ b/src/services/mongoose/bridge-accounts.ts @@ -61,7 +61,12 @@ export const createExternalAccount = async (data: { status?: "pending" | "verified" | "failed" }) => { try { - const record = await BridgeExternalAccount.create(data) + const { bridgeExternalAccountId, accountId, status, ...immutable } = data + const record = await BridgeExternalAccount.findOneAndUpdate( + { bridgeExternalAccountId, accountId }, + { $setOnInsert: { bridgeExternalAccountId, accountId, ...immutable }, $set: { status: status ?? "pending" } }, + { upsert: true, new: true, setDefaultsOnInsert: true }, + ) return record } catch (error) { return new RepositoryError(String(error))