Skip to content

feat: ERPNext kill switch for referral reward payouts - #501

Open
islandbitcoin wants to merge 4 commits into
mainfrom
feat/referral-rewards-erp-kill-switch
Open

feat: ERPNext kill switch for referral reward payouts#501
islandbitcoin wants to merge 4 commits into
mainfrom
feat/referral-rewards-erp-kill-switch

Conversation

@islandbitcoin

Copy link
Copy Markdown
Contributor

Backend half of the referral-rewards kill switch (ERP half: lnflash/frappe-flash-admin#75). Requested after the 2026-09-01 referral-farming wave: 87 same-day accounts, 288 invites, invitee→inviter chains maxing the daily limit within their first hour, ~$100 paid before detection.

Design

  • referralRewardsEnabledInErp() (src/app/invite/referral-settings.ts): cached (60s, failures cached too — an ERP outage degrades to deferral without a fetch storm) reader of the Referral Settings single. Returns true only on an affirmative, readable rewards_enabled=1; off / unreadable / missing / malformed are all "do not pay".
  • Gate placement is the safety argument: it runs in awardReferralRewardOnKycApproval BEFORE the atomic reward claim. Returning there leaves the invite ACCEPTED + unrewarded — a deferred payout, not a failed one. It pays on any later trigger (KYC re-fire, or an ops sweep of accepted+unrewarded invites) once the switch is re-enabled. Nothing marked failed, nothing lost, no double-pay risk.
  • The existing deployments-level referralReward.enabled flag is unchanged and still gates first — but it needs a deploy to flip. This gives the operator a ~60s toggle in the ERPNext desk.
  • ErpNext.getReferralSettings() + ReferralSettingsQueryError mirror the Fygaro Settings reader exactly.

Ops notes

  • Rewards accrued while the switch is OFF pay later only when something re-invokes the award for that account. The banked settle-unredeemed-referrals-style sweep (or a KYC status flap) covers this; if OFF becomes long-lived, a small retry-deferred-rewards script is the follow-up.
  • Deploy order is safe in either direction: flash-first = missing doctype reads as unreadable = payouts defer until make erp lands the single (which defaults to ON).

Tests

  • referral-settings.spec.ts: affirmative-only polarity (on/off/missing/malformed/read-error), fail-closed on outage, TTL memoisation of successes AND failures.
  • award-referral-reward.spec.ts: two new cases pinning deferral semantics — switch off ⇒ no query, no claim, no payment, no failure mark.
  • Full unit suite 224/224 (2428 tests), tsc + eslint clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NEoz7nBtdtsHyuYG5wNPQV

bobodread876 and others added 4 commits September 1, 2026 14:23
Reads the new 'Referral Settings' Single (frappe-flash-admin#75) before
every payout, through a 60s cache. Polarity is the feature: money moves
only on an affirmative, readable rewards_enabled=1 — off, unreadable,
missing, and malformed all mean do-not-pay.

Fail-closed is safe here because the gate runs BEFORE the atomic reward
claim: returning leaves the invite ACCEPTED and unrewarded, a DEFERRED
payout that pays on any later trigger once the switch is back on.
Nothing is marked failed, nothing is lost, and an ERP outage cannot
strand or double-pay a reward.

Context: 2026-09-01 referral-farming wave (87 fresh accounts, 288
invites in a day, invitee->inviter chains). The deployments-level
referralReward.enabled flag still exists but needs a deploy; this gives
the operator a ~60s toggle in ERPNext.
- Move the ERPNext kill-switch check after the mongo invite lookups
  (still before the atomic claim) so non-referred KYC approvals never
  touch the ERP reader or log a bogus 'referral reward deferred' line;
  the deferral log now names only genuinely deferred rewards. Pinned by
  new tests asserting ERP is never consulted for non-referred /
  already-processed accounts.
- Drop the duplicate 'unreadable kill switch' unit test; the calling
  contract is fully pinned by the preceding case and the error->false
  mapping is pinned in referral-settings.spec.ts.
- Update the consumers list in src/services/frappe/coerce.ts to include
  app/invite/referral-settings.ts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEoz7nBtdtsHyuYG5wNPQV
…liation

- src/services/frappe/ErpNext.ts: move getReferralSettings back below its
  own doc comment; it had drifted above getFeeDiscounts, leaving the Fee
  Discount explanation orphaned over the wrong method and getFeeDiscounts
  with no comment at all.
- Add the retry-deferred-referral-rewards sweep promised in the original
  PR's ops notes but never shipped: while the ERPNext kill switch is off,
  KYC approvals leave their invite ACCEPTED + unrewarded (deferred, not
  failed). retryDeferredReferralRewards() finds that backlog and, unless
  --dry-run, replays awardReferralRewardOnKycApproval per distinct account
  — safe because that hook is itself idempotent and fail-closed. Dry-run
  alone also answers the review's minimum ask: an operator flipping the
  switch back on can see the backlog size instead of grepping logs.
- New src/scripts/retry-deferred-referral-rewards.ts CLI wrapper (defaults
  to --dry-run) plus a yarn script entry, mirroring the existing
  reconcile-bridge-ibex-deposits / replay-bridge-events scripts.
- Tests: retry-deferred-rewards.spec.ts covers the exact query shape,
  dry-run reporting, per-account dedup on replay, empty backlog, and
  invites with no redeemedById.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EtQzyzQ38thfC2F8BLK4u8
…reader

Code-review fixes for PR #501:

- retry-deferred-referral-rewards.ts defaulted --dry-run to true while its
  own usage doc said "drop --dry-run to actually replay it" — omitting the
  flag left an operator's documented command paying nobody. Flip the
  default to false, matching every other operator sweep script in this
  repo (replay-bridge-events.ts, cash-wallet-cutover.ts) and the fact that
  replaying is always safe (award hook is idempotent/fail-closed). Extract
  buildArgs() and guard execution behind require.main so the default is
  unit-testable against the real yargs parser.

- ErpNext.getReferralSettings()'s catch block logged the error but never
  called recordExceptionInCurrentSpan, unlike every other query method in
  the class. This is the kill-switch reader consulted on every KYC
  approval, so losing APM visibility into its failures is a real gap
  during an ERP outage. Add the missing call, matching
  getFygaroSettings/getFeeDiscounts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EtQzyzQ38thfC2F8BLK4u8
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants