Skip to content

feat(fygaro): fee-aware auto-credit — credit net, configurable via ERPNext - #473

Merged
islandbitcoin merged 3 commits into
mainfrom
feat/fygaro-fees
Aug 11, 2026
Merged

feat(fygaro): fee-aware auto-credit — credit net, configurable via ERPNext#473
islandbitcoin merged 3 commits into
mainfrom
feat/fygaro-fees

Conversation

@islandbitcoin

Copy link
Copy Markdown
Contributor

Money flow

The Fygaro card-topup webhook credited the full gross face value. But Fygaro/PayPal take a processor cut off what actually settles to Flash, and Flash wants a margin — so today Flash loses money on every top-up. This PR makes the credited amount the NET, driven by operator-configurable fees, and stamps the fee breakdown onto the ERPNext audit row.

Fee source — ERPNext "Fygaro Settings"

A new ErpNext.getFygaroSettings() reads the Fygaro Settings Single doctype (processor, processor_fee_percent, processor_fee_fixed, flash_margin_percent, flash_margin_fixed, auto_credit_limit, minimum_topup, auto_credit_enabled). It is memoised for ~60s in fygaro-settings.ts so the webhook never hammers ERPNext per payment. A failed read, a missing row, or a malformed row all resolve to unavailable — never "assume zero fees" — and the failure is cached so an outage degrades to record-only without a fetch storm.

The formula (integer cents; each fee rounded to the nearest cent)

gross_cents         = round(amount * 100)
processor_fee_cents = round(gross_cents * processor_fee_percent/100) + round(processor_fee_fixed * 100)
flash_fee_cents     = round(gross_cents * flash_margin_percent/100)  + round(flash_margin_fixed * 100)
net_cents           = gross_cents - processor_fee_cents - flash_fee_cents

$10.00 → processor $0.79, flash $0.20, net $9.01.

Gating

Auto-credit runs only if all hold:

  1. FygaroConfig.credit.enabled — the existing yaml deploy-level master gate (kept as-is)
  2. Fygaro Settings read succeeded AND auto_credit_enabled
  3. currency === "USD"
  4. gross_cents ≤ auto_credit_limit * 100 (threshold on gross)
  5. net_cents > 0

