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
16 changes: 16 additions & 0 deletions src/domain/accounts/index.types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,22 @@ interface IAccountsRepository {
findByNpub(npub: Npub): Promise<Account | RepositoryError>
update(account: Account): Promise<Account | RepositoryError>

transitionBridgeKycStatus(
id: AccountId,
nextStatus:
| "open"
| "not_started"
| "incomplete"
| "awaiting_questionnaire"
| "awaiting_ubo"
| "under_review"
| "paused"
| "approved"
| "rejected"
| "offboarded",
): Promise<
{ changed: boolean; previousStatus?: Account["bridgeKycStatus"] } | RepositoryError
>
updateBridgeFields(
id: AccountId,
fields: {
Expand Down
22 changes: 12 additions & 10 deletions src/services/bridge/webhook-server/routes/external-account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,18 @@ export const externalAccountHandler = async (req: Request, res: Response) => {
}

try {
// Idempotency FIRST: the lock used to be taken after the external-account
// upsert, so a retried delivery re-persisted before being detected.
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 bridgeCustomerId = toBridgeCustomerId(customer_id)
const account = await AccountsRepository().findByBridgeCustomerId(bridgeCustomerId)
if (account instanceof Error) {
Expand Down Expand Up @@ -65,16 +77,6 @@ export const externalAccountHandler = async (req: Request, res: Response) => {
"Bridge external account persisted",
)

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" })
}

return res.status(200).json({ status: "success" })
} catch (error) {
baseLogger.error(
Expand Down
167 changes: 60 additions & 107 deletions src/services/bridge/webhook-server/routes/kyc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,17 @@ export const kycHandler = async (req: Request, res: Response) => {
}

try {
// Idempotency FIRST: Bridge retries reuse the same event_id, and the lock
// used to be taken only AFTER all side effects ran (pure bookkeeping —
// it prevented nothing). Locked means a delivery of this exact event is
// already being (or was) processed.
const lockKey = `bridge-kyc:${event_id}`
const lockResult = await LockService().lockIdempotencyKey(lockKey as IdempotencyKey)
if (lockResult instanceof Error) {
baseLogger.info({ customerId, event_id }, "Duplicate Bridge KYC webhook")
return res.status(200).json({ status: "already_processed" })
}

const bridgeCustomerId = toBridgeCustomerId(customerId)
const account = await AccountsRepository().findByBridgeCustomerId(bridgeCustomerId)
if (account instanceof Error) {
Expand All @@ -88,71 +99,64 @@ export const kycHandler = async (req: Request, res: Response) => {
"paused",
])

// Map Bridge customer status fields to our internal kyc status
// Bridge customer.status values: not_started, active (approved), rejected, offboarded
// Map Bridge customer status fields to our internal kyc status.
// Bridge customer.status values: not_started, active (approved), rejected,
// offboarded, plus the pending set above.
let nextStatus: NonNullable<Account["bridgeKycStatus"]> | undefined
if (status === "not_started") {
const nextStatus = "not_started" as const
const result = await AccountsRepository().updateBridgeFields(account.id, {
bridgeKycStatus: nextStatus,
})

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

baseLogger.info({ accountId: account.id, customerId }, "Bridge KYC not started")
nextStatus = "not_started"
} else if (PENDING_BRIDGE_STATUSES.has(status)) {
const nextStatus = status as NonNullable<Account["bridgeKycStatus"]>
const result = await AccountsRepository().updateBridgeFields(account.id, {
bridgeKycStatus: nextStatus,
})

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

await notifyKycStatusChange({
account,
previousStatus: account.bridgeKycStatus,
nextStatus,
rejectionReasons,
})
nextStatus = status as NonNullable<Account["bridgeKycStatus"]>
} else if (status === "active" || status === "approved") {
nextStatus = "approved"
} else if (status === "rejected") {
nextStatus = "rejected"
} else if (status === "offboarded") {
nextStatus = "offboarded"
}

if (!nextStatus) {
baseLogger.info(
{ accountId: account.id, customerId, status },
"Bridge KYC moved to pending",
{ accountId: account.id, customerId, status, event_type },
"Unhandled Bridge customer status — no action taken",
)
} else if (status === "active" || status === "approved") {
const nextStatus = "approved" as const
const result = await AccountsRepository().updateBridgeFields(account.id, {
bridgeKycStatus: nextStatus,
})
return res.status(200).json({ status: "success" })
}

if (result instanceof Error) {
baseLogger.error(
{ accountId: account.id, error: result },
"Failed to update KYC status",
)
return res.status(500).json({ error: "Failed to update status" })
}
// Atomic compare-and-swap: only the delivery that actually changes the
// status wins. Losers (distinct events carrying the same status, e.g.
// customer.created racing customer.updated — the double-push bug) skip
// notifications and side effects entirely.
const transition = await AccountsRepository().transitionBridgeKycStatus(
account.id,
nextStatus,
)
if (transition instanceof Error) {
baseLogger.error(
{ accountId: account.id, error: transition },
"Failed to update KYC status",
)
return res.status(500).json({ error: "Failed to update status" })
}
if (!transition.changed) {
baseLogger.info(
{ accountId: account.id, customerId, status, event_type },
"Bridge KYC status already current — skipping notification/side effects",
)
return res.status(200).json({ status: "already_current" })
}

if (nextStatus !== "not_started") {
await notifyKycStatusChange({
account,
previousStatus: account.bridgeKycStatus,
previousStatus: transition.previousStatus,
nextStatus,
rejectionReasons,
})
}

if (nextStatus === "approved") {
baseLogger.info({ accountId: account.id, customerId }, "Bridge KYC approved")

const vaResult = await BridgeService.createVirtualAccount(account.id)
if (vaResult instanceof Error) {
baseLogger.error(
Expand All @@ -165,71 +169,20 @@ export const kycHandler = async (req: Request, res: Response) => {
"Virtual account auto-created after KYC approval",
)
}
} else if (status === "rejected") {
const nextStatus = "rejected" as const
const result = await AccountsRepository().updateBridgeFields(account.id, {
bridgeKycStatus: nextStatus,
})

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

await notifyKycStatusChange({
account,
previousStatus: account.bridgeKycStatus,
nextStatus,
rejectionReasons,
})

} else if (nextStatus === "rejected") {
baseLogger.warn(
{
accountId: account.id,
customerId,
rejectionReasons,
},
{ accountId: account.id, customerId, rejectionReasons },
"Bridge KYC rejected",
)
} else if (status === "offboarded") {
const nextStatus = "offboarded" as const
const result = await AccountsRepository().updateBridgeFields(account.id, {
bridgeKycStatus: nextStatus,
})

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

await notifyKycStatusChange({
account,
previousStatus: account.bridgeKycStatus,
nextStatus,
rejectionReasons,
})

} else if (nextStatus === "offboarded") {
baseLogger.warn({ accountId: account.id, customerId }, "Bridge KYC offboarded")
} else {
baseLogger.info(
{ accountId: account.id, customerId, status, event_type },
"Unhandled Bridge customer status — no action taken",
{ accountId: account.id, customerId, status: nextStatus },
"Bridge KYC status updated",
)
}

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

return res.status(200).json({ status: "success" })
} catch (error) {
baseLogger.error({ error, customerId }, "Error processing Bridge KYC webhook")
Expand Down
43 changes: 43 additions & 0 deletions src/services/mongoose/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,48 @@ export const AccountsRepository = (): IAccountsRepository => {
}
}

// Atomic KYC status transition (bridge webhook dedupe): updates only when
// the status actually differs, and reports whether THIS call made the
// change. Concurrent webhook deliveries for the same target status (Bridge
// retries, or customer.created + customer.updated racing) collapse to one
// winner — losers see changed:false and must skip notifications and
// side effects. Returns the pre-update status of the winning transition.
const transitionBridgeKycStatus = async (
id: AccountId,
nextStatus:
| "open"
| "not_started"
| "incomplete"
| "awaiting_questionnaire"
| "awaiting_ubo"
| "under_review"
| "paused"
| "approved"
| "rejected"
| "offboarded",
): Promise<
{ changed: boolean; previousStatus?: Account["bridgeKycStatus"] } | RepositoryError
> => {
try {
const result = await Account.findOneAndUpdate(
{ _id: toObjectId<AccountId>(id), bridgeKycStatus: { $ne: nextStatus } },
{ $set: { bridgeKycStatus: nextStatus } },
{ new: false },
)
if (!result) {
const exists = await Account.exists({ _id: toObjectId<AccountId>(id) })
if (!exists) return new RepositoryError("Account not found")
return { changed: false }
}
return {
changed: true,
previousStatus: result.bridgeKycStatus as Account["bridgeKycStatus"],
}
} catch (error) {
return parseRepositoryError(error)
}
}

const updateBridgeFields = async (
id: AccountId,
fields: {
Expand Down Expand Up @@ -238,6 +280,7 @@ export const AccountsRepository = (): IAccountsRepository => {
findByUsername,
findByNpub,
update,
transitionBridgeKycStatus,
updateBridgeFields,
findByBridgeEthereumAddress,
findByBridgeCustomerId,
Expand Down
Loading
Loading