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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions dev/config/base-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,19 @@ ibex:
# production overrides to enforce the allowlist; empty disables it (ISL-112).
allowedIps: []

fygaro:
enabled: false # flags-off baseline; enable per environment via config overrides
webhook:
port: 4010
timestampSkewMs: 300000
# HMAC shared secrets keyed by the Fygaro-Key-ID header value.
# Real secrets injected via config overrides; never commit a real secret.
secrets: {}
credit:
# Phase gate: with credit OFF the webhook only records the payment and
# notifies ops; the treasury -> user transfer stays manual.
enabled: false

bridge:
enabled: false # flags-off baseline; enable per environment via config overrides
apiKey: "" # real key injected via config overrides; never commit a real key
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"migrate-mongo-up": "migrate-mongo up -f './src/migrations/migrate-mongo-config.js'",
"gen-test-jwt": "ts-node ./dev/bin/gen-test-jwt.ts",
"bridge-webhook": ". ./.env && ts-node --transpile-only -r tsconfig-paths/register src/servers/bridge-webhook-server.ts --configPath dev/config/base-config.yaml",
"fygaro-webhook": ". ./.env && ts-node --transpile-only -r tsconfig-paths/register src/servers/fygaro-webhook-server.ts --configPath dev/config/base-config.yaml",
"replay-bridge-events": "yarn build && node lib/scripts/replay-bridge-events.js",
"reconcile-bridge-ibex-deposits": "yarn build && node lib/scripts/reconcile-bridge-ibex-deposits.js"
},
Expand Down
35 changes: 35 additions & 0 deletions src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -772,6 +772,41 @@ export const configSchema = {
// stay hidden unless explicitly enabled per the v0.6.0 flag ramp.
default: { enabled: false },
},
fygaro: {
type: "object",
properties: {
enabled: { type: "boolean" },
webhook: {
type: "object",
properties: {
port: { type: "integer", default: 4010 },
// HMAC shared secrets keyed by the Fygaro-Key-ID header value.
// Multiple entries support secret rotation.
secrets: {
type: "object",
additionalProperties: { type: "string" },
default: {},
},
timestampSkewMs: { type: "integer", default: 300000 },
},
additionalProperties: false,
default: {},
},
credit: {
type: "object",
properties: {
enabled: { type: "boolean", default: false },
},
additionalProperties: false,
// Phase gate: with credit OFF the webhook only records + notifies;
// the treasury -> user transfer stays manual.
default: { enabled: false },
},
},
additionalProperties: false,
// Default OFF baseline (mirrors topup); enable per environment via overrides.
default: { enabled: false },
},
frappe: {
type: "object",
properties: {
Expand Down
15 changes: 15 additions & 0 deletions src/config/schema.types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,20 @@ type BridgeConfig = {
webhook: BridgeWebhook
}

type FygaroWebhookConfig = {
port: number
secrets: Record<string, string>
timestampSkewMs: number
}

type FygaroConfig = {
enabled: boolean
webhook: FygaroWebhookConfig
credit: {
enabled: boolean
}
}

type CashoutEmail = {
to: string
from: string
Expand Down Expand Up @@ -237,6 +251,7 @@ type YamlSchema = {
topup: {
enabled: boolean
}
fygaro: FygaroConfig
sendgrid: SendGridConfig
frappe: FrappeConfig
fcmTopics: {
Expand Down
2 changes: 2 additions & 0 deletions src/config/yaml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -419,4 +419,6 @@ export const IbexConfig = yamlConfig.ibex as IbexConfig

export const BridgeConfig = yamlConfig.bridge as BridgeConfig

export const FygaroConfig = yamlConfig.fygaro as FygaroConfig

export const FrappeConfig = yamlConfig.frappe as FrappeConfig
9 changes: 9 additions & 0 deletions src/servers/fygaro-webhook-server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { startFygaroWebhookServer } from "@services/fygaro/webhook-server"
import { baseLogger } from "@services/logger"
import { setupMongoConnection } from "@services/mongodb"

if (require.main === module) {
setupMongoConnection()
.then(async () => startFygaroWebhookServer())
.catch((err) => baseLogger.error(err, "fygaro webhook server error"))
}
5 changes: 5 additions & 0 deletions src/services/alerts/dedup-key.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ export const generateDedupKey = {
`ibex:reconcile:ibex-without-bridge:${txHash.toLowerCase()}`,
ibexReconcileFailed: (txHash: string) =>
`ibex:reconcile:failed:${txHash.toLowerCase()}`,
erpnextFygaroAudit: (transactionId: string) => `erpnext-audit:fygaro:${transactionId}`,
fygaroWebhookPayment: (transactionId: string) =>
`fygaro-webhook:payment:${transactionId}`,
fygaroUnattributed: (transactionId: string) => `fygaro:unattributed:${transactionId}`,
fygaroCreditFailed: (transactionId: string) => `fygaro:credit-failed:${transactionId}`,
}

export const normalizeDedupKey = (key: string): string =>
Expand Down
7 changes: 6 additions & 1 deletion src/services/alerts/index.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@

export type AlertSeverity = "critical" | "warning"

export type AlertSource = "bridge-webhook" | "bridge-api" | "ibex" | "erpnext-audit"
export type AlertSource =
| "bridge-webhook"
| "bridge-api"
| "ibex"
| "erpnext-audit"
| "fygaro-webhook"

export interface BridgeAlert {
dedupKey: string
Expand Down
94 changes: 94 additions & 0 deletions src/services/frappe/BridgeTransferRequestWriter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,100 @@ export const writeIbexCryptoReceiveRequest = async ({
)
}

// Fygaro card top-up audit row: fiat captured on Fygaro's side, recorded the
// moment the payment webhook lands. `fygaro:` prefixed request ids keep these
// rows disjoint from Bridge deposit ids and IBEX settle rows.
export const writeFygaroTopupRequest = async ({
transactionId,
amount,
currency,
accountId,
createdAt,
rawPayload,
}: {
transactionId: string
amount: string
currency: string
accountId?: AccountId | string
createdAt?: string
rawPayload: unknown
}): Promise<true | BridgeTransferRequestUpsertError> => {
return upsert(
new BridgeTransferRequest({
requestId: `fygaro:${transactionId}`,
transactionType: BridgeTransferRequestTransactionType.Topup,
status: BridgeTransferRequestStatus.FiatReceived,
provider: "Fygaro",
asset: "USD",
network: "Card",
amount: String(amount),
currency: String(currency),
accountId,
sourceEventId: transactionId,
sourceEventType: "fygaro.payment",
sourceSystemsSeen: ["fygaro_webhook"],
firstSeenAt: createdAt,
rawPayload,
}),
)
}

// Whether this Fygaro payment was already fully processed (its audit row
// promoted to Completed by a prior delivery). Used as the processed-marker for
// webhook re-deliveries. A lookup failure degrades to false — the credit
// itself is exactly-once under withPaymentIdempotency, so a false negative
// can never double-pay; it only costs a redundant cached-send replay.
export const isFygaroTopupCompleted = async (transactionId: string): Promise<boolean> => {
if (!ErpNext?.findBridgeTransferRequest) return false
const doc = await ErpNext.findBridgeTransferRequest(`fygaro:${transactionId}`)
if (doc instanceof Error) {
baseLogger.warn(
{ transactionId, error: doc },
"Failed to check Fygaro topup completion; treating as not completed",
)
return false
}
return doc?.status === BridgeTransferRequestStatus.Completed
}

// Called after the treasury -> user intraledger credit succeeds: promotes the
// Fygaro topup row to Completed and stamps the credited wallet on it. The
// upsert's monotonic status guard makes this safe to repeat.
export const completeFygaroTopup = async ({
transactionId,
accountId,
walletId,
amount,
currency,
rawPayload,
}: {
transactionId: string
accountId: AccountId | string
walletId: WalletId | string
amount: string
currency: string
rawPayload: unknown
}): Promise<true | BridgeTransferRequestUpsertError> => {
return upsert(
new BridgeTransferRequest({
requestId: `fygaro:${transactionId}`,
transactionType: BridgeTransferRequestTransactionType.Topup,
status: BridgeTransferRequestStatus.Completed,
provider: "Fygaro",
asset: "USD",
network: "Card",
amount: String(amount),
currency: String(currency),
accountId,
walletId,
sourceEventId: transactionId,
sourceEventType: "fygaro.payment",
sourceSystemsSeen: ["fygaro_webhook", "ibex_intraledger_credit"],
rawPayload,
}),
)
}

type BridgeCashoutWriteInput = {
transferId: string
amount: string
Expand Down
2 changes: 1 addition & 1 deletion src/services/frappe/models/BridgeTransferRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export type BridgeTransferRequestInput = {
status: BridgeTransferRequestStatus
amount: string
currency: string
provider?: "Bridge"
provider?: "Bridge" | "Fygaro"
asset?: string
network?: string
developerFee?: string
Expand Down
118 changes: 118 additions & 0 deletions src/services/fygaro/webhook-server/credit-topup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { PaymentSendStatus } from "@domain/bitcoin/lightning"
import { WalletCurrency } from "@domain/shared"
import { AccountsRepository, WalletsRepository } from "@services/mongoose"
import { baseLogger } from "@services/logger"

/**
* Credits a verified Fygaro card payment to the payer's Flash account:
* an intraledger USD send from the bank-owner treasury to the user's cash
* wallet. Mirrors the referral-reward payout path (award-referral-reward.ts),
* the in-repo precedent for treasury -> user credits.
*
* Safety properties:
* - The send runs under withPaymentIdempotency keyed on the Fygaro
* transaction id, so a webhook replay can never double-credit (ENG-530).
* - PaymentSendStatus.Pending means money has probably left the treasury —
* it is reported as credited (never retried) and left to ops to confirm,
* the same never-risk-a-double-pay stance the referral payout takes.
*/
const TREASURY_ROLE = "bankowner"

export class FygaroCreditError extends Error {
step: string
constructor(step: string, message: string) {
super(message)
this.name = "FygaroCreditError"
this.step = step
}
}

const walletsFor = async (accountId: AccountId): Promise<Wallet[]> => {
const wallets = await WalletsRepository().listByAccountId(accountId)
return wallets instanceof Error ? [] : wallets
}

export const creditFygaroTopup = async ({
recipientAccountId,
amountCents,
transactionId,
}: {
recipientAccountId: AccountId
amountCents: number
transactionId: string
}): Promise<
{ walletId: WalletId; status: "success" | "pending" } | FygaroCreditError
> => {
if (!Number.isInteger(amountCents) || amountCents <= 0) {
return new FygaroCreditError("validate-amount", `invalid amount: ${amountCents}`)
}

const treasuryAccount = await AccountsRepository().findByRole(TREASURY_ROLE)
if (treasuryAccount instanceof Error) {
return new FygaroCreditError(
"resolve-treasury",
`no account holds the '${TREASURY_ROLE}' role`,
)
}

// Prefer the USDT wallet (the active cash wallet on every account — see
// accounts/create-account.ts), falling back to the legacy USD wallet.
const treasuryWallets = await walletsFor(treasuryAccount.id)
const fundingWallet =
treasuryWallets.find((w) => w.currency === WalletCurrency.Usdt) ??
treasuryWallets.find((w) => w.currency === WalletCurrency.Usd)
if (!fundingWallet) {
return new FygaroCreditError(
"resolve-treasury-wallet",
"treasury account has no USDT or USD wallet",
)
}

// Recipients must hold a wallet in the funding wallet's currency —
// send-intraledger rejects cross-currency sends.
const recipientWallet = (await walletsFor(recipientAccountId)).find(
(w) => w.currency === fundingWallet.currency,
)
if (!recipientWallet) {
return new FygaroCreditError(
"resolve-recipient-wallet",
`recipient has no ${fundingWallet.currency} wallet`,
)
}

// Lazy-import so merely importing this module doesn't pull the IBEX client
// (and its module-load side effects) into unrelated code paths.
const { intraledgerPaymentSendWalletIdForUsdWallet } = await import(
"@app/payments/send-intraledger"
)
const result = await intraledgerPaymentSendWalletIdForUsdWallet({
senderWalletId: fundingWallet.id,
recipientWalletId: recipientWallet.id,
amount: amountCents,
memo: `Card top-up (Fygaro ${transactionId})`,
idempotencyKey: `fygaro:${transactionId}` as IdempotencyKey,
})

if (result instanceof Error) {
baseLogger.error(
{ err: result, transactionId, recipientAccountId },
"fygaro credit: intraledger send returned an error",
)
return new FygaroCreditError("intraledger-send", result.message)
}
if (result === PaymentSendStatus.Success) {
return { walletId: recipientWallet.id, status: "success" }
}
if (result === PaymentSendStatus.Pending) {
// Money has probably left the treasury: report credited, never re-pay.
baseLogger.warn(
{ transactionId, recipientAccountId },
"fygaro credit: send pending — treating as credited for idempotency",
)
return { walletId: recipientWallet.id, status: "pending" }
}
return new FygaroCreditError(
"intraledger-send",
`unexpected payment status: ${String(result)}`,
)
}
Loading
Loading