Otherwise → RECORD-ONLY: the ERPNext row stays Fiat Received, nothing is credited, and one ops alert (notifyOpsEvent + alertBridge) names the specific failing gate: settings-unavailable / auto-credit-disabled / non-usd / over-limit / non-positive-net. The credit-disabled master gate records silently (unchanged deploy behavior; settings aren't even read).

When it does credit: it sends net_cents (not gross) and populates the row — initial_amount = gross, processor_fee, flash_fee, final_amount = net credited (fields added to the BridgeTransferRequest model/writer; the doctype fields ship in the companion PR).

All existing safety is preserved: idempotency on the transaction id, Pending-means-credited, the releasing-lock + Completed-row processed-marker, and the ResourceAttemptsLockServiceError-vs-other-error distinction.

GraphQL — globals.fygaroTopup

New nullable public field so the mobile app can compute "you'll receive $X" locally:

type FygaroTopupInfo {
  minimumAmount: Float!       # USD, from minimum_topup
  processorFeePercent: Float!
  processorFeeFixed: Float!   # USD
  flashFeePercent: Float!     # from flash_margin_percent
  flashFeeFixed: Float!       # USD, from flash_margin_fixed
}

type Globals {
  # ...
  fygaroTopup: FygaroTopupInfo   # null when settings unavailable → app falls back gracefully
}

Sourced from the same 60s-cached settings. SDL regenerated via yarn write-sdl (public schema + composed supergraph).

Safe degradation

If ERPNext is unreachable or the settings row is malformed while credit is enabled, every payment is recorded, not credited, with a settings-unavailable ops alert — money is never credited off missing fee data, and the GraphQL field returns null so the app hides the estimate.

Tests

test/flash/unit/services/fygaro/webhook-server/:

  • fee math — table ($10/$1/$500/$33.33 rounding), independent per-component rounding, fixed-margin
  • gating matrix — credits when all gates pass; record-only for each failing gate with the right alert reason; inclusive limit threshold
  • settings — validation/coercion, 60s caching, failure fallback + failure-caching + recovery, malformed-row rejection
  • row fieldsinitial/processor_fee/flash_fee/final populated on credit

yarn tsc-check, yarn eslint-check, and the full yarn test:unit (174 suites / 1487 tests) all pass.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NcEjF6SS3Ci5D4CzZqdPjb

bobodread876 and others added 3 commits August 10, 2026 17:09
…PNext

The Fygaro card-topup webhook credited the FULL gross face value, but
Fygaro/PayPal take a processor cut off what settles to Flash and Flash
wants a margin — so every top-up lost money. This makes the credited
amount the NET, driven by operator-configurable fees.

Fee source — ERPNext "Fygaro Settings" (Single doctype), read via a new
ErpNext.getFygaroSettings() and memoised ~60s (fygaro-settings.ts) so the
webhook never hammers ERPNext per payment. A failed/garbage read resolves
to "unavailable" (never "assume zero fees").

Fee formula (integer cents; each fee rounded to the nearest cent):
  gross_cents         = round(amount * 100)
  processor_fee_cents = round(gross * processor_fee_percent/100) + round(processor_fee_fixed * 100)
  flash_fee_cents     = round(gross * flash_margin_percent/100)  + round(flash_margin_fixed * 100)
  net_cents           = gross - processor_fee - flash_fee
e.g. $10.00 → processor $0.79, flash $0.20, net $9.01.

Gating — auto-credit runs ONLY IF all hold: (1) yaml credit.enabled master
gate, (2) settings available AND auto_credit_enabled, (3) currency USD,
(4) gross ≤ auto_credit_limit, (5) net > 0. Otherwise RECORD-ONLY: row
stays Fiat Received, no credit, and one ops alert (notifyOpsEvent +
alertBridge) names the failing gate (settings-unavailable / auto-credit-
disabled / non-usd / over-limit / non-positive-net). The deploy-level
credit-disabled gate records silently, as before.

On credit: send net_cents (not gross) and stamp the ERPNext row with
initial_amount (gross), processor_fee, flash_fee, final_amount (net). All
existing safety is preserved — idempotency on the tx id, Pending-means-
credited, the releasing-lock + Completed processed-marker, and the
ResourceAttemptsLockServiceError-vs-other-error split.

globals.fygaroTopup — new nullable public field exposing minimumAmount,
processorFeePercent, processorFeeFixed, flashFeePercent, flashFeeFixed from
the cached settings, so the app can preview "you'll receive $X" locally;
null when settings are unavailable (app degrades). SDL regenerated.

Tests: fee-math table + rounding, full gating matrix, settings caching /
failure fallback, and populated row fields.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NcEjF6SS3Ci5D4CzZqdPjb
…tings retry, resolver test

Code-review fixes for the fee-aware auto-credit path:

- fees.ts: enforce minimum_topup server-side. It was read/validated/cached and
  exposed via GraphQL but never gated crediting, so a $2 top-up auto-credited
  under a $10 minimum. Add an inclusive `under-minimum` gate (checked after the
  net gate so a non-positive-net tiny payment still reports the more fundamental
  reason). Symmetric with the existing inclusive auto_credit_limit upper bound.

- payment.ts: reject a non-numeric amount ("abc") with 400 up front. Previously
  Math.round(Number("abc")*100) was NaN, slipped past every numeric gate into
  the credit path, and surfaced as a CRITICAL "auto-credit failed" page —
  misclassifying garbage input as a credit failure.

- payment.ts: return 500 for the transient `settings-unavailable` reason instead
  of acking 200. A brief ERPNext blip (settings cached undefined up to 60s) was
  permanently downgrading a payment to manual credit because 200 stops Fygaro
  retrying. Now Fygaro retries and the read self-heals; no dedupe lock is taken
  (it would block the retry) and the un-dedupable ops-feed line is skipped, while
  the TTL-deduped alert still pages a sustained outage. Deterministic reasons
  keep their 200 record-only behavior.

- Tests: add a Globals `fygaroTopup` resolver spec (null when settings absent;
  each field pinned to its exact source with all-distinct values to catch a
  field swap); add fees + payment coverage for `under-minimum`; add the 400
  non-numeric-amount case; move `settings-unavailable` to its own 500 test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwZjNcKfkMhrBE33HcUVN
Address the residual review note: document that the credit path always
writes explicit fee strings (incl. "0.00" for a zero-rate promo) via
centsToDollars, so the admin's "Pending" display only ever appears on
uncredited rows. No behavior change — centsToDollars already never
returns undefined; this pins the contract against a future refactor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NcEjF6SS3Ci5D4CzZqdPjb
@islandbitcoin
islandbitcoin merged commit 8c07d3e into main Aug 11, 2026
15 of 16 checks passed
islandbitcoin added a commit to lnflash/flash-mobile that referenced this pull request Aug 11, 2026
…d top-ups (#688)

* feat(topup): show net "you'll receive" and enforce $10 minimum on card top-ups

Fast-follow to the merged backend fee feature (lnflash/flash#473), which exposes
Globals.fygaroTopup with the processor/Flash fee params and the top-up minimum.

- Add `fygaroTopup { minimumAmount processorFeePercent processorFeeFixed
  flashFeePercent flashFeeFixed }` to the `transferFlags` globals query that the
  topup/cashout entry screen already fetches, so the data is cache-warm on
  arrival. The field + the `FygaroTopupInfo` type were hand-added to
  public-schema.graphql (no backend access to re-introspect) and codegen was
  regenerated.
- TopupDetails now shows "You'll receive $X.XX" (net after fees) live as the
  user types, mirroring the cashout settlement-amount preview (#683). Card-only;
  the line is hidden when `fygaroTopup` is null so a wrong number is never shown.
- Raise/enforce the card minimum to `fygaroTopup.minimumAmount` (fallback $10
  when null); the invalid-amount alert now states the real minimum.
- New i18n keys added to en + all 23 locale files. Tests cover the $10 floor,
  the $5 rejection (incl. null fallback), and the $9.01 net for a $10 gross.

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

* fix(topup): mirror backend cent-rounding in net estimate, gate preview on minimum

Address code-review findings on the "you'll receive" top-up disclosure:

- estimateTopupNet now computes in integer cents, rounding each fee
  component (processor %+fixed, flash %+fixed) to the nearest cent before
  subtracting, exactly as the backend does (flash#473). The prior
  float-dollar math with a single final round diverged by a cent for a
  large fraction of non-round amounts (e.g. $10.25 showed $9.25 while the
  backend credits $9.24), over-promising the headline number.
- The net preview is now gated on the enforced minimum, so a below-floor
  amount (e.g. $5) no longer shows a concrete receive figure that
  Continue will immediately refuse.
- Tests: added a non-round unit case (10.25 → $9.24) and a preview case
  (10.25 → $9.24, not $9.25) that fail under the old float math, plus a
  below-minimum hide case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwZjNcKfkMhrBE33HcUVN

---------

Co-authored-by: Dread <dread@example.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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