feat: per-user Flash-fee discounts + payer-email attribution on Fygaro topups - #484
Merged
Conversation
…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
bobodread876
approved these changes
Aug 17, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
src/services/frappe/fee-discounts.ts(60s TTL, mirrorsfygaro-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'sActivetick is honoured in the validator, not only in the query filter.computeFygaroFeesdiscounts 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 otherwisenon-positive-netpayment.CashoutManager.createOfferapplies the discount to the service fee in onemultiplyBipsstep (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.cashoutRate.feeBasisPointsis now per-account. The authedcashoutRatequery 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 anInt, 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 fractionaldiscount_percentcan 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 andcashout-rate.spec.tspins it. Whole- and half-percent discounts are exact.2. Payer-email fallback attribution (username on every topup)
When
customReferenceis 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 stampsaccount_idon the audit row — so the admin Transfer Requests page shows the username on every top-up, credited or not.customReference; the email-matched account never feeds the credit gate (test-pinned).source_systems_seengainsemail_attributionso the detail view shows how the row got its account.Known gap — email-attributed rows are EXCLUDED from the daily cap.
sumFygaroTopupGrossCentsSinceskips any row markedemail_attribution, because thataccount_idcame 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 insource_systems_seen, and a hand-credit is a human decision taken outside this auto-credit gate. Since a blankcustomReferenceis every pre-fix app build, that is currently the dominant population of top-ups. The L1/L2/L3 daily caps shipped in #482 therefore boundcustomReference-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 fromcustomReference, so a row only stays exempt while it is genuinely email-attributed.Known gap: the unauthenticated
globals.fygaroTopupfee 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
Activehonoured 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.payment.ts) —findByUsernamereturnsCouldNotFindAccountFromUsernameErrorfor a real miss andparseRepositoryError(err)for a Mongo fault. Collapsing them meant a re-delivery during a Mongo blip fell through to the payer-email fallback and stampedemail_attributiononto a row whoseaccount_idwas 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 staticfygaro:account-lookup-failedkey, and returns 500 so Fygaro retries — same self-heal policy the handler already applies to transient ERPNext reads.email_attributionmarker is no longer sticky (ErpNext.applyUpdateGuards) — it is a live claim about how this row'saccount_idwas 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,Activeenforcement, 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).tsc(both configs), eslint, prettier,check-yaml,check:sdland 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.envand the ErpNext client.🤖 Generated with Claude Code
https://claude.ai/code/session_017z72JdVxFAYpCQ3RJYkBRQ