Skip to content

feat: per-user Flash-fee discounts + payer-email attribution on Fygaro topups - #484

Merged
islandbitcoin merged 6 commits into
mainfrom
feat/fee-discounts
Aug 17, 2026
Merged

feat: per-user Flash-fee discounts + payer-email attribution on Fygaro topups#484
islandbitcoin merged 6 commits into
mainfrom
feat/fee-discounts

Conversation

@islandbitcoin

@islandbitcoin islandbitcoin commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Backed by the new ERPNext Fee Discount doctype (lnflash/frappe-flash-admin#67). Two operator-facing features:

1. Fee Discount whitelist (topups + Jamaican bank cashouts)

A whitelisted username gets a 0–100% discount off the FLASH fee — never processor fees — on Fygaro card top-ups and/or cashout offers.

  • New cached reader src/services/frappe/fee-discounts.ts (60s TTL, mirrors fygaro-settings.ts) that fails OPEN to 0%: an unreadable/malformed whitelist charges the standard fee; it can never block a credit or an offer (deliberately the opposite polarity of Fygaro Settings, which fails closed). Malformed rows are dropped individually, and the operator's Active tick is honoured in the validator, not only in the query filter.
  • computeFygaroFees discounts the whole Flash fee (percent + fixed components), clamped 0–100 so garbage can never inflate a fee. The discounted breakdown lands on the ERPNext audit row (flash_fee, final_amount) and in the credited net. A 100% discount = full waiver and can rescue an otherwise non-positive-net payment.
  • CashoutManager.createOffer applies the discount to the service fee in one multiplyBips step (0.01% precision), before the JMD/USD payout branch — covers both JMD and USD payouts to Jamaican banks. The offer the app displays carries the discounted fee automatically. Bridge/ACH withdrawals untouched.
  • API change — cashoutRate.feeBasisPoints is now per-account. The authed cashoutRate query previously returned the flat config fee; it now returns the caller's discounted fee, so the entry-screen preview matches the offer the user then accepts. That means the query reads ERPNext (the 60s-cached Fee Discount whitelist) on every call, and fails open to the standard fee. Because the field is an Int, the quote is rounded to a whole basis point while the offer keeps full precision and rounds once at the end on money — so a fractional discount_percent can leave the two up to 1 bip apart (at the configured 200-bip fee: any discount that is not a multiple of 0.5%; e.g. 33.4% quotes 133 bips / $6.65 on a $500 cashout while the offer charges 133.2 bips / $6.66). The SDL description states that tolerance and cashout-rate.spec.ts pins it. Whole- and half-percent discounts are exact.
  • Whitelist consulted only when a credit could actually happen (same guard as the history read), so a credit-disabled deploy still never touches ERPNext.

2. Payer-email fallback attribution (username on every topup)

When customReference is blank or unknown (every pre-fix app build), the webhook now resolves the checkout payer email → Kratos identity (email is a login identifier) → Flash account, and stamps account_id on the audit row — so the admin Transfer Requests page shows the username on every top-up, credited or not.

  • DISPLAY-ONLY by design: the checkout email is payer-typed, not verified app identity. The credit path stays keyed strictly on customReference; the email-matched account never feeds the credit gate (test-pinned).
  • The unattributed ops alert now names the matched username as an unverified lead to confirm, not an instruction to credit — no more manual email→kratos→mongo chase.
  • source_systems_seen gains email_attribution so the detail view shows how the row got its account.
  • Best-effort: a Kratos/Mongo failure on the email lookup degrades to today's unattributed row. A repository fault on the customReference lookup is different — it is not "unknown username" and now returns 500 so Fygaro retries (see below).

Known gap — email-attributed rows are EXCLUDED from the daily cap. sumFygaroTopupGrossCentsSince skips any row marked email_attribution, because that account_id came from payer-typed input nobody verified: counting it would let anyone who knows a victim's email burn the victim's daily allowance and lock them out of auto-credit for 24h. The consequence is the inverse, and worth sizing the caps around: payer-email-attributed top-ups do not consume the account's daily allowance, and they still don't after an ops hand-credit — the marker survives in source_systems_seen, and a hand-credit is a human decision taken outside this auto-credit gate. Since a blank customReference is every pre-fix app build, that is currently the dominant population of top-ups. The L1/L2/L3 daily caps shipped in #482 therefore bound customReference-attributed volume only. Whoever tunes those numbers should read them that way. The marker is cleared the moment a later delivery attributes the same transaction from customReference, so a row only stays exempt while it is genuinely email-attributed.

Known gap: the unauthenticated globals.fygaroTopup fee preview shows standard rates, so a discounted user sees a conservative "you'll receive" estimate in the app and receives more.

Review fixes folded in

  • Case-insensitive matching + guard dedupe — usernames normalize to lowercase on both sides (registration case vs whatever the operator types into ERPNext).
  • Active honoured in the validator — unticking Active is how a promo ends; enforcing it only in the query filter meant a dropped filter would discount forever, silently, because the reader fails open.
  • Repository fault ≠ unknown username (payment.ts) — findByUsername returns CouldNotFindAccountFromUsernameError for a real miss and parseRepositoryError(err) for a Mongo fault. Collapsing them meant a re-delivery during a Mongo blip fell through to the payer-email fallback and stamped email_attribution onto a row whose account_id was already verified — permanently exempting an already-credited top-up from the cap sum (so the account read $0 spent and could auto-credit its full cap again) while acking 200 and stranding the payment. A fault now logs, fires a TTL-deduped warning on a static fygaro:account-lookup-failed key, and returns 500 so Fygaro retries — same self-heal policy the handler already applies to transient ERPNext reads.
  • The email_attribution marker is no longer sticky (ErpNext.applyUpdateGuards) — it is a live claim about how this row's account_id was resolved, not provenance, so an incoming payload that names an account without claiming email attribution clears it from the merged set. Unattributed re-deliveries leave it alone.

Tests

  • fee-discounts.spec.ts (new): validation/coercion, Active enforcement, flow scoping, fail-open on error + failure caching, malformed-row isolation, TTL behavior.
  • fees.spec.ts: discount math incl. fixed-component, fractional %, clamping, gate flow-through, non-positive-net rescue.
  • payment.spec.ts: email attribution (stamped + alert-named + never credited; skipped when customReference resolves; kratos-down safe; no-email safe), repository-fault → 500 with no audit write and no email fallback, discounted credit path end-to-end (flashFee "0.10"/"0.00" on the promoted row), whitelist not consulted when auto-credit is off.
  • ErpNext.spec.ts: cap sum skips email-attributed rows (and does not treat a lookalike system or an absent value as an exemption); a customReference-attributed upsert clears the marker on both the direct and create-race paths, while an email-attributed or account-less write keeps it.
  • cashout-fee-discount.spec.ts (new): real Money math — 0/25/100% and username-less accounts.
  • cashout-rate.spec.ts: discounted quote, 100% waiver, no-username, ERPNext-down fail-closed, and the ±1 bip rounding contract (33.4% inexact, 33.5% exact).
  • Full unit suite: 189 suites, 1808 passed, 3 skipped. tsc (both configs), eslint, prettier, check-yaml, check:sdl and madge all clean.

Deploy

Deploy frappe-flash-admin#67 first (fail-open makes order non-load-bearing, but discounts only take effect once the doctype exists). No chart/env changes: the fygaro-webhook workload already gets galoy.kratos.env and the ErpNext client.

🤖 Generated with Claude Code

https://claude.ai/code/session_017z72JdVxFAYpCQ3RJYkBRQ

bobodread876 and others added 6 commits August 16, 2026 13:20
…o topups

Two operator-facing features backed by the new ERPNext "Fee Discount"
doctype (frappe-flash-admin companion PR):

1. Fee Discount whitelist — a named user gets a 0-100% discount off the
   FLASH fee (never processor fees) on Fygaro card top-ups and/or Jamaican
   bank cashout offers. New cached reader (fee-discounts.ts, 60s TTL,
   mirrors fygaro-settings.ts) that fails OPEN to 0%: an unreadable or
   malformed whitelist charges the standard fee — it can never block a
   credit or an offer. Applied in computeFygaroFees (whole Flash fee,
   percent + fixed components, clamped 0-100) and in
   CashoutManager.createOffer (service fee, one multiplyBips step, covers
   both JMD and USD payouts). 100% = full waiver; the discounted breakdown
   lands on the ERPNext audit row and in the offer the app displays.

2. Payer-email fallback attribution — when customReference is blank or
   unknown (every pre-fix app build), resolve the checkout payer email via
   Kratos (email is a login identifier) to a Flash account and stamp
   account_id on the audit row, so the admin Transfer Requests page shows
   the username on every top-up. DISPLAY-ONLY by design: the checkout email
   is payer-typed, not verified app identity, so the credit path stays
   keyed strictly on customReference. The unattributed ops alert now names
   the matched username (no more manual email->kratos->mongo chase), and
   source_systems_seen gains "email_attribution" so the detail view shows
   how the row got its account.

Side effect worth noting: email-attributed rows now carry account_id, so
they count toward the per-level daily top-up cap (gross card volume is
counted regardless of how the row was attributed) — a correct tightening.

Known gap: the app's unauthenticated globals.fygaroTopup fee preview shows
standard rates, so a discounted user sees a conservative "you'll receive"
estimate and receives more. Cashout offers are per-user, so the app shows
the discounted fee there automatically.

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

- Match the Fee Discount whitelist case-insensitively: usernames are
  case-insensitive platform-wide (findByUsername collation strength 2),
  so lowercase both the map key and the lookup; a user registered
  "RegginaB" whitelisted as "regginab" now gets the discount.
- Lowercase the Fygaro payer email before the Kratos identifier lookup
  so auto-capitalized checkout emails still attribute the payment.
- Fold the fee-discount read into the same could-actually-credit guard
  as the history read instead of duplicating the compound condition.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017z72JdVxFAYpCQ3RJYkBRQ
…te, honour Active, discount the cashout preview

Blocking:
- Email-attributed Fygaro rows no longer count toward the per-level daily
  top-up cap. Their account_id comes from a payer-typed checkout email that
  nobody verified, so a relative paying for someone else — or anyone who
  knows a victim's email — could burn that account's 24h allowance with one
  card payment. sumFygaroTopupGrossCentsSince now fetches source_systems_seen
  and skips rows carrying the email_attribution marker. Filtered in JS, not
  with a Frappe `not like`: SQL evaluates `NULL NOT LIKE '%x%'` as false, which
  would have dropped every row with an empty source_systems_seen and
  under-counted the window. The marker string is now a shared constant so
  writer and reader cannot drift. The unattributed alert says UNVERIFIED and
  asks ops to confirm ownership instead of nudging a hand-credit.
- validateFeeDiscountDoc rejects rows with Active unticked. The raw
  `filters=[["active","=",1]]` query string was the only thing ending a promo,
  and this reader fails open, so a lost or mis-encoded filter would have kept
  deactivated rows discounting Flash's fee with nothing to alarm on. The query
  is also built through axios params now rather than string interpolation.
- cashoutRate quotes the caller's DISCOUNTED fee. It is an authed
  atAccountLevel query, so the account is already in context; a whitelisted
  user was seeing an entry-screen preview that disagreed with the offer
  CashoutManager then built.

Should-fix:
- toFiniteNumber / toBoolean extracted to src/services/frappe/coerce.ts; the
  byte-identical copies in fee-discounts.ts and fygaro-settings.ts are gone.

Tests:
- ErpNext.spec: new getFeeDiscounts block (active=1 filter, fields, page cap,
  non-array response, rejected request) and cap-exclusion cases — the
  father/daughter $125 scenario, rows with no marker still counted, a
  lookalike marker not exempting, and an excluded row not erroring on amount.
- cashout-fee-discount.spec: JMD payout cases at 0/25/100% discount against a
  locked J$160 rate, plus fail-closed on a missing rate. The suite previously
  only ever ran the USD branch.
- cashout-rate.spec: discounted, waived, and no-username quotes.
- fee-discounts.spec: deactivated rows rejected and never discount.
- payment.spec: an email-attributed payment reads no cap and credits nothing.
- coerce.spec: pins the shared coercion contract for both readers.

GraphQL SDL regenerated for the feeBasisPoints description.
…ticky the email marker, honest bip contract

A Mongo fault while resolving customReference was collapsed into the same
branch as "no such username" (findByUsername returns
CouldNotFindAccountFromUsernameError for a real miss and
parseRepositoryError(err) for a fault). On a Fygaro RE-DELIVERY — a designed
path, since the handler 500s on settings/history-unavailable — that dropped a
perfectly-referenced payment into the payer-email fallback and stamped the
sticky `email_attribution` marker onto a row whose account_id was already
verified. Because sumFygaroTopupGrossCentsSince skips marked rows, an
already-credited $100 went invisible to the daily cap: the account read $0
spent and could auto-credit its full allowance again inside 24h. The same
delivery acked 200, so Fygaro stopped retrying and the payment stranded.

- payment.ts: branch on CouldNotFindAccountFromUsernameError for a genuine
  miss; any other repository error now logs, fires a TTL-deduped warning on a
  new static `fygaro:account-lookup-failed` key, and returns 500 so Fygaro
  retries — no audit write, no dedupe lock, no email fallback. Same self-heal
  policy the handler already applies to transient ERPNext reads.
- ErpNext.applyUpdateGuards: `email_attribution` is a live claim about the
  current attribution, not provenance, so an incoming payload that names an
  account without claiming email attribution now CLEARS it from the merged
  set. Unattributed re-deliveries still leave it alone.

Separately, the cashoutRate SDL promised feeBasisPoints "matches the fee the
offer will charge". It cannot: the field is an Int rounded here, while the
offer keeps full precision and rounds once at the end on money. At the
configured 200-bip fee any discount_percent that is not a multiple of 0.5
disagrees by up to 1 bip (33.4% -> preview $6.65 vs offer $6.66 on a $500
cashout). Softened the description (schema.graphql, supergraph.graphql, and
the resolver) and the comment to state the real ±1 bip tolerance.

Tests:
- payment.spec: repository fault returns 500, never calls
  writeFygaroTopupRequest (so never with emailAttributed: true), never
  consults the email fallback or the dedupe lock, and alerts on the static
  key. Existing unknown-username case now mocks the real
  CouldNotFindAccountFromUsernameError instead of a bare Error.
- ErpNext.spec: customReference-attributed upsert over an email-attributed row
  clears the marker (direct and create-race paths); an email-attributed or
  account-less write keeps it.
- cashout-rate.spec: 33.4% pins the documented ±1 bip tolerance; 33.5% pins
  that half-percent multiples stay exact.

Full unit suite: 189 suites, 1808 passed, 3 skipped. tsc (both configs),
eslint, prettier, check-yaml, madge clean; regenerated SDL matches by hand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017z72JdVxFAYpCQ3RJYkBRQ
… it, honest SDL

Round-3 review findings:

- The account-lookup fault path returned 500 without writing an audit
  row, so a captured card payment had NO server-side record if Fygaro's
  retry budget expired before Mongo recovered — reintroducing the exact
  failure class this webhook was built to end (pre-fix, the same fault
  at least produced an unattributed row). It now records unattributed
  first, with no account_id and no email_attribution marker, so a later
  retry upserts the verified account_id onto the same row and the
  daily-cap sum still counts it once attributed. A failed write there
  needs no handling: the response is already 500.

- That alert was severity warning (Slack only, no page) while the
  structurally identical audit-write failure pages. A Mongo blip at 02:00
  meant one Slack line and silence for every other payment that hour.
  Now critical; the dedup key stays static so PagerDuty groups a whole
  outage into one incident rather than paging per payment.

- The cashoutRate SDL promised the offer may differ by "up to 1 bip",
  but rounding is not the only divergence: getFlashFeeDiscountPercent
  fails open to 0 and caches 60s, so a whitelist read that succeeds at
  preview and fails when createOffer runs charges the full fee against a
  discounted quote — 50 bips on a 25%-off account. Both divergences are
  now stated, and a spec pins the fail-open one.

189 suites / 1810 tests pass; tsc and eslint clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017z72JdVxFAYpCQ3RJYkBRQ
- "mis-encoded" tripped the typos CI job in three comments from earlier
  review rounds; reworded to "malformed".

- Documented the account_id overload as a deliberate trade rather than an
  oversight, per review: a Fygaro row's account_id is either
  customReference-verified or payer-typed, and only isEmailAttributedRow
  tells them apart. The comment now warns every future reader of that
  field, names the clean design (a separate column), and records why it
  was not taken here — the ERPNext admin page derives the displayed
  username FROM account_id, so splitting it means changing the doctype
  and its payer-identity join in lockstep. Tracked for follow-up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017z72JdVxFAYpCQ3RJYkBRQ
@islandbitcoin
islandbitcoin merged commit fd91627 into main Aug 17, 2026
15 of 16 checks passed
@islandbitcoin
islandbitcoin deleted the feat/fee-discounts branch August 18, 2026 04:00
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