From b7e4448e75560c0fad2ea55372a001471de1acb2 Mon Sep 17 00:00:00 2001 From: Dread Date: Sat, 10 Oct 2026 19:25:48 -0700 Subject: [PATCH 1/6] feat(erpnext): read the Referral Payout Allowlist doctype (ENG-640) GET /api/resource/Referral Payout Allowlist with fields account_id and enabled, filtered to enabled rows, no page cap. A non-array response or a request error returns ReferralPayoutAllowlistQueryError. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01WxkKbrg2Xohf3mQj7BLrs6 --- src/services/frappe/ErpNext.ts | 49 +++++++++++++++ src/services/frappe/errors.ts | 1 + .../unit/services/frappe/ErpNext.spec.ts | 60 +++++++++++++++++++ 3 files changed, 110 insertions(+) diff --git a/src/services/frappe/ErpNext.ts b/src/services/frappe/ErpNext.ts index 2de987ade..2862b6bb1 100644 --- a/src/services/frappe/ErpNext.ts +++ b/src/services/frappe/ErpNext.ts @@ -27,6 +27,7 @@ import { AllowedCountryQueryError, FygaroSettingsQueryError, ReferralSettingsQueryError, + ReferralPayoutAllowlistQueryError, FygaroTopupHistoryQueryError, IdVerificationCreateError, IdVerificationQueryError, @@ -277,6 +278,15 @@ export type ReferralSettingsDoc = { rewards_enabled?: unknown } +// Raw "Referral Payout Allowlist" doctype row: accounts whose referral reward +// may pay while the global referral program (helm referralReward.enabled) is +// paused. frappe resolves the operator's input to the flash account id at save +// time; flash reads only `account_id` (Mongo _id string) and `enabled`. +export type ReferralPayoutAllowlistDoc = { + account_id?: string + enabled?: number | boolean | string +} + // Raw "Fee Discount" doctype row as ERPNext returns it (one row per username, // operator-managed at /app/fee-discount). Numeric/check fields may arrive as // numbers or strings; the caller (fee-discounts.ts) coerces and validates @@ -1126,6 +1136,45 @@ export class ErpNext { } } + // Reads every ENABLED "Referral Payout Allowlist" row. Consumed through a + // 60s FAIL-CLOSED cache in @app/invite/referral-payout-allowlist: an error + // here means "nobody is allowlisted", so it must return the error rather + // than a default list. + async getReferralPayoutAllowlist(): Promise< + ReferralPayoutAllowlistDoc[] | ReferralPayoutAllowlistQueryError + > { + try { + // `enabled` is fetched as well as filtered on: the reader re-checks it, + // so a disabled row is dropped even if this filter is ever lost. + const fields = JSON.stringify(["account_id", "enabled"]) + const filters = JSON.stringify([["enabled", "=", 1]]) + const resp = await axios.get( + `${this.url}/api/resource/${encodeURIComponent("Referral Payout Allowlist")}`, + { + params: { filters, fields, limit_page_length: 0 }, + headers: this.headers, + }, + ) + const data = resp.data?.data + if (!Array.isArray(data)) + return new ReferralPayoutAllowlistQueryError( + "No data in Referral Payout Allowlist response", + ) + return data as ReferralPayoutAllowlistDoc[] + } catch (err) { + const responseData = isAxiosError(err) ? err.response?.data : undefined + baseLogger.error( + { err, responseData }, + "Error querying Referral Payout Allowlist from ERPNext", + ) + recordExceptionInCurrentSpan({ + error: err, + attributes: { "erpnext.exception": responseData?.exception }, + }) + return new ReferralPayoutAllowlistQueryError(err) + } + } + // Countries whose residents Bridge can issue Flash a USD virtual account // for: the ERPNext "Allowed Country" doctype rows ops have ticked // `flash_allowed`. Read by the bridgeInitiateKyc country gate. Only the diff --git a/src/services/frappe/errors.ts b/src/services/frappe/errors.ts index 218e9d361..caff24ac6 100644 --- a/src/services/frappe/errors.ts +++ b/src/services/frappe/errors.ts @@ -67,6 +67,7 @@ export class BridgeTransferRequestUpsertError extends ErpNextError {} export class FygaroSettingsQueryError extends ErpNextError {} export class ReferralSettingsQueryError extends ErpNextError {} export class FeeDiscountQueryError extends ErpNextError {} +export class ReferralPayoutAllowlistQueryError extends ErpNextError {} export class AllowedCountryQueryError extends ErpNextError {} export class FygaroTopupHistoryQueryError extends ErpNextError {} export class IdVerificationCreateError extends ErpNextError {} diff --git a/test/flash/unit/services/frappe/ErpNext.spec.ts b/test/flash/unit/services/frappe/ErpNext.spec.ts index 61c49011d..9a125072d 100644 --- a/test/flash/unit/services/frappe/ErpNext.spec.ts +++ b/test/flash/unit/services/frappe/ErpNext.spec.ts @@ -27,6 +27,7 @@ import { import { AllowedCountryQueryError, FeeDiscountQueryError, + ReferralPayoutAllowlistQueryError, ReferralSettingsQueryError, } from "@services/frappe/errors" import { @@ -1381,6 +1382,65 @@ describe("ErpNext.getFeeDiscounts", () => { }) }) +describe("ErpNext.getReferralPayoutAllowlist", () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it("requests only ENABLED rows, fetching account_id and enabled", async () => { + mockedAxios.get.mockResolvedValue({ data: { data: [] } }) + + await client.getReferralPayoutAllowlist() + + const [url, config] = mockedAxios.get.mock.calls[0] + expect(url).toBe("https://erp.example/api/resource/Referral%20Payout%20Allowlist") + expect(JSON.parse(config.params.filters)).toEqual([["enabled", "=", 1]]) + // `enabled` is fetched as well as filtered on so the reader can re-check it. + expect(JSON.parse(config.params.fields)).toEqual(["account_id", "enabled"]) + // No pagination cap: a truncated page would silently drop allowlisted accounts. + expect(config.params.limit_page_length).toBe(0) + }) + + it("returns the rows as-is for the reader to validate", async () => { + const rows = [{ account_id: "64b7f0c2a1b2c3d4e5f60718", enabled: 1 }] + mockedAxios.get.mockResolvedValue({ data: { data: rows } }) + + expect(await client.getReferralPayoutAllowlist()).toEqual(rows) + }) + + it("returns an error (not a silent empty list) when the response is not an array", async () => { + mockedAxios.get.mockResolvedValue({ data: { data: { account_id: "x" } } }) + + expect(await client.getReferralPayoutAllowlist()).toBeInstanceOf( + ReferralPayoutAllowlistQueryError, + ) + }) + + it("returns an error when the response has no data", async () => { + mockedAxios.get.mockResolvedValue({ data: {} }) + + expect(await client.getReferralPayoutAllowlist()).toBeInstanceOf( + ReferralPayoutAllowlistQueryError, + ) + }) + + it("returns the error rather than throwing, and records it in the span", async () => { + const err = { + isAxiosError: true, + response: { status: 404, data: { exception: "DoesNotExistError" } }, + } + mockedAxios.get.mockRejectedValue(err) + + expect(await client.getReferralPayoutAllowlist()).toBeInstanceOf( + ReferralPayoutAllowlistQueryError, + ) + expect(mockedRecordExceptionInCurrentSpan).toHaveBeenCalledWith({ + error: err, + attributes: { "erpnext.exception": "DoesNotExistError" }, + }) + }) +}) + describe("ErpNext.getReferralSettings", () => { beforeEach(() => { jest.clearAllMocks() From be7206e854c55d2a7f67a34259d2764f5cc3d6be Mon Sep 17 00:00:00 2001 From: Dread Date: Sat, 10 Oct 2026 19:25:48 -0700 Subject: [PATCH 2/6] feat(invite): fail-closed cached Referral Payout Allowlist reader (ENG-640) 60s cache of allowlisted account ids (successes and failures). Unreadable, non-array, disabled or malformed rows mean nobody is allowlisted. Rows are keyed by the 24-hex flash account id, so no Mongo lookups. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01WxkKbrg2Xohf3mQj7BLrs6 --- src/app/invite/referral-payout-allowlist.ts | 127 +++++++++++ .../invite/referral-payout-allowlist.spec.ts | 199 ++++++++++++++++++ 2 files changed, 326 insertions(+) create mode 100644 src/app/invite/referral-payout-allowlist.ts create mode 100644 test/flash/unit/app/invite/referral-payout-allowlist.spec.ts diff --git a/src/app/invite/referral-payout-allowlist.ts b/src/app/invite/referral-payout-allowlist.ts new file mode 100644 index 000000000..1cdf79626 --- /dev/null +++ b/src/app/invite/referral-payout-allowlist.ts @@ -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; 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> => { + 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() + 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> => { + const now = Date.now() + if (cache && now - cache.at < CACHE_TTL_MS) return cache.accountIds + + let accountIds: Set + 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 => { + 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 => { + try { + return (await loadAllowlist()).size === 0 + } catch { + return true + } +} diff --git a/test/flash/unit/app/invite/referral-payout-allowlist.spec.ts b/test/flash/unit/app/invite/referral-payout-allowlist.spec.ts new file mode 100644 index 000000000..d6082659b --- /dev/null +++ b/test/flash/unit/app/invite/referral-payout-allowlist.spec.ts @@ -0,0 +1,199 @@ +/** + * The ERPNext Referral Payout Allowlist reader. Polarity IS the feature: an + * account is allowlisted only on a readable, enabled, well-formed row. An + * unreadable, missing, or malformed allowlist must read as "nobody", never + * as "everybody" (unlike the fail-open fee-discounts reader it resembles). + */ +const mockGetAllowlist = jest.fn() +const mockErpNext: { getReferralPayoutAllowlist?: (...a: unknown[]) => unknown } = { + getReferralPayoutAllowlist: (...a: unknown[]) => mockGetAllowlist(...a), +} +jest.mock("@services/frappe/ErpNext", () => ({ + __esModule: true, + get default() { + return mockErpNext + }, +})) + +jest.mock("@services/logger", () => ({ + baseLogger: { info: jest.fn(), error: jest.fn(), warn: jest.fn() }, +})) + +import { + accountAllowlistedForReferralPayout, + referralPayoutAllowlistIsEmpty, + resetReferralPayoutAllowlistCache, + validateReferralPayoutAllowlistDoc, +} from "@app/invite/referral-payout-allowlist" +import { ReferralPayoutAllowlistQueryError } from "@services/frappe/errors" +import { baseLogger } from "@services/logger" + +const ACCT_A = "64b7f0c2a1b2c3d4e5f60718" as AccountId +const ACCT_B = "64b7f0c2a1b2c3d4e5f60719" as AccountId +const ACCT_C = "64b7f0c2a1b2c3d4e5f6071a" as AccountId + +const row = (account_id: unknown, enabled: unknown = 1) => ({ account_id, enabled }) + +const realGetAllowlist = mockErpNext.getReferralPayoutAllowlist + +beforeEach(() => { + jest.clearAllMocks() + jest.useRealTimers() + mockErpNext.getReferralPayoutAllowlist = realGetAllowlist + resetReferralPayoutAllowlistCache() +}) + +describe("validateReferralPayoutAllowlistDoc", () => { + it("returns the account id of an enabled, well-formed row", () => { + expect(validateReferralPayoutAllowlistDoc(row(ACCT_A))).toBe(ACCT_A) + }) + + it("trims and lowercases the account id", () => { + expect(validateReferralPayoutAllowlistDoc(row(` ${ACCT_A.toUpperCase()} `))).toBe( + ACCT_A, + ) + }) + + it.each([ + ["enabled: 0", row(ACCT_A, 0)], + ['enabled: "0"', row(ACCT_A, "0")], + ["enabled: false", row(ACCT_A, false)], + ["enabled missing", { account_id: ACCT_A }], + ["blank account_id", row("")], + ["account_id missing", { enabled: 1 }], + ["account_id too short", row("64b7f0c2a1b2c3d4e5f607")], + ["account_id not hex", row("zzb7f0c2a1b2c3d4e5f60718")], + ["account_id a username", row("civilizedbarbarian")], + ["account_id a UUID", row("6b1c0e8e-5c3f-4c1e-9f6e-2a1b3c4d5e6f")], + ["account_id a number", row(12345)], + ["null doc", null], + ["string doc", "64b7f0c2a1b2c3d4e5f60718"], + ])("drops the row when %s", (_label, doc) => { + expect(validateReferralPayoutAllowlistDoc(doc)).toBeUndefined() + }) +}) + +describe("accountAllowlistedForReferralPayout", () => { + it("is true for an account on an enabled row", async () => { + mockGetAllowlist.mockResolvedValue([row(ACCT_A)]) + + await expect(accountAllowlistedForReferralPayout(ACCT_A)).resolves.toBe(true) + }) + + it("is false for a different account", async () => { + mockGetAllowlist.mockResolvedValue([row(ACCT_A)]) + + await expect(accountAllowlistedForReferralPayout(ACCT_B)).resolves.toBe(false) + }) + + it("matches regardless of the case the row was stored in", async () => { + mockGetAllowlist.mockResolvedValue([row(ACCT_A.toUpperCase())]) + + await expect(accountAllowlistedForReferralPayout(ACCT_A)).resolves.toBe(true) + }) + + it("drops disabled and malformed rows (even if the ERP filter is lost) but keeps the rest", async () => { + mockGetAllowlist.mockResolvedValue([ + row(ACCT_A, 0), + row(ACCT_B, "0"), + row("not-an-account-id"), + null, + row(ACCT_C), + ]) + + await expect(accountAllowlistedForReferralPayout(ACCT_A)).resolves.toBe(false) + await expect(accountAllowlistedForReferralPayout(ACCT_B)).resolves.toBe(false) + await expect(accountAllowlistedForReferralPayout(ACCT_C)).resolves.toBe(true) + expect(baseLogger.warn).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining("dropping"), + ) + }) + + it("fails CLOSED on an ERP read error, and warns", async () => { + mockGetAllowlist.mockResolvedValue(new ReferralPayoutAllowlistQueryError("erp down")) + + await expect(accountAllowlistedForReferralPayout(ACCT_A)).resolves.toBe(false) + expect(baseLogger.warn).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining("EMPTY"), + ) + }) + + it("fails CLOSED when the ERP response is not an array", async () => { + mockGetAllowlist.mockResolvedValue({ account_id: ACCT_A, enabled: 1 }) + + await expect(accountAllowlistedForReferralPayout(ACCT_A)).resolves.toBe(false) + }) + + it("fails CLOSED when the ERP client throws, and never throws itself", async () => { + mockGetAllowlist.mockRejectedValue(new Error("boom")) + + await expect(accountAllowlistedForReferralPayout(ACCT_A)).resolves.toBe(false) + }) + + it("fails CLOSED when the ErpNext client has no allowlist method", async () => { + mockErpNext.getReferralPayoutAllowlist = undefined + + await expect(accountAllowlistedForReferralPayout(ACCT_A)).resolves.toBe(false) + }) + + it("memoises for the TTL: a KYC burst is one ERP read", async () => { + mockGetAllowlist.mockResolvedValue([row(ACCT_A)]) + + await accountAllowlistedForReferralPayout(ACCT_A) + await accountAllowlistedForReferralPayout(ACCT_B) + await referralPayoutAllowlistIsEmpty() + + expect(mockGetAllowlist).toHaveBeenCalledTimes(1) + }) + + it("caches failures too, so an ERP outage cannot become a retry storm", async () => { + mockGetAllowlist.mockResolvedValue(new ReferralPayoutAllowlistQueryError("erp down")) + + await accountAllowlistedForReferralPayout(ACCT_A) + await accountAllowlistedForReferralPayout(ACCT_A) + + expect(mockGetAllowlist).toHaveBeenCalledTimes(1) + }) + + it("stops matching a removed row once the 60s TTL lapses", async () => { + jest.useFakeTimers({ now: new Date("2026-10-10T00:00:00Z") }) + mockGetAllowlist.mockResolvedValueOnce([row(ACCT_A)]).mockResolvedValueOnce([]) + + await expect(accountAllowlistedForReferralPayout(ACCT_A)).resolves.toBe(true) + + jest.setSystemTime(new Date("2026-10-10T00:00:59Z")) + await expect(accountAllowlistedForReferralPayout(ACCT_A)).resolves.toBe(true) + + jest.setSystemTime(new Date("2026-10-10T00:01:00Z")) + await expect(accountAllowlistedForReferralPayout(ACCT_A)).resolves.toBe(false) + expect(mockGetAllowlist).toHaveBeenCalledTimes(2) + }) +}) + +describe("referralPayoutAllowlistIsEmpty", () => { + it("is true when no rows are enabled", async () => { + mockGetAllowlist.mockResolvedValue([]) + + await expect(referralPayoutAllowlistIsEmpty()).resolves.toBe(true) + }) + + it("is true when every row is malformed or disabled", async () => { + mockGetAllowlist.mockResolvedValue([row(ACCT_A, 0), row("bad")]) + + await expect(referralPayoutAllowlistIsEmpty()).resolves.toBe(true) + }) + + it("is false when at least one valid row is enabled", async () => { + mockGetAllowlist.mockResolvedValue([row(ACCT_A)]) + + await expect(referralPayoutAllowlistIsEmpty()).resolves.toBe(false) + }) + + it("is true (fail closed) on an ERP read error", async () => { + mockGetAllowlist.mockResolvedValue(new ReferralPayoutAllowlistQueryError("erp down")) + + await expect(referralPayoutAllowlistIsEmpty()).resolves.toBe(true) + }) +}) From b1de8b702053084cd750b0a169c929608c74a739 Mon Sep 17 00:00:00 2001 From: Dread Date: Sat, 10 Oct 2026 19:25:48 -0700 Subject: [PATCH 3/6] feat(invite): pay allowlisted referrals while the program is paused (ENG-640) With referralReward.enabled=false, an empty allowlist still returns before any Mongo query. Otherwise a referral proceeds when the invitee or the inviter is allowlisted, and both parties are paid per the tier. The ERPNext Referral Settings switch still gates every payout. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01WxkKbrg2Xohf3mQj7BLrs6 --- src/app/invite/award-referral-reward.ts | 34 ++- .../app/invite/award-referral-reward.spec.ts | 264 ++++++++++++++++++ 2 files changed, 297 insertions(+), 1 deletion(-) diff --git a/src/app/invite/award-referral-reward.ts b/src/app/invite/award-referral-reward.ts index 9aaa24929..d30327dcb 100644 --- a/src/app/invite/award-referral-reward.ts +++ b/src/app/invite/award-referral-reward.ts @@ -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" @@ -45,6 +49,10 @@ 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, }: { @@ -52,7 +60,17 @@ export const awardReferralRewardOnKycApproval = async ({ }): Promise => { 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 @@ -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 diff --git a/test/flash/unit/app/invite/award-referral-reward.spec.ts b/test/flash/unit/app/invite/award-referral-reward.spec.ts index 5bb7603b8..c6259b91d 100644 --- a/test/flash/unit/app/invite/award-referral-reward.spec.ts +++ b/test/flash/unit/app/invite/award-referral-reward.spec.ts @@ -48,6 +48,13 @@ jest.mock("@app/invite/referral-settings", () => ({ referralRewardsEnabledInErp: (...a: unknown[]) => mockErpEnabled(...a), })) +const mockAllowlistEmpty = jest.fn() +const mockAllowlisted = jest.fn() +jest.mock("@app/invite/referral-payout-allowlist", () => ({ + referralPayoutAllowlistIsEmpty: (...a: unknown[]) => mockAllowlistEmpty(...a), + accountAllowlistedForReferralPayout: (...a: unknown[]) => mockAllowlisted(...a), +})) + const mockRewardPush = jest.fn() jest.mock("@app/invite/send-referral-notifications", () => ({ sendReferralRewardNotificationBestEffort: (...a: unknown[]) => mockRewardPush(...a), @@ -96,6 +103,9 @@ describe("awardReferralRewardOnKycApproval", () => { beforeEach(() => { mockErpEnabled.mockResolvedValue(true) jest.clearAllMocks() + // Allowlist defaults: nobody listed (the prod default). + mockAllowlistEmpty.mockResolvedValue(true) + mockAllowlisted.mockResolvedValue(false) mockGetConfig.mockReturnValue({ enabled: true, tiers: DEFAULT_TIERS }) mockExists.mockResolvedValue(null) // no prior processed invite for the account mockFindOne.mockResolvedValue(pendingInvite()) @@ -116,6 +126,10 @@ describe("awardReferralRewardOnKycApproval", () => { await awardReferralRewardOnKycApproval({ accountId: INVITEE }) expect(mockFindOne).not.toHaveBeenCalled() expect(mockPay).not.toHaveBeenCalled() + // Paused + empty allowlist: one in-memory size check, zero Mongo. + expect(mockAllowlistEmpty).toHaveBeenCalledTimes(1) + expect(mockAllowlisted).not.toHaveBeenCalled() + expect(mockExists).not.toHaveBeenCalled() }) it("defers (no claim, no failure mark) when the ERPNext kill switch is off", async () => { @@ -407,3 +421,253 @@ describe("awardReferralRewardOnKycApproval", () => { expect(baseLogger.error).toHaveBeenCalled() }) }) + +describe("awardReferralRewardOnKycApproval: Referral Payout Allowlist gate", () => { + type Listed = "invitee" | "inviter" | "neither" + + const listedIds = (listed: Listed): string[] => + listed === "invitee" ? [INVITEE] : listed === "inviter" ? [INVITER] : [] + + const setup = ({ + configEnabled, + erpEnabled, + allowlistEmpty, + listed, + }: { + configEnabled: boolean + erpEnabled: boolean + allowlistEmpty: boolean + listed: Listed + }) => { + mockGetConfig.mockReturnValue({ enabled: configEnabled, tiers: DEFAULT_TIERS }) + mockErpEnabled.mockResolvedValue(erpEnabled) + mockAllowlistEmpty.mockResolvedValue(allowlistEmpty) + const ids = allowlistEmpty ? [] : listedIds(listed) + mockAllowlisted.mockImplementation((id: string) => Promise.resolve(ids.includes(id))) + } + + const expectPaidBoth = () => { + expect(mockFindOneAndUpdate).toHaveBeenCalledTimes(1) + expect(mockPay).toHaveBeenCalledTimes(2) + expect(mockPay).toHaveBeenCalledWith( + expect.objectContaining({ recipientWalletId: "inviter-usdt", amount: 500 }), + ) + expect(mockPay).toHaveBeenCalledWith( + expect.objectContaining({ recipientWalletId: "invitee-usdt", amount: 500 }), + ) + expect(lastSet().rewardStatus).toBe("paid") + } + + const expectDeferred = (reason: string) => { + expect(mockFindOne).toHaveBeenCalled() + expect(mockFindOneAndUpdate).not.toHaveBeenCalled() + expect(mockPay).not.toHaveBeenCalled() + expect(mockUpdateOne).not.toHaveBeenCalled() + expect(mockNextSeq).not.toHaveBeenCalled() + expect(baseLogger.info).toHaveBeenCalledWith( + expect.objectContaining({ accountId: INVITEE }), + expect.stringContaining(reason), + ) + } + + const expectNoMongo = () => { + expect(mockExists).not.toHaveBeenCalled() + expect(mockFindOne).not.toHaveBeenCalled() + expect(mockFindOneAndUpdate).not.toHaveBeenCalled() + expect(mockErpEnabled).not.toHaveBeenCalled() + expect(mockPay).not.toHaveBeenCalled() + expect(mockUpdateOne).not.toHaveBeenCalled() + } + + beforeEach(() => { + jest.clearAllMocks() + mockExists.mockResolvedValue(null) + mockFindOne.mockResolvedValue(pendingInvite()) + mockFindOneAndUpdate.mockResolvedValue(pendingInvite()) + mockNextSeq.mockResolvedValue(50) + mockFindByRole.mockResolvedValue({ id: REWARDS_ACCT }) + useWallets({ + [REWARDS_ACCT]: [usd("rewards-usd"), usdt("rewards-usdt")], + [INVITER]: [usd("inviter-usd"), usdt("inviter-usdt")], + [INVITEE]: [usd("invitee-usd"), usdt("invitee-usdt")], + }) + mockPay.mockResolvedValue(PaymentSendStatus.Success) + }) + + describe("program paused (config.enabled=false), allowlist non-empty", () => { + it.each(["invitee", "inviter"])( + "%s allowlisted + ERP switch ON: pays BOTH parties per the tier", + async (listed) => { + setup({ configEnabled: false, erpEnabled: true, allowlistEmpty: false, listed }) + + await awardReferralRewardOnKycApproval({ accountId: INVITEE }) + + expect(mockErpEnabled).toHaveBeenCalled() + expectPaidBoth() + }, + ) + + it.each(["invitee", "inviter"])( + "%s allowlisted + ERP switch OFF: deferred by the master switch", + async (listed) => { + setup({ configEnabled: false, erpEnabled: false, allowlistEmpty: false, listed }) + + await awardReferralRewardOnKycApproval({ accountId: INVITEE }) + + expect(mockErpEnabled).toHaveBeenCalled() + expectDeferred("ERPNext kill switch") + }, + ) + + it.each([true, false])( + "neither party allowlisted (ERP switch %s): deferred as not allowlisted, ERP never read", + async (erpEnabled) => { + setup({ + configEnabled: false, + erpEnabled, + allowlistEmpty: false, + listed: "neither", + }) + + await awardReferralRewardOnKycApproval({ accountId: INVITEE }) + + expect(mockAllowlisted).toHaveBeenCalledWith(INVITEE) + expect(mockAllowlisted).toHaveBeenCalledWith(INVITER) + expect(mockErpEnabled).not.toHaveBeenCalled() + expectDeferred("not allowlisted") + }, + ) + + it("allowlisted invitee proceeds without consulting the inviter", async () => { + setup({ + configEnabled: false, + erpEnabled: true, + allowlistEmpty: false, + listed: "invitee", + }) + + await awardReferralRewardOnKycApproval({ accountId: INVITEE }) + + expect(mockAllowlisted).toHaveBeenCalledTimes(1) + expect(mockAllowlisted).toHaveBeenCalledWith(INVITEE) + }) + + it("allowlisted party whose account was already rewarded is never re-paid", async () => { + setup({ + configEnabled: false, + erpEnabled: true, + allowlistEmpty: false, + listed: "invitee", + }) + mockExists.mockResolvedValue({ _id: "earlier-invite" }) + + await awardReferralRewardOnKycApproval({ accountId: INVITEE }) + + expect(mockFindOne).not.toHaveBeenCalled() + expect(mockFindOneAndUpdate).not.toHaveBeenCalled() + expect(mockPay).not.toHaveBeenCalled() + }) + + it("allowlisted inviter + non-referred KYC approval: no log, no ERP read", async () => { + setup({ + configEnabled: false, + erpEnabled: true, + allowlistEmpty: false, + listed: "inviter", + }) + mockFindOne.mockResolvedValue(null) + + await awardReferralRewardOnKycApproval({ accountId: INVITEE }) + + expect(mockErpEnabled).not.toHaveBeenCalled() + expect(baseLogger.info).not.toHaveBeenCalled() + expect(mockPay).not.toHaveBeenCalled() + }) + + it("invite with no inviterId is deferred, never paid", async () => { + setup({ + configEnabled: false, + erpEnabled: true, + allowlistEmpty: false, + listed: "inviter", + }) + mockFindOne.mockResolvedValue({ ...pendingInvite(), inviterId: undefined }) + + await awardReferralRewardOnKycApproval({ accountId: INVITEE }) + + expect(mockErpEnabled).not.toHaveBeenCalled() + expectDeferred("not allowlisted") + }) + + it("allowlisted payouts advance the global tier counter", async () => { + setup({ + configEnabled: false, + erpEnabled: true, + allowlistEmpty: false, + listed: "inviter", + }) + + await awardReferralRewardOnKycApproval({ accountId: INVITEE }) + + expect(mockNextSeq).toHaveBeenCalledTimes(1) + expect(lastSet().rewardSeq).toBe(50) + }) + }) + + describe("program paused, allowlist EMPTY (prod default)", () => { + it.each([ + [true, "invitee"], + [false, "invitee"], + [true, "neither"], + [false, "neither"], + ] as const)( + "ERP switch %s, %s: zero Mongo, zero ERP, no pay", + async (erpEnabled, listed) => { + setup({ configEnabled: false, erpEnabled, allowlistEmpty: true, listed }) + + await awardReferralRewardOnKycApproval({ accountId: INVITEE }) + + expectNoMongo() + expect(mockAllowlisted).not.toHaveBeenCalled() + }, + ) + }) + + describe("program enabled (config.enabled=true): allowlist is never consulted", () => { + it.each<[boolean, Listed]>([ + [false, "neither"], + [true, "invitee"], + [true, "inviter"], + [true, "neither"], + ])( + "allowlist non-empty=%s, listed=%s, ERP ON: pays as today", + async (nonEmpty, listed) => { + setup({ + configEnabled: true, + erpEnabled: true, + allowlistEmpty: !nonEmpty, + listed, + }) + + await awardReferralRewardOnKycApproval({ accountId: INVITEE }) + + expect(mockAllowlistEmpty).not.toHaveBeenCalled() + expect(mockAllowlisted).not.toHaveBeenCalled() + expectPaidBoth() + }, + ) + + it.each(["invitee", "inviter", "neither"])( + "listed=%s, ERP OFF: deferred by the master switch as today", + async (listed) => { + setup({ configEnabled: true, erpEnabled: false, allowlistEmpty: false, listed }) + + await awardReferralRewardOnKycApproval({ accountId: INVITEE }) + + expect(mockAllowlistEmpty).not.toHaveBeenCalled() + expect(mockAllowlisted).not.toHaveBeenCalled() + expectDeferred("ERPNext kill switch") + }, + ) + }) +}) From ffc8488d959a4abbc0e8ee4c5305ffb7f1f53354 Mon Sep 17 00:00:00 2001 From: Dread Date: Sat, 10 Oct 2026 19:25:48 -0700 Subject: [PATCH 4/6] feat(invite): filter the deferred-reward sweep by account or inviter (ENG-640) --account/--username narrow to one invitee and --inviter to one inviter, inside the Mongo query. The result reports whether the filtered party is allowlisted, and an empty filtered backlog warns. Usage text: fund the rewards wallet first, dry-run with --username, then execute. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01WxkKbrg2Xohf3mQj7BLrs6 --- src/app/invite/retry-deferred-rewards.ts | 55 +++++++- .../retry-deferred-referral-rewards.ts | 57 +++++++- .../app/invite/retry-deferred-rewards.spec.ts | 129 ++++++++++++++++++ .../retry-deferred-referral-rewards.spec.ts | 87 +++++++++++- 4 files changed, 321 insertions(+), 7 deletions(-) diff --git a/src/app/invite/retry-deferred-rewards.ts b/src/app/invite/retry-deferred-rewards.ts index 35209ec2f..c4d687e55 100644 --- a/src/app/invite/retry-deferred-rewards.ts +++ b/src/app/invite/retry-deferred-rewards.ts @@ -9,6 +9,12 @@ * 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" @@ -16,10 +22,14 @@ 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 } /** @@ -30,13 +40,18 @@ export type RetryDeferredReferralRewardsResult = { */ export const retryDeferredReferralRewards = async ({ dryRun, + account, + inviter, }: { dryRun: boolean + account?: AccountId + inviter?: AccountId }): Promise => { 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 @@ -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, + } } diff --git a/src/scripts/retry-deferred-referral-rewards.ts b/src/scripts/retry-deferred-referral-rewards.ts index a052bc8cb..4de4021b9 100644 --- a/src/scripts/retry-deferred-referral-rewards.ts +++ b/src/scripts/retry-deferred-referral-rewards.ts @@ -11,12 +11,25 @@ * Usage: * node lib/scripts/retry-deferred-referral-rewards.js \ * --configPath dev/config/base-config.yaml \ - * [--dry-run] + * [--dry-run] \ + * [--account | --username ] \ + * [--inviter ] + * + * --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 (an underfunded payout is recorded as + * "partial" and needs manual reconciliation); + * 2. dry-run with --username --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" @@ -28,12 +41,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 diff --git a/test/flash/unit/app/invite/retry-deferred-rewards.spec.ts b/test/flash/unit/app/invite/retry-deferred-rewards.spec.ts index 25dbe0e5a..4b12e0c48 100644 --- a/test/flash/unit/app/invite/retry-deferred-rewards.spec.ts +++ b/test/flash/unit/app/invite/retry-deferred-rewards.spec.ts @@ -19,6 +19,11 @@ jest.mock("@app/invite/award-referral-reward", () => ({ awardReferralRewardOnKycApproval: (...a: unknown[]) => mockAward(...a), })) +const mockAllowlisted = jest.fn() +jest.mock("@app/invite/referral-payout-allowlist", () => ({ + accountAllowlistedForReferralPayout: (...a: unknown[]) => mockAllowlisted(...a), +})) + jest.mock("@services/logger", () => ({ baseLogger: { info: jest.fn(), error: jest.fn(), warn: jest.fn() }, })) @@ -84,4 +89,128 @@ describe("retryDeferredReferralRewards", () => { expect(mockAward).not.toHaveBeenCalled() expect(result).toEqual({ backlogCount: 1, accountsRetried: 0 }) }) + + describe("filtered to one account / inviter (Referral Payout Allowlist)", () => { + const ACCOUNT = "64b7f0c2a1b2c3d4e5f60718" as AccountId + const INVITER = "64b7f0c2a1b2c3d4e5f60719" as AccountId + + beforeEach(() => { + mockAllowlisted.mockResolvedValue(true) + }) + + it("filters the query by redeemedById inside Mongo, not after loading", async () => { + mockFind.mockResolvedValue([]) + await retryDeferredReferralRewards({ dryRun: true, account: ACCOUNT }) + expect(mockFind).toHaveBeenCalledWith({ + status: InviteStatus.ACCEPTED, + rewardStatus: { $exists: false }, + redeemedById: ACCOUNT, + }) + }) + + it("filters the query by inviterId when an inviter is given", async () => { + mockFind.mockResolvedValue([]) + await retryDeferredReferralRewards({ dryRun: true, inviter: INVITER }) + expect(mockFind).toHaveBeenCalledWith({ + status: InviteStatus.ACCEPTED, + rewardStatus: { $exists: false }, + redeemedById: { $exists: true }, + inviterId: INVITER, + }) + }) + + it("combines both filters", async () => { + mockFind.mockResolvedValue([]) + await retryDeferredReferralRewards({ + dryRun: true, + account: ACCOUNT, + inviter: INVITER, + }) + expect(mockFind).toHaveBeenCalledWith({ + status: InviteStatus.ACCEPTED, + rewardStatus: { $exists: false }, + redeemedById: ACCOUNT, + inviterId: INVITER, + }) + }) + + it.each([true, false])( + "dry-run reports whether the account is allowlisted (%s)", + async (listed) => { + mockAllowlisted.mockResolvedValue(listed) + mockFind.mockResolvedValue([deferredInvite(ACCOUNT)]) + const result = await retryDeferredReferralRewards({ + dryRun: true, + account: ACCOUNT, + }) + expect(mockAllowlisted).toHaveBeenCalledWith(ACCOUNT) + expect(result).toEqual({ + backlogCount: 1, + accountsRetried: 0, + allowlisted: listed, + }) + expect(mockAward).not.toHaveBeenCalled() + expect(baseLogger.info).toHaveBeenCalledWith( + expect.objectContaining({ account: ACCOUNT, allowlisted: listed }), + expect.stringContaining("backlog"), + ) + }, + ) + + it("reports whether the inviter is allowlisted", async () => { + mockAllowlisted.mockResolvedValue(false) + mockFind.mockResolvedValue([deferredInvite("acct-1")]) + const result = await retryDeferredReferralRewards({ + dryRun: true, + inviter: INVITER, + }) + expect(mockAllowlisted).toHaveBeenCalledWith(INVITER) + expect(result).toEqual({ + backlogCount: 1, + accountsRetried: 0, + inviterAllowlisted: false, + }) + }) + + it("warns, naming the account, when the filtered backlog is empty", async () => { + mockFind.mockResolvedValue([]) + await retryDeferredReferralRewards({ dryRun: true, account: ACCOUNT }) + expect(baseLogger.warn).toHaveBeenCalledWith( + expect.objectContaining({ account: ACCOUNT }), + expect.stringContaining("no accepted + unrewarded invite"), + ) + }) + + it("does not warn for an empty unfiltered backlog", async () => { + mockFind.mockResolvedValue([]) + await retryDeferredReferralRewards({ dryRun: true }) + expect(baseLogger.warn).not.toHaveBeenCalled() + expect(mockAllowlisted).not.toHaveBeenCalled() + }) + + it("replays the award only for the filtered account", async () => { + mockFind.mockResolvedValue([deferredInvite(ACCOUNT)]) + const result = await retryDeferredReferralRewards({ + dryRun: false, + account: ACCOUNT, + }) + expect(mockAward).toHaveBeenCalledTimes(1) + expect(mockAward).toHaveBeenCalledWith({ accountId: ACCOUNT }) + expect(result).toEqual({ backlogCount: 1, accountsRetried: 1, allowlisted: true }) + }) + + it("replays every invitee of the filtered inviter", async () => { + mockFind.mockResolvedValue([deferredInvite("acct-1"), deferredInvite("acct-2")]) + const result = await retryDeferredReferralRewards({ + dryRun: false, + inviter: INVITER, + }) + expect(mockAward).toHaveBeenCalledTimes(2) + expect(result).toEqual({ + backlogCount: 2, + accountsRetried: 2, + inviterAllowlisted: true, + }) + }) + }) }) diff --git a/test/flash/unit/scripts/retry-deferred-referral-rewards.spec.ts b/test/flash/unit/scripts/retry-deferred-referral-rewards.spec.ts index 8eef8ed63..2ded19711 100644 --- a/test/flash/unit/scripts/retry-deferred-referral-rewards.spec.ts +++ b/test/flash/unit/scripts/retry-deferred-referral-rewards.spec.ts @@ -21,11 +21,27 @@ jest.mock("@services/logger", () => ({ baseLogger: { info: jest.fn(), error: jest.fn(), warn: jest.fn() }, })) +const mockFindByUsername = jest.fn() +jest.mock("@services/mongoose", () => ({ + AccountsRepository: () => ({ + findByUsername: (...a: unknown[]) => mockFindByUsername(...a), + }), +})) + jest.mock("@app/invite/retry-deferred-rewards", () => ({ retryDeferredReferralRewards: jest.fn(), })) -import { buildArgs } from "../../../../src/scripts/retry-deferred-referral-rewards" +import { CouldNotFindAccountFromUsernameError } from "@domain/errors" + +import { + buildArgs, + resolveFilters, +} from "../../../../src/scripts/retry-deferred-referral-rewards" + +const CONFIG = ["--configPath", "dev/config/base-config.yaml"] +const ACCOUNT = "64b7f0c2a1b2c3d4e5f60718" +const INVITER = "64b7f0c2a1b2c3d4e5f60719" describe("retry-deferred-referral-rewards buildArgs", () => { it("defaults dry-run to false when the flag is omitted, matching the usage doc", () => { @@ -40,3 +56,72 @@ describe("retry-deferred-referral-rewards buildArgs", () => { expect(args["dry-run"]).toBe(true) }) }) + +describe("retry-deferred-referral-rewards filters", () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it("parses --account, --username and --inviter", () => { + expect(buildArgs([...CONFIG, "--account", ACCOUNT]).account).toBe(ACCOUNT) + expect(buildArgs([...CONFIG, "--username", "alice"]).username).toBe("alice") + expect(buildArgs([...CONFIG, "--inviter", INVITER]).inviter).toBe(INVITER) + }) + + it("leaves every filter unset by default (whole backlog)", () => { + const args = buildArgs(CONFIG) + expect(args.account).toBeUndefined() + expect(args.username).toBeUndefined() + expect(args.inviter).toBeUndefined() + }) + + it("rejects --account together with --username", () => { + const exit = jest.spyOn(process, "exit").mockImplementation((() => { + throw new Error("process.exit") + }) as never) + const consoleError = jest.spyOn(console, "error").mockImplementation(() => undefined) + try { + expect(() => + buildArgs([...CONFIG, "--account", ACCOUNT, "--username", "alice"]), + ).toThrow() + } finally { + exit.mockRestore() + consoleError.mockRestore() + } + }) + + it("resolves --username to its account id", async () => { + mockFindByUsername.mockResolvedValue({ id: ACCOUNT }) + await expect(resolveFilters({ username: " alice " })).resolves.toEqual({ + account: ACCOUNT, + inviter: undefined, + }) + expect(mockFindByUsername).toHaveBeenCalledWith("alice") + }) + + it("throws (exit 1) when --username is not found", async () => { + mockFindByUsername.mockResolvedValue(new CouldNotFindAccountFromUsernameError()) + await expect(resolveFilters({ username: "ghost" })).rejects.toThrow("ghost") + }) + + it("passes --account and --inviter through after validating their shape", async () => { + await expect(resolveFilters({ account: ACCOUNT, inviter: INVITER })).resolves.toEqual( + { account: ACCOUNT, inviter: INVITER }, + ) + expect(mockFindByUsername).not.toHaveBeenCalled() + }) + + it.each(["account", "inviter"] as const)( + "throws on a malformed --%s instead of sweeping nothing silently", + async (flag) => { + await expect(resolveFilters({ [flag]: "alice" })).rejects.toThrow(`--${flag}`) + }, + ) + + it("returns no filters when none are given", async () => { + await expect(resolveFilters({})).resolves.toEqual({ + account: undefined, + inviter: undefined, + }) + }) +}) From 47a398472ee5d03d1f7042f4f1494f4b2151bef4 Mon Sep 17 00:00:00 2001 From: Dread Date: Sat, 10 Oct 2026 19:29:25 -0700 Subject: [PATCH 5/6] docs(invite): fund rewards wallet before adding allowlist row Review fix: the operator usage text now states the wallet must be funded before the Referral Payout Allowlist row is added, and that an underfunded payout is recorded as "partial" or "failed" (award-referral-reward.ts writes "failed" when neither party was paid). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01G4SaLJF5SWq6sjBEaWJFow --- src/scripts/retry-deferred-referral-rewards.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/scripts/retry-deferred-referral-rewards.ts b/src/scripts/retry-deferred-referral-rewards.ts index 4de4021b9..2b1142107 100644 --- a/src/scripts/retry-deferred-referral-rewards.ts +++ b/src/scripts/retry-deferred-referral-rewards.ts @@ -18,8 +18,9 @@ * --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 (an underfunded payout is recorded as - * "partial" and needs manual reconciliation); + * 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 --dry-run and check the logged * backlogCount and allowlisted=true; * 3. execute the same command without --dry-run. From db2ed1c956b4f74f41c6d2e2cbea662a203df55b Mon Sep 17 00:00:00 2001 From: Dread Date: Sat, 10 Oct 2026 19:39:32 -0700 Subject: [PATCH 6/6] chore(deps): pin handlebars 4.7.10 via resolutions (Audit critical) `make audit` (yarn audit --level critical) fails on main since the two Handlebars advisories published with 4.7.10 (JS injection via AST type confusion; via own-property-check bypass). handlebars is a transitive dep of grpc_tools_node_protoc_ts and spectaql only; the existing `**/handlebars` resolution pinned 4.7.9. Bump it to 4.7.10. No runtime code path uses handlebars. Verified: `yarn audit --level critical` exit 14 (no critical) after the bump, 30 before. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01G4SaLJF5SWq6sjBEaWJFow (cherry picked from commit df358ae24efca59f9eb2636cae6af51d055c54b7) --- package.json | 2 +- yarn.lock | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 4aed9fd81..c3cbbfbc1 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/yarn.lock b/yarn.lock index e8897caaf..7507349ce 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8606,12 +8606,12 @@ gzip-size@^5.1.1: duplexer "^0.1.1" pify "^4.0.1" -handlebars@4.7.7, handlebars@4.7.9, handlebars@^4.7.7: - version "4.7.9" - resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.9.tgz#6f139082ab58dc4e5a0e51efe7db5ae890d56a0f" - integrity sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ== +handlebars@4.7.10, handlebars@4.7.7, handlebars@^4.7.7: + version "4.7.10" + resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.10.tgz#12419ea1b41391b3ffcf003f2164dcbce090c417" + integrity sha512-P5VJMVM7qgBn6vjXMw8WG9uVI+ncf2pi72j4de4yz5ZULLj2RGqLYaKOYGsgyrViQ0tePOVlN1tDCCXXtFqXKg== dependencies: - minimist "^1.2.5" + minimist "^1.2.8" neo-async "^2.6.2" source-map "^0.6.1" wordwrap "^1.0.0" @@ -10766,7 +10766,7 @@ minimatch@^5.1.0: dependencies: brace-expansion "^2.0.1" -minimist@^1.2.0, minimist@^1.2.5, minimist@^1.2.6: +minimist@^1.2.0, minimist@^1.2.5, minimist@^1.2.6, minimist@^1.2.8: version "1.2.8" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==