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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,7 @@
"**/axios": "1.16.0",
"**/body-parser": "1.20.3",
"**/ws": "8.20.0",
"**/handlebars": "4.7.9",
"**/handlebars": "4.7.10",
"**/immutable": "5.1.5",
"**/fast-uri": "3.1.2",
"**/glob": "10.5.0",
Expand Down
34 changes: 33 additions & 1 deletion src/app/invite/award-referral-reward.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ import { nextReferralRewardSeq } from "@services/mongoose/models/referral-reward
import { baseLogger } from "@services/logger"

import { referralRewardsEnabledInErp } from "./referral-settings"
import {
accountAllowlistedForReferralPayout,
referralPayoutAllowlistIsEmpty,
} from "./referral-payout-allowlist"

import { sendReferralRewardNotificationBestEffort } from "./send-referral-notifications"

Expand Down Expand Up @@ -45,14 +49,28 @@ const markReward = async (
// an ops sweep can find rows stuck in "processing" (e.g. pod killed
// mid-payout before any terminal mark landed).
// - Never throws into the KYC path: all errors are caught and logged.
// - Paused program (config.enabled=false): only a referral where the inviter
// OR the invitee is on the ERPNext Referral Payout Allowlist proceeds (both
// parties are then paid per the tier); the ERPNext switch still gates it.
// An empty allowlist (the default) returns before any Mongo query.
export const awardReferralRewardOnKycApproval = async ({
accountId,
}: {
accountId: AccountId
}): Promise<void> => {
try {
const config = getReferralRewardConfig()
if (!config.enabled) return

// While the program is paused, only allowlisted referrals may pay. The
// empty check comes first so the default (nobody listed) does zero Mongo
// work, exactly as before the allowlist existed. An allowlisted invitee
// proceeds as normal; otherwise the inviter must be allowlisted, which
// is only knowable after the pending-invite lookup below.
let requireAllowlistedInviter = false
if (!config.enabled) {
if (await referralPayoutAllowlistIsEmpty()) return
requireAllowlistedInviter = !(await accountAllowlistedForReferralPayout(accountId))
}

// One reward per invitee, ever. Bridge KYC can flap back to "approved"
// (approved -> under_review -> approved re-fires this hook), and redemption
Expand All @@ -72,6 +90,20 @@ export const awardReferralRewardOnKycApproval = async ({
})
if (!pending) return // not a referred user, or already claimed

if (requireAllowlistedInviter) {
const inviterId = pending.inviterId?.toString() as AccountId | undefined
if (!inviterId || !(await accountAllowlistedForReferralPayout(inviterId))) {
// Same deferral semantics as the ERPNext switch below: the invite is
// left ACCEPTED + unrewarded and pays on a later trigger once either
// party is allowlisted or the program is re-enabled.
baseLogger.info(
{ accountId, inviterId },
"referral reward deferred: not allowlisted (program paused, neither party on the Referral Payout Allowlist)",
)
return
}
}

// Operator kill switch (ERPNext "Referral Settings" single). Checked
// AFTER the invite lookups (so non-referred KYC approvals never touch the
// ERP reader or log a bogus deferral — this hook fires on EVERY Bridge KYC
Expand Down
127 changes: 127 additions & 0 deletions src/app/invite/referral-payout-allowlist.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/**
* Cached reader for the ERPNext "Referral Payout Allowlist" doctype: the
* accounts whose referral reward may pay while the global referral program
* (helm `referralReward.enabled`) stays paused after the 2026-08-31 farming
* wave. A referral pays when EITHER party (inviter or invitee) is listed;
* both parties are still paid per the tier. The ERPNext Referral Settings
* switch (referral-settings.ts) remains the master kill on top of this.
*
* FAIL-CLOSED, like referral-settings.ts. An unreadable, missing, malformed,
* or empty allowlist means "nobody is allowlisted", so nothing pays. Do NOT
* copy the polarity of services/frappe/fee-discounts.ts, which fails OPEN to
* a 0% discount: here that copy error would turn an ERPNext outage into
* payouts.
*
* Rows are keyed by the flash account id (Mongo `_id` string). frappe
* resolves the operator's input to that id at save time, so this reader never
* touches Mongo: it only reads `account_id` and `enabled`.
*
* Memoised for ~60s (successes AND failures) so a KYC-approval burst cannot
* become an ERPNext fetch storm, while removing a row takes effect within a
* minute.
*/
import { toBoolean } from "@services/frappe/coerce"
import ErpNext from "@services/frappe/ErpNext"
import { baseLogger } from "@services/logger"

const CACHE_TTL_MS = 60_000

const ACCOUNT_ID_PATTERN = /^[0-9a-f]{24}$/

let cache: { accountIds: Set<AccountId>; at: number } | undefined

export const resetReferralPayoutAllowlistCache = () => {
cache = undefined
}

/**
* The allowlisted account id of one raw row, or undefined when the row must
* be dropped: not an object, not enabled, or an account_id that is not a
* 24-hex ObjectId string. `enabled` is re-checked here even though the ERP
* query filters on it, so a lost filter cannot re-enable a removed row.
*/
export const validateReferralPayoutAllowlistDoc = (
doc: unknown,
): AccountId | undefined => {
if (!doc || typeof doc !== "object") return undefined
const { account_id: rawAccountId, enabled } = doc as {
account_id?: unknown
enabled?: unknown
}
if (!toBoolean(enabled)) return undefined
if (typeof rawAccountId !== "string") return undefined
const accountId = rawAccountId.trim().toLowerCase()
if (!ACCOUNT_ID_PATTERN.test(accountId)) return undefined
return accountId as AccountId
}

const fetchAllowlist = async (): Promise<Set<AccountId>> => {
const docs = ErpNext?.getReferralPayoutAllowlist
? await ErpNext.getReferralPayoutAllowlist()
: undefined

if (docs instanceof Error || !Array.isArray(docs)) {
baseLogger.warn(
{ error: docs },
"Referral Payout Allowlist unreadable — treating it as EMPTY (no allowlisted payouts)",
)
return new Set()
}

const accountIds = new Set<AccountId>()
for (const doc of docs) {
const accountId = validateReferralPayoutAllowlistDoc(doc)
if (accountId) accountIds.add(accountId)
else {
baseLogger.warn(
{ doc },
"Referral Payout Allowlist: dropping malformed or disabled row",
)
}
}
return accountIds
}

const loadAllowlist = async (): Promise<Set<AccountId>> => {
const now = Date.now()
if (cache && now - cache.at < CACHE_TTL_MS) return cache.accountIds

let accountIds: Set<AccountId>
try {
accountIds = await fetchAllowlist()
} catch (err) {
baseLogger.warn(
{ err },
"Referral Payout Allowlist load threw — treating it as EMPTY (no allowlisted payouts)",
)
accountIds = new Set()
}

cache = { accountIds, at: now }
return accountIds
}

/** True only when the account is on a readable, enabled allowlist row. Never throws. */
export const accountAllowlistedForReferralPayout = async (
accountId: AccountId,
): Promise<boolean> => {
try {
const accountIds = await loadAllowlist()
return accountIds.has(String(accountId).toLowerCase() as AccountId)
} catch {
return false
}
}

/**
* True when nobody is allowlisted (the prod default, and the fail-closed
* result of an unreadable allowlist). Lets the paused award hook return
* before any Mongo query. Never throws.
*/
export const referralPayoutAllowlistIsEmpty = async (): Promise<boolean> => {
try {
return (await loadAllowlist()).size === 0
} catch {
return true
}
}
55 changes: 51 additions & 4 deletions src/app/invite/retry-deferred-rewards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,27 @@
* Also usable in dry-run alone: an operator about to flip the switch back on
* can see the backlog size up front instead of relying on grepping logs for
* "deferred" lines.
*
* `account` / `inviter` narrow the sweep (inside the Mongo query) to one
* invitee or one inviter: the Referral Payout Allowlist flow, where an
* operator replays a just-allowlisted account's deferred reward. The result
* then reports whether that account is currently allowlisted, so a dry run
* shows "not allowlisted" instead of a silent no-op.
*/
import { InviteStatus } from "@domain/invite"

import { InviteRepository } from "@services/mongoose/models/invite"
import { baseLogger } from "@services/logger"

import { awardReferralRewardOnKycApproval } from "./award-referral-reward"
import { accountAllowlistedForReferralPayout } from "./referral-payout-allowlist"

export type RetryDeferredReferralRewardsResult = {
backlogCount: number
accountsRetried: number
// Set only when the sweep is filtered to that party.
allowlisted?: boolean
inviterAllowlisted?: boolean
}

/**
Expand All @@ -30,13 +40,18 @@ export type RetryDeferredReferralRewardsResult = {
*/
export const retryDeferredReferralRewards = async ({
dryRun,
account,
inviter,
}: {
dryRun: boolean
account?: AccountId
inviter?: AccountId
}): Promise<RetryDeferredReferralRewardsResult> => {
const deferred = await InviteRepository.find({
status: InviteStatus.ACCEPTED,
rewardStatus: { $exists: false },
redeemedById: { $exists: true },
redeemedById: account ?? { $exists: true },
...(inviter ? { inviterId: inviter } : {}),
})

// Redemption history can hold more than one accepted invite for the same
Expand All @@ -50,16 +65,48 @@ export const retryDeferredReferralRewards = async ({
),
] as AccountId[]

const allowlistReport: Pick<
RetryDeferredReferralRewardsResult,
"allowlisted" | "inviterAllowlisted"
> = {}
if (account)
allowlistReport.allowlisted = await accountAllowlistedForReferralPayout(account)
if (inviter)
allowlistReport.inviterAllowlisted =
await accountAllowlistedForReferralPayout(inviter)
const filterReport = {
...(account ? { account } : {}),
...(inviter ? { inviter } : {}),
...allowlistReport,
}

baseLogger.info(
{ backlogCount: deferred.length, accounts: accountIds.length, dryRun },
{
backlogCount: deferred.length,
accounts: accountIds.length,
dryRun,
...filterReport,
},
"referral reward backlog: accepted + unrewarded invites found",
)

if (dryRun) return { backlogCount: deferred.length, accountsRetried: 0 }
if (deferred.length === 0 && (account || inviter)) {
baseLogger.warn(
{ account, inviter },
"referral reward backlog: no accepted + unrewarded invite for the requested account/inviter",
)
}

if (dryRun)
return { backlogCount: deferred.length, accountsRetried: 0, ...allowlistReport }

for (const accountId of accountIds) {
await awardReferralRewardOnKycApproval({ accountId })
}

return { backlogCount: deferred.length, accountsRetried: accountIds.length }
return {
backlogCount: deferred.length,
accountsRetried: accountIds.length,
...allowlistReport,
}
}
58 changes: 56 additions & 2 deletions src/scripts/retry-deferred-referral-rewards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,26 @@
* Usage:
* node lib/scripts/retry-deferred-referral-rewards.js \
* --configPath dev/config/base-config.yaml \
* [--dry-run]
* [--dry-run] \
* [--account <accountId> | --username <username>] \
* [--inviter <accountId>]
*
* --account / --username replay only that invitee's deferred reward;
* --inviter replays only invites sent by that account. Use them for the
* Referral Payout Allowlist (src/app/invite/referral-payout-allowlist.ts):
* 1. fund the rewards wallet FIRST, before adding the allowlist row (an
* underfunded payout is recorded as "partial" or "failed" and needs
* manual reconciliation);
* 2. dry-run with --username <name> --dry-run and check the logged
* backlogCount and allowlisted=true;
* 3. execute the same command without --dry-run.
*/

import yargs from "yargs"
import { hideBin } from "yargs/helpers"
import { checkedToAccountId } from "@domain/accounts"
import { setupMongoConnection } from "@services/mongodb"
import { AccountsRepository } from "@services/mongoose"
import { baseLogger } from "@services/logger"
import { retryDeferredReferralRewards } from "@app/invite/retry-deferred-rewards"

Expand All @@ -28,12 +42,52 @@ export const buildArgs = (argv: string[]) =>
describe: "Report the backlog size without paying anyone (default: false)",
})
.option("configPath", { type: "string" as const, demandOption: true })
.option("account", {
type: "string" as const,
describe: "Only retry this invitee account id",
})
.option("username", {
type: "string" as const,
describe: "Only retry this invitee username (resolved to its account id)",
})
.option("inviter", {
type: "string" as const,
describe: "Only retry invites sent by this inviter account id",
})
.conflicts("account", "username")
.parseSync()

const toAccountIdOrThrow = (raw: string, flag: string): AccountId => {
const accountId = checkedToAccountId(raw.trim())
if (accountId instanceof Error) throw new Error(`--${flag} is not a valid account id`)
return accountId
}

export const resolveFilters = async (args: {
account?: string
username?: string
inviter?: string
}): Promise<{ account?: AccountId; inviter?: AccountId }> => {
let account: AccountId | undefined
if (args.account !== undefined) account = toAccountIdOrThrow(args.account, "account")
if (args.username !== undefined) {
const found = await AccountsRepository().findByUsername(
args.username.trim() as Username,
)
if (found instanceof Error)
throw new Error(`--username ${args.username} not found: ${found.name}`)
account = found.id
}
const inviter =
args.inviter !== undefined ? toAccountIdOrThrow(args.inviter, "inviter") : undefined
return { account, inviter }
}

const main = async () => {
const args = buildArgs(hideBin(process.argv))
const dryRun = args["dry-run"]
const result = await retryDeferredReferralRewards({ dryRun })
const { account, inviter } = await resolveFilters(args)
const result = await retryDeferredReferralRewards({ dryRun, account, inviter })
baseLogger.info(
{ ...result, dryRun },
dryRun
Expand Down
Loading
Loading