diff --git a/dev/apollo-federation/supergraph.graphql b/dev/apollo-federation/supergraph.graphql index 1a4433d9b..2aaee4baf 100644 --- a/dev/apollo-federation/supergraph.graphql +++ b/dev/apollo-federation/supergraph.graphql @@ -863,7 +863,7 @@ type CashoutRate exchangeRate: JMDCents! """ - Flash cashout service fee in basis points, deducted from the USD amount before conversion. + Flash cashout service fee in basis points for the calling account, deducted from the USD amount before conversion. Already net of any Fee Discount the account is whitelisted for. The offer may still charge more than quoted: up to 1 bip from rounding when the discount is a fraction of a percent, or the full standard fee if the Fee Discount list is unreadable at the moment the offer is built. """ feeBasisPoints: Int! } diff --git a/src/app/offers/CashoutManager.ts b/src/app/offers/CashoutManager.ts index 619b730ee..8831ff79c 100644 --- a/src/app/offers/CashoutManager.ts +++ b/src/app/offers/CashoutManager.ts @@ -11,6 +11,7 @@ import { RepositoryError } from "@domain/errors" import { notifyOpsEvent, toDisplayAmount } from "@services/alerts/ops-events" import { EmailService } from "@services/email" import ErpNext from "@services/frappe/ErpNext" +import { getFlashFeeDiscountPercent } from "@services/frappe/fee-discounts" import { BankAccountQueryError, ExchangeRateQueryError } from "@services/frappe/errors" import { AccountsRepository, WalletsRepository } from "@services/mongoose" @@ -71,7 +72,21 @@ const CashoutManager = { const invoice = decodeInvoice(invoiceResp.invoice.bolt11) if (invoice instanceof Error) return invoice - const serviceFee = userPayment.multiplyBips(config.fee) + // Flash-fee discount from the operator's Fee Discount whitelist (ERPNext, + // cached 60s, fail-open to 0 — an unreadable whitelist charges the + // standard fee, never blocks the offer). Expressed to 0.01% precision as + // "kept" basis points so the Money math stays in one multiplyBips step. + const feeDiscountPercent = await getFlashFeeDiscountPercent({ + username: account.username, + flow: "cashout", + }) + const fullServiceFee = userPayment.multiplyBips(config.fee) + const serviceFee = + feeDiscountPercent > 0 + ? fullServiceFee.multiplyBips( + BigInt(10000 - Math.round(feeDiscountPercent * 100)) as BasisPoints, + ) + : fullServiceFee const usdPayout = userPayment.subtract(serviceFee) const bankAccounts = await ErpNext.getBankAccountsByCustomer(account.erpParty!) diff --git a/src/graphql/public/root/query/cashout-rate.ts b/src/graphql/public/root/query/cashout-rate.ts index b7c014011..b7e5237c2 100644 --- a/src/graphql/public/root/query/cashout-rate.ts +++ b/src/graphql/public/root/query/cashout-rate.ts @@ -4,6 +4,7 @@ import { GT } from "@graphql/index" import { mapAndParseErrorForGqlResponse } from "@graphql/error-map" import JMDCentsScalar from "@graphql/shared/types/scalar/jmd-cent-amount" import ErpNext from "@services/frappe/ErpNext" +import { getFlashFeeDiscountPercent } from "@services/frappe/fee-discounts" // The settlement rate the app shows BEFORE a cashout offer exists. This is the // same source `CashoutManager.createOffer` locks into a JMD offer (ERPNext @@ -27,7 +28,7 @@ const CashoutRateType = GT.Object({ feeBasisPoints: { type: GT.NonNull(GT.Int), description: - "Flash cashout service fee in basis points, deducted from the USD amount before conversion.", + "Flash cashout service fee in basis points for the calling account, deducted from the USD amount before conversion. Already net of any Fee Discount the account is whitelisted for. The offer may still charge more than quoted: up to 1 bip from rounding when the discount is a fraction of a percent, or the full standard fee if the Fee Discount list is unreadable at the moment the offer is built.", resolve: (source: CashoutRateSource) => source.feeBasisPoints, }, }), @@ -35,14 +36,54 @@ const CashoutRateType = GT.Object({ const CashoutRateQuery = GT.Field({ type: GT.NonNull(CashoutRateType), - resolve: async () => { + resolve: async ( + _: unknown, + __: Record, + { domainAccount }: GraphQLPublicContextAuth, + ) => { const exchangeRate = await ErpNext.getCashoutExchangeRate() if (exchangeRate instanceof Error) { throw mapAndParseErrorForGqlResponse(exchangeRate) } + + // Quote the fee this caller will actually be charged. CashoutManager + // applies the same Fee Discount when it builds the offer, so a whitelisted + // user seeing the undiscounted config fee here would watch the preview + // disagree with the offer — the exact mismatch this query exists to + // prevent. Fail-open (0 on any read problem) is inherited from + // getFlashFeeDiscountPercent: the preview degrades to the standard fee, it + // never breaks. + // + // Exactness caveat, deliberately documented rather than hidden: this is the + // same "kept basis points" arithmetic CashoutManager uses, but the field is + // an Int, so the discounted rate is rounded to a whole basis point HERE + // while the offer keeps full precision and rounds once at the end, on money + // (userPayment.multiplyBips(fee).multiplyBips(keptBips)). Whenever + // `fee * keptBips / 10000` is not an integer the two differ by up to 1 bip. + // With the configured 200-bip fee that is any discount_percent that is not a + // multiple of 0.5: e.g. 33.4% -> keptBips 6660 -> this quotes + // round(133.2) = 133 bips, while a $500 cashout is charged 1000¢ * 6660/10000 + // = 666¢, i.e. $6.66 against a $6.65 preview. Whole/half-percent discounts — + // every discount ops has actually configured — are exact. + // + // Rounding is not the only way the offer can exceed this quote: + // getFlashFeeDiscountPercent fails open to 0 and caches for 60s, so a + // whitelist read that succeeds here and fails when createOffer runs a + // minute later charges the full fee against a discounted quote — 50 bips + // on a 25%-off account, not 1. Both divergences are stated in the SDL + // description rather than hidden; cashout-rate.spec.ts pins them. + const discountPercent = await getFlashFeeDiscountPercent({ + username: domainAccount?.username, + flow: "cashout", + }) + const keptBips = 10000 - Math.round(discountPercent * 100) + const feeBasisPoints = Math.round( + (Number(Cashout.OfferConfig.fee) * keptBips) / 10000, + ) + return { exchangeRate, - feeBasisPoints: Number(Cashout.OfferConfig.fee), + feeBasisPoints, } }, }) diff --git a/src/graphql/public/schema.graphql b/src/graphql/public/schema.graphql index df95cecc3..21cad00c2 100644 --- a/src/graphql/public/schema.graphql +++ b/src/graphql/public/schema.graphql @@ -705,7 +705,7 @@ type CashoutRate { exchangeRate: JMDCents! """ - Flash cashout service fee in basis points, deducted from the USD amount before conversion. + Flash cashout service fee in basis points for the calling account, deducted from the USD amount before conversion. Already net of any Fee Discount the account is whitelisted for. The offer may still charge more than quoted: up to 1 bip from rounding when the discount is a fraction of a percent, or the full standard fee if the Fee Discount list is unreadable at the moment the offer is built. """ feeBasisPoints: Int! } diff --git a/src/services/alerts/dedup-key.ts b/src/services/alerts/dedup-key.ts index f9a813eba..ce4d86e64 100644 --- a/src/services/alerts/dedup-key.ts +++ b/src/services/alerts/dedup-key.ts @@ -47,6 +47,12 @@ export const generateDedupKey = { fygaroClockSkew: () => "fygaro:clock-skew", fygaroFloatLow: () => "fygaro:float-low", fygaroFloatExhausted: () => "fygaro:float-exhausted", + // The account repository faulted while resolving a customReference. Static + // (no transaction suffix) because this is an infrastructure outage, not a + // per-payment anomaly: every in-flight delivery hits it at once and should + // collapse into one alert per window. Distinct from fygaroUnattributed so an + // outage never masquerades as "this payer typed a bad username". + fygaroAccountLookupFailed: () => "fygaro:account-lookup-failed", } export const normalizeDedupKey = (key: string): string => diff --git a/src/services/frappe/BridgeTransferRequestWriter.ts b/src/services/frappe/BridgeTransferRequestWriter.ts index eaec250f8..72cfddf97 100644 --- a/src/services/frappe/BridgeTransferRequestWriter.ts +++ b/src/services/frappe/BridgeTransferRequestWriter.ts @@ -9,6 +9,7 @@ import { BridgeTransferRequest, BridgeTransferRequestStatus, BridgeTransferRequestTransactionType, + EMAIL_ATTRIBUTION_SOURCE_SYSTEM, } from "./models/BridgeTransferRequest" type BridgeDepositEventObject = { @@ -216,6 +217,7 @@ export const writeFygaroTopupRequest = async ({ amount, currency, accountId, + emailAttributed, createdAt, rawPayload, }: { @@ -223,6 +225,12 @@ export const writeFygaroTopupRequest = async ({ amount: string currency: string accountId?: AccountId | string + // True when accountId was resolved from the checkout payer email rather + // than customReference — display-grade attribution the webhook never + // credits from. Marked in source_systems_seen so the admin detail view + // shows how the row got its account, and so the daily-cap sum can skip it: + // an unverified email must never consume the named account's allowance. + emailAttributed?: boolean createdAt?: string rawPayload: unknown }): Promise => { @@ -239,7 +247,9 @@ export const writeFygaroTopupRequest = async ({ accountId, sourceEventId: transactionId, sourceEventType: "fygaro.payment", - sourceSystemsSeen: ["fygaro_webhook"], + sourceSystemsSeen: emailAttributed + ? ["fygaro_webhook", EMAIL_ATTRIBUTION_SOURCE_SYSTEM] + : ["fygaro_webhook"], firstSeenAt: createdAt, rawPayload, }), diff --git a/src/services/frappe/ErpNext.ts b/src/services/frappe/ErpNext.ts index fc0902bd0..5d64417d0 100644 --- a/src/services/frappe/ErpNext.ts +++ b/src/services/frappe/ErpNext.ts @@ -14,6 +14,7 @@ import { CashoutDraftError, CashoutSubmitError, ExchangeRateQueryError, + FeeDiscountQueryError, FygaroSettingsQueryError, FygaroTopupHistoryQueryError, JournalEntryDeleteError, @@ -32,6 +33,7 @@ import { BridgeTransferRequest, BridgeTransferRequestStatus, BridgeTransferRequestTransactionType, + EMAIL_ATTRIBUTION_SOURCE_SYSTEM, toFrappeDatetime, } from "./models/BridgeTransferRequest" import { Filter } from "./SearchFilters" @@ -81,6 +83,28 @@ const mergeSourceSystemsSeen = ( return merged.length ? merged.join(",") : undefined } +// Whether a Bridge Transfer Request row's account_id came from the unverified +// payer-email fallback. `source_systems_seen` is a comma-joined list, so match +// on exact members rather than a substring. A null/absent value means "not +// email-attributed" — which counts the row, i.e. fails CLOSED for the daily cap. +const isEmailAttributedRow = (sourceSystemsSeen?: string | null): boolean => + (sourceSystemsSeen ?? "") + .split(",") + .some((system) => system.trim() === EMAIL_ATTRIBUTION_SOURCE_SYSTEM) + +// Remove one member from a comma-joined source_systems_seen list, normalizing +// like mergeSourceSystemsSeen so the two round-trip identically. +const dropSourceSystem = ( + sourceSystemsSeen: string | undefined, + system: string, +): string | undefined => { + const kept = (sourceSystemsSeen ?? "") + .split(",") + .map((s) => s.trim()) + .filter((s) => s && s !== system) + return kept.length ? kept.join(",") : undefined +} + export type BridgeTransferRequestDoc = { name: string status?: string @@ -106,6 +130,18 @@ export type FygaroSettingsDoc = { l3_daily_limit?: number | 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 +// before use. +export type FeeDiscountDoc = { + username?: string + discount_percent?: number | string + applies_to_topup?: number | boolean | string + applies_to_cashout?: number | boolean | string + active?: number | boolean | string +} + export class ErpNext { url: string headers: Record @@ -412,12 +448,54 @@ export class ErpNext { } } + // Reads every ACTIVE "Fee Discount" row (operator-managed whitelist of + // usernames whose Flash fee is discounted on Fygaro top-ups and/or bank + // cashouts). The consumers read this through a 60s cache and fail open to a + // 0% discount, so an ERPNext blip can never block a credit or an offer — + // it just charges the standard fee. + async getFeeDiscounts(): Promise { + try { + // Serialized through axios `params` (not hand-interpolated into the URL) + // so encoding is the HTTP client's job. `active` is fetched as well as + // filtered on: validateFeeDiscountDoc re-checks it, so a deactivated row + // is dropped even if this filter is ever lost or malformed. + const fields = JSON.stringify([ + "username", + "discount_percent", + "applies_to_topup", + "applies_to_cashout", + "active", + ]) + const filters = JSON.stringify([["active", "=", 1]]) + const resp = await axios.get( + `${this.url}/api/resource/${encodeURIComponent("Fee Discount")}`, + { + params: { filters, fields, limit_page_length: 0 }, + headers: this.headers, + }, + ) + const data = resp.data?.data + if (!Array.isArray(data)) + return new FeeDiscountQueryError("No data in Fee Discount response") + return data as FeeDiscountDoc[] + } catch (err) { + const responseData = isAxiosError(err) ? err.response?.data : undefined + baseLogger.error({ err, responseData }, "Error querying Fee Discount from ERPNext") + recordExceptionInCurrentSpan({ + error: err, + attributes: { "erpnext.exception": responseData?.exception }, + }) + return new FeeDiscountQueryError(err) + } + } + // Sums the GROSS USD cents of one account's Fygaro card top-ups over a // trailing window, for the per-level daily top-up limit gate. Counts every // captured USD payment (Fiat Received or Completed — i.e. the card was // charged, whether or not it has been credited yet), excludes Cancelled - // rows, and excludes the current delivery's own audit row (written before - // the gate runs) via excludeRequestId. Non-USD rows are excluded because + // rows, excludes email-attributed rows (see below), and excludes the + // current delivery's own audit row (written before the gate runs) via + // excludeRequestId. Non-USD rows are excluded because // their `amount` is the raw foreign-currency figure — a 5,000 JMD payment // counted at face value would look like $5,000 of prior gross and lock the // account out of auto-credit for a day. The window filters on @@ -464,7 +542,7 @@ export class ErpNext { ], [BridgeTransferRequest.doctype, "request_id", "!=", excludeRequestId], ]) - const fields = JSON.stringify(["request_id", "amount"]) + const fields = JSON.stringify(["request_id", "amount", "source_systems_seen"]) const resp = await axios.get( `${this.url}/api/resource/${encodeURIComponent(BridgeTransferRequest.doctype)}`, { @@ -480,7 +558,26 @@ export class ErpNext { for (const row of rows as { request_id?: string amount?: number | string | null + source_systems_seen?: string | null }[]) { + // An email-attributed row got its account_id from the payer-typed + // checkout email — input nobody verified against an identity. Letting + // it into this sum would let ANY card payment burn the named account's + // daily allowance: a relative paying for someone else, or an attacker + // who merely knows a victim's email, could lock them out of + // auto-credit for 24h. Those rows stay display-only — including after + // an ops hand-credit, since source_systems_seen merges as a union and + // keeps the marker. A hand-credit is a human decision outside this + // auto-credit gate, so leaving it out of the cap is the safe side. + // + // Filtered here in JS rather than with a Frappe + // `["source_systems_seen","not like","%email_attribution%"]` filter on + // purpose: SQL evaluates `NULL NOT LIKE '%x%'` to NULL (falsy), so + // that filter would silently drop every row with an empty + // source_systems_seen from the window — under-counting the gross and + // quietly defeating the cap, the exact failure this method's other + // guards refuse to accept. + if (isEmailAttributedRow(row.source_systems_seen)) continue // Frappe's list API returns null for unset fields, and Number(null) // is 0 — a null amount must fail closed like any other unparsable // row, not silently contribute nothing to the sum. @@ -773,12 +870,28 @@ export class ErpNext { payload: ReturnType, existing: BridgeTransferRequestDoc, ): ReturnType { + const merged = mergeSourceSystemsSeen( + existing.source_systems_seen, + payload.source_systems_seen, + ) + + // `source_systems_seen` accumulates as a union so no writer erases another + // writer's provenance — with one exception. `email_attribution` is not + // provenance; it is a live claim about how THIS row's account_id was + // resolved, and the daily-cap sum reads it to decide whether the row counts + // (sumFygaroTopupGrossCentsSince). A later delivery that attributes the same + // transaction from customReference (verified) must therefore CLEAR it: a + // sticky marker would keep a verified, already-credited top-up out of the + // account's trailing-24h gross forever, handing it a second full daily + // allowance. Only an incoming payload that both names an account and does + // not claim email attribution can clear it — an unattributed re-delivery + // (no account_id) leaves the existing marker alone. const guarded = { ...payload, - source_systems_seen: mergeSourceSystemsSeen( - existing.source_systems_seen, - payload.source_systems_seen, - ), + source_systems_seen: + payload.account_id && !isEmailAttributedRow(payload.source_systems_seen) + ? dropSourceSystem(merged, EMAIL_ATTRIBUTION_SOURCE_SYSTEM) + : merged, } if ( diff --git a/src/services/frappe/coerce.ts b/src/services/frappe/coerce.ts new file mode 100644 index 000000000..2d62be5e6 --- /dev/null +++ b/src/services/frappe/coerce.ts @@ -0,0 +1,31 @@ +/** + * Coercion helpers for raw ERPNext/Frappe doctype fields. + * + * Frappe's REST API is loose about scalar encodings: a Currency/Float field + * may arrive as a number or as a numeric string, and a Check field arrives as + * 1/0 (occasionally "1"/"0"). Every reader of a raw doctype needs the same two + * coercions, so they live here once — two copies would drift the first time + * someone teaches one of them a new encoding, and because both coercions fail + * soft the divergence would be silent. + * + * Consumers: fee-discounts.ts, fygaro/webhook-server/fygaro-settings.ts. + */ + +/** + * Coerce a value ERPNext may send as a number or a numeric string. Returns + * undefined for anything that is not a finite number (blank strings, null, + * "abc", Infinity) so callers can reject the row rather than treat garbage + * as zero. + */ +export const toFiniteNumber = (value: unknown): number | undefined => { + if (typeof value === "number") return Number.isFinite(value) ? value : undefined + if (typeof value === "string" && value.trim() !== "") { + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : undefined + } + return undefined +} + +/** ERPNext Check fields come back as 1/0; be liberal about truthy encodings. */ +export const toBoolean = (value: unknown): boolean => + value === 1 || value === true || value === "1" diff --git a/src/services/frappe/errors.ts b/src/services/frappe/errors.ts index 7b2071193..030ebd7ad 100644 --- a/src/services/frappe/errors.ts +++ b/src/services/frappe/errors.ts @@ -14,4 +14,5 @@ export class BankAccountUpdateRequestQueryError extends ErpNextError {} export class ExchangeRateQueryError extends ErpNextError {} export class BridgeTransferRequestUpsertError extends ErpNextError {} export class FygaroSettingsQueryError extends ErpNextError {} +export class FeeDiscountQueryError extends ErpNextError {} export class FygaroTopupHistoryQueryError extends ErpNextError {} diff --git a/src/services/frappe/fee-discounts.ts b/src/services/frappe/fee-discounts.ts new file mode 100644 index 000000000..3b76083ec --- /dev/null +++ b/src/services/frappe/fee-discounts.ts @@ -0,0 +1,131 @@ +/** + * Cached reader for the ERPNext "Fee Discount" doctype — the operator-managed + * whitelist of usernames whose FLASH fee is discounted on Fygaro card top-ups + * and/or Jamaican bank cashouts (processor fees are never discounted). + * + * Consulted per top-up webhook delivery and per cashout offer, so the active + * rows are memoised for ~60s (successes AND failures — a failure is cached so + * an ERPNext outage degrades to standard fees without a fetch storm, and + * recovers within the TTL), mirroring fygaro-settings.ts. + * + * FAIL-OPEN, deliberately the opposite polarity of Fygaro Settings: a read + * failure, a missing doctype, or a malformed row resolves to a 0% discount + * (standard fee). A discount is a bonus on top of an otherwise-valid flow; + * blocking credits or offers because the discount list is unreadable would + * turn a courtesy into an outage. Worst case a whitelisted user pays the + * standard fee for a minute and ops adjusts manually. + */ +import { toBoolean, toFiniteNumber } from "@services/frappe/coerce" +import ErpNext, { type FeeDiscountDoc } from "@services/frappe/ErpNext" +import { baseLogger } from "@services/logger" + +export type FeeDiscountFlow = "topup" | "cashout" + +type FeeDiscount = { + // Percentage taken OFF the Flash fee, 0-100 (100 = full waiver). + discountPercent: number + appliesToTopup: boolean + appliesToCashout: boolean +} + +const CACHE_TTL_MS = 60_000 + +let cache: { value: Map; at: number } | undefined + +// Validates one raw row into a keyed entry, or undefined for garbage (blank +// username, non-numeric or out-of-range percent, or a row the operator has +// deactivated). Malformed rows are dropped individually — one bad row must +// not take out the rest of the whitelist. +// The username key is lowercased: platform usernames are case-insensitive +// (AccountsRepository.findByUsername matches with collation strength 2), so +// account.username carries registration case and the operator may type any +// casing into ERPNext — both sides normalize to lowercase to match. +export const validateFeeDiscountDoc = ( + doc: FeeDiscountDoc, +): { username: string; discount: FeeDiscount } | undefined => { + if (!doc || typeof doc !== "object") return undefined + // Honour the operator's Active tick here, not only in the ERPNext query + // filter. Unticking Active is how a promo ends; if this were enforced by + // the query string alone, a dropped or malformed filter would silently + // keep every deactivated row discounting Flash's fee forever — and because + // the reader deliberately fails open, nothing would alarm. + if (!toBoolean(doc.active)) return undefined + const username = + typeof doc.username === "string" ? doc.username.trim().toLowerCase() : "" + if (!username) return undefined + + const discountPercent = toFiniteNumber(doc.discount_percent) + if (discountPercent === undefined || discountPercent < 0 || discountPercent > 100) { + return undefined + } + + return { + username, + discount: { + discountPercent, + appliesToTopup: toBoolean(doc.applies_to_topup), + appliesToCashout: toBoolean(doc.applies_to_cashout), + }, + } +} + +const loadDiscounts = async (): Promise> => { + const now = Date.now() + if (cache && now - cache.at < CACHE_TTL_MS) return cache.value + + const byUsername = new Map() + const docs = ErpNext?.getFeeDiscounts ? await ErpNext.getFeeDiscounts() : [] + if (docs instanceof Error) { + baseLogger.warn( + { error: docs }, + "Failed to read Fee Discounts; failing open to standard fees", + ) + cache = { value: byUsername, at: now } + return byUsername + } + + for (const doc of docs) { + const validated = validateFeeDiscountDoc(doc) + if (!validated) { + baseLogger.warn({ doc }, "Malformed Fee Discount row skipped") + continue + } + byUsername.set(validated.username, validated.discount) + } + cache = { value: byUsername, at: now } + return byUsername +} + +/** + * The Flash-fee discount percent (0-100) for a username in a given flow. + * Returns 0 for users not on the whitelist, rows not covering the flow, and + * on ANY read failure (fail-open — see module doc). Never throws. + */ +export const getFlashFeeDiscountPercent = async ({ + username, + flow, +}: { + username: string | undefined + flow: FeeDiscountFlow +}): Promise => { + if (!username) return 0 + try { + const discounts = await loadDiscounts() + // Lowercased to match the map keys — usernames are case-insensitive. + const discount = discounts.get(username.trim().toLowerCase()) + if (!discount) return 0 + const applies = flow === "topup" ? discount.appliesToTopup : discount.appliesToCashout + return applies ? discount.discountPercent : 0 + } catch (error) { + baseLogger.warn( + { error, username, flow }, + "Fee Discount lookup failed; failing open to the standard fee", + ) + return 0 + } +} + +/** Test-only: clears the module cache so specs start from a cold read. */ +export const _resetFeeDiscountsCache = (): void => { + cache = undefined +} diff --git a/src/services/frappe/models/BridgeTransferRequest.ts b/src/services/frappe/models/BridgeTransferRequest.ts index 34258df2c..6de309f71 100644 --- a/src/services/frappe/models/BridgeTransferRequest.ts +++ b/src/services/frappe/models/BridgeTransferRequest.ts @@ -11,6 +11,33 @@ export enum BridgeTransferRequestStatus { Failed = "Failed", } +// Marker written into `source_systems_seen` when a row's account_id was +// resolved from the checkout payer email rather than from customReference. +// That input is payer-typed and identity-unverified, so rows carrying this +// marker are DISPLAY-ONLY: no gate may read their account_id (see +// ErpNext.sumFygaroTopupGrossCentsSince). Writer and reader share the +// constant so the two can never drift on the spelling. +// +// ⚠️ READ BEFORE ADDING ANY NEW READER OF `account_id` ON A FYGARO ROW. +// This overloads a provenance list with a TRUST CLAIM, which means +// `account_id` on a Fygaro Topup row is either "verified via +// customReference" or "typed by whoever held the card" — and nothing in the +// type system distinguishes them. Every gate, report, threshold or credit +// path that reads `account_id` MUST call `isEmailAttributedRow` on the row's +// `source_systems_seen` first and treat a marked row as unattributed. +// Today exactly one reader does (the daily-cap sum); a second one that +// forgets would silently trust payer-typed input. +// +// The clean design is a separate column (`account_id_unverified`, or an +// `attribution_source` Select) so `account_id` keeps one meaning and neither +// the cap exemption nor the un-sticking logic in `applyUpdateGuards` needs to +// exist. That was a deliberate trade, not an oversight: the ERPNext admin +// page derives the displayed username FROM `account_id`, so splitting the +// field means changing the Bridge Transfer Request doctype and its +// payer-identity join in lockstep (frappe-flash-admin: collect_lookup_refs / +// match_account_identity / build_payer_fields). Tracked for follow-up. +export const EMAIL_ATTRIBUTION_SOURCE_SYSTEM = "email_attribution" + export type BridgeTransferRequestInput = { requestId: string transactionType: BridgeTransferRequestTransactionType diff --git a/src/services/fygaro/webhook-server/fees.ts b/src/services/fygaro/webhook-server/fees.ts index b7c264432..f7a6951c8 100644 --- a/src/services/fygaro/webhook-server/fees.ts +++ b/src/services/fygaro/webhook-server/fees.ts @@ -27,22 +27,33 @@ type FeeSettings = Pick< /** * gross_cents = round(amount * 100) (caller supplies) * processor_fee_cents = round(gross * pct/100) + round(fixed * 100) - * flash_fee_cents = round(gross * pct/100) + round(fixed * 100) + * flash_fee_cents = round((round(gross * pct/100) + round(fixed * 100)) + * * (1 - discount/100)) * net_cents = gross - processor_fee - flash_fee + * + * `flashFeeDiscountPercent` (0-100, from the operator's Fee Discount + * whitelist) discounts the WHOLE Flash fee — percent and fixed components — + * never the processor fee, which is money PayPal already took. 100 waives the + * Flash fee entirely. Out-of-range values are clamped so a garbage discount + * can never inflate the fee or push the net above gross-minus-processor. */ export const computeFygaroFees = ({ grossCents, settings, + flashFeeDiscountPercent = 0, }: { grossCents: number settings: FeeSettings + flashFeeDiscountPercent?: number }): FygaroFees => { const processorFeeCents = Math.round((grossCents * settings.processorFeePercent) / 100) + Math.round(settings.processorFeeFixed * 100) - const flashFeeCents = + const fullFlashFeeCents = Math.round((grossCents * settings.flashMarginPercent) / 100) + Math.round(settings.flashMarginFixed * 100) + const discount = Math.min(100, Math.max(0, flashFeeDiscountPercent)) + const flashFeeCents = Math.round((fullFlashFeeCents * (100 - discount)) / 100) const netCents = grossCents - processorFeeCents - flashFeeCents return { grossCents, processorFeeCents, flashFeeCents, netCents } @@ -103,6 +114,7 @@ export const evaluateCreditGate = ({ grossCents, level, priorDayGrossCents, + flashFeeDiscountPercent = 0, }: { creditEnabled: boolean currency: string @@ -113,6 +125,10 @@ export const evaluateCreditGate = ({ // Gross cents this account was charged over the trailing 24h (excluding the // current payment), or undefined when the history read failed. priorDayGrossCents: number | undefined + // Flash-fee discount for this account from the Fee Discount whitelist + // (0-100; 0 for everyone not on it). Only shifts the fee math — every + // gross-denominated gate (limits, minimum) is discount-blind by design. + flashFeeDiscountPercent?: number }): CreditGate => { if (!creditEnabled) return { credit: false, reason: "credit-disabled" } if (!settings) return { credit: false, reason: "settings-unavailable" } @@ -136,7 +152,7 @@ export const evaluateCreditGate = ({ return { credit: false, reason: "daily-limit-exceeded" } } - const fees = computeFygaroFees({ grossCents, settings }) + const fees = computeFygaroFees({ grossCents, settings, flashFeeDiscountPercent }) if (fees.netCents <= 0) return { credit: false, reason: "non-positive-net" } if (grossCents < Math.round(settings.minimumTopup * 100)) { return { credit: false, reason: "under-minimum" } diff --git a/src/services/fygaro/webhook-server/fygaro-settings.ts b/src/services/fygaro/webhook-server/fygaro-settings.ts index 9fcf61ed5..f2114d88e 100644 --- a/src/services/fygaro/webhook-server/fygaro-settings.ts +++ b/src/services/fygaro/webhook-server/fygaro-settings.ts @@ -12,6 +12,7 @@ * auto-credit — never as "assume zero fees" — so a broken settings row can * never make Flash credit the gross face value again. */ +import { toBoolean, toFiniteNumber } from "@services/frappe/coerce" import ErpNext, { type FygaroSettingsDoc } from "@services/frappe/ErpNext" import { baseLogger } from "@services/logger" @@ -40,21 +41,6 @@ const CACHE_TTL_MS = 60_000 let cache: { value: FygaroSettings | undefined; at: number } | undefined -// Coerce a value that ERPNext may send as a number or a numeric string. Returns -// undefined for anything that is not a finite number. -const toFiniteNumber = (value: unknown): number | undefined => { - if (typeof value === "number") return Number.isFinite(value) ? value : undefined - if (typeof value === "string" && value.trim() !== "") { - const parsed = Number(value) - return Number.isFinite(parsed) ? parsed : undefined - } - return undefined -} - -// ERPNext Check fields come back as 1/0; be liberal about truthy encodings. -const toBoolean = (value: unknown): boolean => - value === 1 || value === true || value === "1" - // Validates the raw doctype into a typed FygaroSettings, or undefined when any // fee-relevant field is missing / non-numeric / negative (i.e. "garbage"). export const validateFygaroSettings = ( diff --git a/src/services/fygaro/webhook-server/routes/payment.ts b/src/services/fygaro/webhook-server/routes/payment.ts index 63e04090c..29c1c2fe5 100644 --- a/src/services/fygaro/webhook-server/routes/payment.ts +++ b/src/services/fygaro/webhook-server/routes/payment.ts @@ -21,7 +21,10 @@ import { Request, Response } from "express" import { FygaroConfig } from "@config" +import { CouldNotFindAccountFromUsernameError } from "@domain/errors" import { ResourceAttemptsLockServiceError } from "@domain/lock" +import { getFlashFeeDiscountPercent } from "@services/frappe/fee-discounts" +import { IdentityRepository } from "@services/kratos" import { LockService } from "@services/lock" import { baseLogger } from "@services/logger" import { AccountsRepository } from "@services/mongoose" @@ -55,6 +58,38 @@ type FygaroPaymentPayload = { const centsToDollars = (cents: number): string => (cents / 100).toFixed(2) +// DISPLAY-ONLY fallback attribution for payments whose customReference missed: +// the payer email Fygaro captured at checkout -> Kratos identity (email is a +// login identifier) -> Flash account. Stamping the account on the audit row +// lets the admin Transfer Requests page show the username on every top-up and +// saves ops the manual email->kratos->mongo chase. It must NEVER feed the +// credit path — the checkout email is whatever the payer typed, not verified +// app identity like customReference — so the caller keeps `account` +// (credit-eligible) and this result strictly separate. Best-effort: any +// failure resolves to undefined and the row simply stays unattributed. +const resolveAccountByPayerEmail = async ( + email: string | undefined, +): Promise => { + // Lowercased before the Kratos lookup: checkout keyboards auto-capitalize + // ("Jabari@gmail.com") while the stored login identifier is lowercase, and + // listIdentities matches the identifier verbatim — without normalizing here + // the attribution silently never fires for those payments. + const trimmed = email?.trim().toLowerCase() + if (!trimmed) return undefined + try { + const userId = await IdentityRepository().getUserIdFromIdentifier( + trimmed as EmailAddress, + ) + if (userId instanceof Error) return undefined + const account = await AccountsRepository().findByUserId(userId) + if (account instanceof Error) return undefined + return account + } catch (error) { + baseLogger.warn({ error, email: trimmed }, "Fygaro payer-email attribution failed") + return undefined + } +} + // Human-readable title for the single record-only ops alert. `credit-disabled` // is intentionally absent — that is the deploy-level master gate and records // silently (no anomaly worth paging on). @@ -133,21 +168,86 @@ export const paymentHandler = async (req: Request, res: Response) => { let account: Account | undefined if (username) { const found = await AccountsRepository().findByUsername(username as Username) - if (found instanceof Error) { + if (found instanceof CouldNotFindAccountFromUsernameError) { + // A genuine miss: no account owns this username. Fall through to the + // display-only payer-email fallback below. baseLogger.warn( { transactionId, username }, "Fygaro payment: customReference does not match any account", ) + } else if (found instanceof Error) { + // A repository FAULT (Mongo timeout, connection blip) is NOT "no such + // username": findByUsername returns CouldNotFindAccountFromUsernameError + // for a real miss and parseRepositoryError(err) for everything else. + // Collapsing the two would let a momentary outage on a RE-DELIVERY drop + // a perfectly-referenced payment into the payer-email fallback and stamp + // the sticky `email_attribution` marker onto a row whose account_id was + // already verified via customReference — permanently exempting an + // already-credited top-up from the daily-cap sum + // (ErpNext.sumFygaroTopupGrossCentsSince), so the account's spent + // allowance reads $0 and it can auto-credit its full cap again. It would + // also ack 200 and strand the payment for manual credit. Same self-heal + // policy this handler applies to transient ERPNext reads below: 500 and + // no dedupe lock — let Fygaro retry once Mongo is back. + baseLogger.error( + { error: found, transactionId, username }, + "Fygaro payment: account lookup failed — returning 500 so Fygaro retries", + ) + // Record the payment UNATTRIBUTED first. The retry above is a policy, + // not a guarantee — if Fygaro's retry budget expires before Mongo + // recovers, bailing without a write would leave captured fiat with no + // server-side record at all, which is the exact failure class this + // webhook was built to end. Deliberately no account_id and no + // `email_attribution` marker: a later retry upserts the verified + // account_id onto this same row and the daily-cap sum still counts it + // once attributed. A failure here needs no extra handling — the + // response is already 500, so Fygaro retries either way. + const faultAudit = await writeFygaroTopupRequest({ + transactionId, + amount: String(payload.amount), + currency, + accountId: undefined, + emailAttributed: false, + createdAt, + rawPayload: req.body, + }) + if (faultAudit instanceof Error) { + baseLogger.error( + { error: faultAudit, transactionId }, + "Failed to persist unattributed Fygaro audit row during an account-lookup fault", + ) + } + // Critical, not warning: this means captured payments are not being + // attributed or credited at all. The dedup key is static, so PagerDuty + // groups a whole outage into ONE incident rather than paging per + // payment — the same reason the audit-write failure below pages. + alertBridge({ + dedupKey: generateDedupKey.fygaroAccountLookupFailed(), + source: "fygaro-webhook", + severity: "critical", + title: + "Fygaro account lookup unavailable — payments recorded unattributed, will retry", + detail: found.message, + context: { transaction_id: transactionId, username }, + }) + return res.status(500).json({ error: "account lookup unavailable; will retry" }) } else { account = found } } + // Fallback attribution for the audit row only (never for crediting): + // resolved from the checkout payer email when customReference missed. + const emailAttributedAccount = account + ? undefined + : await resolveAccountByPayerEmail(payload.client?.email) + const auditResult = await writeFygaroTopupRequest({ transactionId, amount: String(payload.amount), currency, - accountId: account?.id, + accountId: account?.id ?? emailAttributedAccount?.id, + emailAttributed: Boolean(emailAttributedAccount), createdAt, rawPayload: req.body, }) @@ -189,28 +289,40 @@ export const paymentHandler = async (req: Request, res: Response) => { baseLogger.info({ transactionId }, "Duplicate Fygaro payment webhook") return res.status(200).json({ status: "already_processed" }) } + // When the payer email resolved to an account, name the username in the + // alert so ops can start from a candidate instead of re-running the + // email->kratos->mongo chase by hand. It is a LEAD, not an instruction: + // the checkout email is payer-typed and identity-unverified (a relative + // or an attacker can type anyone's address), so the alert says confirm + // before crediting rather than "credit this account". + const resolvedUsername = emailAttributedAccount?.username alertBridge({ dedupKey: generateDedupKey.fygaroUnattributed(transactionId), source: "fygaro-webhook", severity: "warning", title: "Fygaro payment could not be attributed to an account", - detail: `customReference=${username ?? ""} — manual attribution needed`, + detail: resolvedUsername + ? `customReference=${username ?? ""} — UNVERIFIED payer-email match on @${resolvedUsername}; confirm the payer owns this account before crediting` + : `customReference=${username ?? ""} — manual attribution needed`, context: { transaction_id: transactionId, amount: String(payload.amount), client_email: payload.client?.email, + ...(resolvedUsername ? { email_matched_username: resolvedUsername } : {}), }, }) notifyOpsEvent({ flow: "deposit", phase: "fygaro-unattributed", status: "pending", + accountId: emailAttributedAccount?.id, amount: { value: String(payload.amount), currency }, meta: { provider: "Fygaro", transactionId, reference: username ?? "blank", email: payload.client?.email ?? "unknown", + ...(resolvedUsername ? { emailMatchedUsername: resolvedUsername } : {}), }, }) return res.status(200).json({ status: "recorded", attributed: false }) @@ -238,6 +350,7 @@ export const paymentHandler = async (req: Request, res: Response) => { // a clean slate — and the gate turns it into the retryable // `history-unavailable` stop. let priorDayGrossCents: number | undefined + let flashFeeDiscountPercent = 0 if (creditEnabled && settings?.autoCreditEnabled && currency === "USD") { const priorSum = await sumFygaroTopupGrossCentsLast24h({ accountId, @@ -251,6 +364,15 @@ export const paymentHandler = async (req: Request, res: Response) => { } else { priorDayGrossCents = priorSum } + + // Flash-fee discount from the operator's Fee Discount whitelist, read + // under the same could-actually-credit guard as the history read. + // Fail-open: the reader resolves to 0 on any failure, so an unreadable + // whitelist charges the standard fee instead of blocking the credit. + flashFeeDiscountPercent = await getFlashFeeDiscountPercent({ + username: account.username, + flow: "topup", + }) } const gate = evaluateCreditGate({ @@ -260,6 +382,7 @@ export const paymentHandler = async (req: Request, res: Response) => { grossCents, level: account.level, priorDayGrossCents, + flashFeeDiscountPercent, }) if (!gate.credit) { diff --git a/test/flash/unit/app/offers/cashout-fee-discount.spec.ts b/test/flash/unit/app/offers/cashout-fee-discount.spec.ts new file mode 100644 index 000000000..c50e3578f --- /dev/null +++ b/test/flash/unit/app/offers/cashout-fee-discount.spec.ts @@ -0,0 +1,260 @@ +/** + * CashoutManager.createOffer × the Fee Discount whitelist: the operator can + * discount a named user's Flash service fee on Jamaican bank cashouts. The + * Money math runs for real — including the JMD conversion, which is the + * primary Jamaican rail — so only the ERPNext-backed whitelist read, the + * exchange rate, and the usual IO around offer creation are mocked. + */ +const mockStorageAdd = jest.fn() +const mockFindWalletById = jest.fn() +const mockFindAccountById = jest.fn() +const mockValidOfferFrom = jest.fn() +const mockResolveSelection = jest.fn() +const mockAddInvoice = jest.fn() +const mockGetBankOwner = jest.fn() +const mockGetBankAccounts = jest.fn() +const mockGetCashoutExchangeRate = jest.fn() +const mockGetFlashFeeDiscountPercent = jest.fn() + +jest.mock("@services/alerts/ops-events", () => ({ + notifyOpsEvent: jest.fn(), + toDisplayAmount: jest.requireActual("@services/alerts/ops-events").toDisplayAmount, +})) + +jest.mock("@config", () => ({ + Cashout: { + // 100 bips = 1% Flash service fee. + OfferConfig: { fee: 100n as BasisPoints, duration: 3600 as Seconds }, + SkipPayment: false, + }, + ExchangeRates: {}, +})) + +jest.mock("@app/cash-wallet-cutover/cashout-routing", () => ({ + resolveCashoutWalletSelection: (...args: unknown[]) => mockResolveSelection(...args), +})) + +jest.mock("@services/ibex/client", () => ({ + __esModule: true, + default: { addInvoice: (...args: unknown[]) => mockAddInvoice(...args) }, +})) + +jest.mock("@services/ledger/caching", () => ({ + getBankOwnerIbexAccount: () => mockGetBankOwner(), +})) + +jest.mock("@services/email", () => ({ + EmailService: { sendCashoutInitiatedEmail: jest.fn() }, +})) + +jest.mock("@services/frappe/ErpNext", () => ({ + __esModule: true, + default: { + getBankAccountsByCustomer: (...args: unknown[]) => mockGetBankAccounts(...args), + getCashoutExchangeRate: (...args: unknown[]) => mockGetCashoutExchangeRate(...args), + }, +})) + +jest.mock("@services/frappe/fee-discounts", () => ({ + getFlashFeeDiscountPercent: (...args: unknown[]) => + mockGetFlashFeeDiscountPercent(...args), +})) + +jest.mock("@services/mongoose", () => ({ + AccountsRepository: jest.fn(() => ({ + findById: (...args: unknown[]) => mockFindAccountById(...args), + })), + WalletsRepository: jest.fn(() => ({ + findById: (...args: unknown[]) => mockFindWalletById(...args), + })), +})) + +jest.mock("@app/offers/storage/Redis", () => ({ + __esModule: true, + default: { + add: (...args: unknown[]) => mockStorageAdd(...args), + }, +})) + +jest.mock("@app/offers/ValidOffer", () => ({ + __esModule: true, + default: { from: (...args: unknown[]) => mockValidOfferFrom(...args) }, +})) + +jest.mock("@domain/bitcoin/lightning", () => { + const actual = jest.requireActual("@domain/bitcoin/lightning") + return { + ...actual, + decodeInvoice: jest.fn(() => ({ + destination: "0".repeat(66) as Pubkey, + paymentHash: + "8862fa7f4dcea0533952783bda143ff7fb7242a9573ac74f1ff944a601f02319" as PaymentHash, + paymentRequest: "lnbc1test" as EncodedPaymentRequest, + milliSatsAmount: 0 as MilliSatoshis, + description: "", + cltvDelta: null, + amount: null, + paymentAmount: null, + routeHints: [], + paymentSecret: null, + features: [], + expiresAt: new Date(Date.now() + 600_000), + isExpired: false, + })), + } +}) + +import CashoutManager from "@app/offers/CashoutManager" +import { JMDAmount, USDAmount } from "@domain/shared" +import { ExchangeRateQueryError } from "@services/frappe/errors" + +const offerId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" as OfferId +const walletId = "11111111-1111-4111-8111-111111111111" as WalletId +const flashWalletId = "22222222-2222-4222-8222-222222222222" as WalletId +const accountId = "64df1a2b3c4d5e6f78901234" as AccountId + +// $500.00 -> a 1% (100 bips) service fee of exactly $5.00 (500¢). +const amount = USDAmount.cents("50000") +if (amount instanceof Error) throw amount + +// NCB buy rate as ERPNext serves it: J$160.00 per US$1 (16000 JMD cents). +// Chosen to divide evenly so the JMD expectations below are exact, not rounded. +const jmdRate = JMDAmount.dollars(160) +if (jmdRate instanceof Error) throw jmdRate + +const offeredPayout = () => { + expect(mockValidOfferFrom).toHaveBeenCalledTimes(1) + return mockValidOfferFrom.mock.calls[0][0].payout +} + +describe("CashoutManager fee discount", () => { + beforeEach(() => { + jest.clearAllMocks() + mockGetBankOwner.mockResolvedValue(flashWalletId) + mockFindWalletById.mockResolvedValue({ id: walletId, accountId }) + mockFindAccountById.mockResolvedValue({ + id: accountId, + erpParty: "party-1", + username: "civilizedbarbarian", + }) + mockResolveSelection.mockResolvedValue({ + route: "usd", + userWalletId: walletId, + flashWalletId, + }) + mockAddInvoice.mockResolvedValue({ invoice: { bolt11: "lnbc1test" } }) + mockGetBankAccounts.mockResolvedValue([{ name: "bank-1", currency: "USD" }]) + mockGetCashoutExchangeRate.mockResolvedValue(jmdRate) + mockGetFlashFeeDiscountPercent.mockResolvedValue(0) + mockValidOfferFrom.mockResolvedValue({ details: {} }) + mockStorageAdd.mockResolvedValue({ id: offerId, details: {} }) + }) + + it("consults the whitelist for the account's username in the cashout flow", async () => { + await CashoutManager.createOffer(walletId, amount, "bank-1", accountId) + + expect(mockGetFlashFeeDiscountPercent).toHaveBeenCalledWith({ + username: "civilizedbarbarian", + flow: "cashout", + }) + }) + + it("charges the standard fee for a 0% discount", async () => { + await CashoutManager.createOffer(walletId, amount, "bank-1", accountId) + + const payout = offeredPayout() + expect(payout.serviceFee.asCents()).toBe("500") + expect(payout.amount.asCents()).toBe("49500") + }) + + it("discounts the service fee by the whitelisted percentage", async () => { + mockGetFlashFeeDiscountPercent.mockResolvedValue(25) + + await CashoutManager.createOffer(walletId, amount, "bank-1", accountId) + + // 1% of $500 = 500¢ full fee; 25% off -> 375¢; payout $496.25. + const payout = offeredPayout() + expect(payout.serviceFee.asCents()).toBe("375") + expect(payout.amount.asCents()).toBe("49625") + }) + + it("waives the service fee entirely at a 100% discount", async () => { + mockGetFlashFeeDiscountPercent.mockResolvedValue(100) + + await CashoutManager.createOffer(walletId, amount, "bank-1", accountId) + + const payout = offeredPayout() + expect(payout.serviceFee.asCents()).toBe("0") + expect(payout.amount.asCents()).toBe("50000") + }) + + describe("JMD payout (the primary Jamaican cashout rail)", () => { + beforeEach(() => { + mockGetBankAccounts.mockResolvedValue([{ name: "bank-1", currency: "JMD" }]) + }) + + it("charges the standard fee and converts at the locked rate with no discount", async () => { + await CashoutManager.createOffer(walletId, amount, "bank-1", accountId) + + const payout = offeredPayout() + expect(payout.serviceFee.asCents()).toBe("500") + // $495.00 x J$160.00 = J$79,200.00 + expect(payout.amount.asCents()).toBe("7920000") + expect(payout.exchangeRate).toBe(jmdRate) + }) + + it("discounts the service fee and converts the LARGER usd payout to JMD", async () => { + mockGetFlashFeeDiscountPercent.mockResolvedValue(25) + + await CashoutManager.createOffer(walletId, amount, "bank-1", accountId) + + // 1% of $500 = 500¢ full fee; 25% off -> 375¢; usd payout $496.25. + // $496.25 x J$160.00 = J$79,400.00 — the discount must reach the JMD + // conversion, not just the fee line. + const payout = offeredPayout() + expect(payout.serviceFee.asCents()).toBe("375") + expect(payout.amount.asCents()).toBe("7940000") + expect(payout.exchangeRate).toBe(jmdRate) + }) + + it("waives the service fee entirely at a 100% discount", async () => { + mockGetFlashFeeDiscountPercent.mockResolvedValue(100) + + await CashoutManager.createOffer(walletId, amount, "bank-1", accountId) + + const payout = offeredPayout() + expect(payout.serviceFee.asCents()).toBe("0") + // $500.00 x J$160.00 = J$80,000.00 + expect(payout.amount.asCents()).toBe("8000000") + }) + + it("fails closed on a missing exchange rate — a discount never rescues a JMD offer", async () => { + mockGetFlashFeeDiscountPercent.mockResolvedValue(25) + mockGetCashoutExchangeRate.mockResolvedValue( + new ExchangeRateQueryError("No USD->JMD for_buying rate found in ERPNext"), + ) + + const result = await CashoutManager.createOffer( + walletId, + amount, + "bank-1", + accountId, + ) + + expect(result).toBeInstanceOf(ExchangeRateQueryError) + expect(mockValidOfferFrom).not.toHaveBeenCalled() + }) + }) + + it("passes an undefined username through (accounts without one just get no discount)", async () => { + mockFindAccountById.mockResolvedValue({ id: accountId, erpParty: "party-1" }) + + await CashoutManager.createOffer(walletId, amount, "bank-1", accountId) + + expect(mockGetFlashFeeDiscountPercent).toHaveBeenCalledWith({ + username: undefined, + flow: "cashout", + }) + expect(offeredPayout().serviceFee.asCents()).toBe("500") + }) +}) diff --git a/test/flash/unit/graphql/public/root/query/cashout-rate.spec.ts b/test/flash/unit/graphql/public/root/query/cashout-rate.spec.ts index dbc5d1107..aeb4ef4e9 100644 --- a/test/flash/unit/graphql/public/root/query/cashout-rate.spec.ts +++ b/test/flash/unit/graphql/public/root/query/cashout-rate.spec.ts @@ -6,6 +6,11 @@ jest.mock("@services/frappe/ErpNext", () => ({ }, })) +const mockGetFlashFeeDiscountPercent = jest.fn() +jest.mock("@services/frappe/fee-discounts", () => ({ + getFlashFeeDiscountPercent: (...a: unknown[]) => mockGetFlashFeeDiscountPercent(...a), +})) + import { Cashout } from "@config" import { JMDAmount } from "@domain/shared" import CashoutRateQuery from "@graphql/public/root/query/cashout-rate" @@ -16,7 +21,11 @@ type CashoutRateResult = { feeBasisPoints: number } -const resolveQuery = async (): Promise => { +const USERNAME = "civilizedbarbarian" + +const resolveQuery = async ( + { username }: { username?: string } = { username: USERNAME }, +): Promise => { const query = CashoutRateQuery as unknown as { resolve: ( source: null, @@ -25,11 +34,23 @@ const resolveQuery = async (): Promise => { info: never, ) => Promise } - return query.resolve(null, {}, {}, undefined as never) + // cashoutRate is an authed atAccountLevel query, so the account is always in + // resolver context — that is what makes a per-account fee quote possible. + return query.resolve(null, {}, { domainAccount: { username } }, undefined as never) +} + +const okRate = () => { + const rate = JMDAmount.dollars(152.7) + if (rate instanceof Error) throw rate + mockGetCashoutExchangeRate.mockResolvedValue(rate) + return rate } beforeEach(() => { mockGetCashoutExchangeRate.mockReset() + mockGetFlashFeeDiscountPercent.mockReset() + // Nobody discounted by default. + mockGetFlashFeeDiscountPercent.mockResolvedValue(0) }) describe("cashoutRate query", () => { @@ -47,6 +68,97 @@ describe("cashoutRate query", () => { expect(Number.isInteger(result.feeBasisPoints)).toBe(true) }) + it("quotes the DISCOUNTED fee for a whitelisted account so the preview matches the offer", async () => { + // CashoutManager.createOffer discounts the same user's fee when it builds + // the offer; quoting the undiscounted config fee here would make the + // entry-screen preview disagree with the offer the user then accepts — + // exactly the mismatch this query exists to prevent. + okRate() + mockGetFlashFeeDiscountPercent.mockResolvedValue(25) + + const result = await resolveQuery() + + expect(mockGetFlashFeeDiscountPercent).toHaveBeenCalledWith({ + username: USERNAME, + flow: "cashout", + }) + // 25% off the Flash fee — a 200-bip config quotes 150 bips. + const fullFee = Number(Cashout.OfferConfig.fee) + expect(result.feeBasisPoints).toBe(fullFee * 0.75) + expect(result.feeBasisPoints).toBeLessThan(fullFee) + expect(Number.isInteger(result.feeBasisPoints)).toBe(true) + }) + + it("rounds a fractional discount to a whole bip, within the ±1 bip the SDL documents", async () => { + // feeBasisPoints is an Int, so a discount whose kept-bips product is not an + // integer CANNOT match the offer exactly: this rounds to a whole bip here, + // while CashoutManager keeps full precision and rounds once at the end, on + // money. The SDL description promises "up to 1 bip from rounding" rather + // than an exact match; this pins that contract so the next reader does not + // re-tighten the wording. 33.4% off a 200-bip fee -> keptBips 6660 -> + // round(133.2) = 133 bips quoted, while a $500 cashout is charged + // 1000¢ * 6660/10000 = 666¢ ($6.66) against a $6.65 preview. + okRate() + mockGetFlashFeeDiscountPercent.mockResolvedValue(33.4) + + const result = await resolveQuery() + + const fullFee = Number(Cashout.OfferConfig.fee) + const exact = (fullFee * (10000 - 3340)) / 10000 + expect(Number.isInteger(exact)).toBe(false) + expect(result.feeBasisPoints).toBe(Math.round(exact)) + expect(Number.isInteger(result.feeBasisPoints)).toBe(true) + expect(Math.abs(result.feeBasisPoints - exact)).toBeLessThanOrEqual(1) + }) + + it("is exact for a half-percent-multiple discount at the configured fee", async () => { + // The other half of the same contract: every discount ops has actually + // configured (whole and half percents) lands on an integer bip, so preview + // and offer agree bit-for-bit. If the configured fee ever changes such that + // this stops holding, this test says so. + okRate() + mockGetFlashFeeDiscountPercent.mockResolvedValue(33.5) + + const result = await resolveQuery() + + const fullFee = Number(Cashout.OfferConfig.fee) + const exact = (fullFee * (10000 - 3350)) / 10000 + expect(Number.isInteger(exact)).toBe(true) + expect(result.feeBasisPoints).toBe(exact) + }) + + it("quotes a zero fee at a 100% waiver", async () => { + okRate() + mockGetFlashFeeDiscountPercent.mockResolvedValue(100) + + expect((await resolveQuery()).feeBasisPoints).toBe(0) + }) + + it("quotes the standard fee when the whitelist is unreadable (fail-open, the other divergence the SDL names)", async () => { + // getFlashFeeDiscountPercent fails open to 0, so an ERPNext blip degrades + // the preview to the standard fee instead of breaking the screen. The + // reverse ordering is the one that generates tickets — a read that works + // here and fails 60s later when createOffer runs charges the FULL fee + // against a discounted quote (50 bips on a 25%-off account, not 1), which + // is why the SDL description names this alongside the rounding tolerance. + okRate() + mockGetFlashFeeDiscountPercent.mockResolvedValue(0) + + expect((await resolveQuery()).feeBasisPoints).toBe(Number(Cashout.OfferConfig.fee)) + }) + + it("quotes the standard fee for an account with no username", async () => { + okRate() + + const result = await resolveQuery({}) + + expect(mockGetFlashFeeDiscountPercent).toHaveBeenCalledWith({ + username: undefined, + flow: "cashout", + }) + expect(result.feeBasisPoints).toBe(Number(Cashout.OfferConfig.fee)) + }) + it("fails closed when ERPNext has no rate — never quotes a guessed rate", async () => { mockGetCashoutExchangeRate.mockResolvedValue( new ExchangeRateQueryError("No USD->JMD for_buying rate found in ERPNext"), diff --git a/test/flash/unit/services/frappe/BridgeTransferRequestWriter.spec.ts b/test/flash/unit/services/frappe/BridgeTransferRequestWriter.spec.ts index 161ec402a..e39df5e5b 100644 --- a/test/flash/unit/services/frappe/BridgeTransferRequestWriter.spec.ts +++ b/test/flash/unit/services/frappe/BridgeTransferRequestWriter.spec.ts @@ -18,6 +18,7 @@ import { writeBridgeCashoutFailed, writeBridgeCashoutPending, writeBridgeDepositRequest, + writeFygaroTopupRequest, writeIbexCryptoReceiveRequest, } from "@services/frappe/BridgeTransferRequestWriter" import { FygaroTopupHistoryQueryError } from "@services/frappe/errors" @@ -354,6 +355,46 @@ describe("BridgeTransferRequestWriter", () => { ) }) + describe("writeFygaroTopupRequest", () => { + it("writes a Fygaro payment as a Fiat Received topup audit row", async () => { + await writeFygaroTopupRequest({ + transactionId: "ftx-1", + amount: "20.00", + currency: "USD", + accountId: "acct-1", + rawPayload: { transactionId: "ftx-1" }, + }) + + expect(lastRequestInput()).toEqual( + expect.objectContaining({ + requestId: "fygaro:ftx-1", + provider: "Fygaro", + status: BridgeTransferRequestStatus.FiatReceived, + accountId: "acct-1", + sourceSystemsSeen: ["fygaro_webhook"], + }), + ) + }) + + it("marks email-derived attribution in source systems so the admin view shows how the row got its account", async () => { + await writeFygaroTopupRequest({ + transactionId: "ftx-2", + amount: "20.00", + currency: "USD", + accountId: "acct-email-match", + emailAttributed: true, + rawPayload: { transactionId: "ftx-2" }, + }) + + expect(lastRequestInput()).toEqual( + expect.objectContaining({ + accountId: "acct-email-match", + sourceSystemsSeen: ["fygaro_webhook", "email_attribution"], + }), + ) + }) + }) + describe("sumFygaroTopupGrossCentsLast24h", () => { it("queries the trailing 24h excluding the payment's own fygaro-prefixed audit row", async () => { sumSince.mockResolvedValue(10_000) diff --git a/test/flash/unit/services/frappe/ErpNext.spec.ts b/test/flash/unit/services/frappe/ErpNext.spec.ts index 42eefe00b..9185b28ed 100644 --- a/test/flash/unit/services/frappe/ErpNext.spec.ts +++ b/test/flash/unit/services/frappe/ErpNext.spec.ts @@ -19,10 +19,12 @@ jest.mock("@config", () => ({ import axios from "axios" import { ErpNext } from "@services/frappe/ErpNext" +import { FeeDiscountQueryError } from "@services/frappe/errors" import { BridgeTransferRequest, BridgeTransferRequestStatus, BridgeTransferRequestTransactionType, + EMAIL_ATTRIBUTION_SOURCE_SYSTEM, } from "@services/frappe/models/BridgeTransferRequest" const mockedAxios = axios as unknown as { @@ -235,6 +237,107 @@ describe("ErpNext.upsertBridgeTransferRequest", () => { ) }) + describe("email_attribution marker", () => { + // `fygaro:` rows carry the marker only while their account_id came from + // the unverified payer email. It gates the daily-cap sum + // (sumFygaroTopupGrossCentsSince skips marked rows), so unlike every other + // member of source_systems_seen it must describe the CURRENT attribution + // rather than accumulate forever. + const emailAttributedExisting = { + name: "BTR-FYG-1", + status: BridgeTransferRequestStatus.Completed, + source_systems_seen: `fygaro_webhook,${EMAIL_ATTRIBUTION_SOURCE_SYSTEM}`, + account_id: "account-1", + } + + const fygaroTopup = (sourceSystemsSeen: string[], accountId?: string) => + new BridgeTransferRequest({ + requestId: "fygaro:tx-1", + transactionType: BridgeTransferRequestTransactionType.Topup, + status: BridgeTransferRequestStatus.FiatReceived, + provider: "Fygaro", + amount: "100.00", + currency: "USD", + accountId, + sourceSystemsSeen, + }) + + it("clears the marker when a customReference-attributed write lands on an email-attributed row", async () => { + // Without this, a re-delivery that verified the account via + // customReference would leave the sticky marker in place and the + // already-credited $100 would stay invisible to the daily cap — the + // account would read $0 spent and could auto-credit its full cap again. + mockedAxios.get.mockResolvedValue({ data: { data: [emailAttributedExisting] } }) + mockedAxios.put.mockResolvedValue({ data: { data: { name: "BTR-FYG-1" } } }) + + await client.upsertBridgeTransferRequest( + fygaroTopup(["fygaro_webhook"], "account-1"), + ) + + expect(mockedAxios.put).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ source_systems_seen: "fygaro_webhook" }), + expect.any(Object), + ) + }) + + it("keeps the marker when the incoming write is itself email-attributed", async () => { + mockedAxios.get.mockResolvedValue({ data: { data: [emailAttributedExisting] } }) + mockedAxios.put.mockResolvedValue({ data: { data: { name: "BTR-FYG-1" } } }) + + await client.upsertBridgeTransferRequest( + fygaroTopup(["fygaro_webhook", EMAIL_ATTRIBUTION_SOURCE_SYSTEM], "account-1"), + ) + + expect(mockedAxios.put).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + source_systems_seen: `fygaro_webhook,${EMAIL_ATTRIBUTION_SOURCE_SYSTEM}`, + }), + expect.any(Object), + ) + }) + + it("keeps the marker when the incoming write names no account", async () => { + // An unattributed re-delivery says nothing about how the row got its + // account_id, so it must not clear another writer's claim. + mockedAxios.get.mockResolvedValue({ data: { data: [emailAttributedExisting] } }) + mockedAxios.put.mockResolvedValue({ data: { data: { name: "BTR-FYG-1" } } }) + + await client.upsertBridgeTransferRequest(fygaroTopup(["fygaro_webhook"])) + + expect(mockedAxios.put).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + source_systems_seen: `fygaro_webhook,${EMAIL_ATTRIBUTION_SOURCE_SYSTEM}`, + }), + expect.any(Object), + ) + }) + + it("clears the marker on the create-race path too", async () => { + mockedAxios.get + .mockResolvedValueOnce({ data: { data: [] } }) + .mockResolvedValueOnce({ data: { data: [emailAttributedExisting] } }) + mockedAxios.post.mockRejectedValue({ + isAxiosError: true, + response: { status: 409, data: { exception: "DuplicateEntryError" } }, + }) + mockedAxios.put.mockResolvedValue({ data: { data: { name: "BTR-FYG-1" } } }) + + const result = await client.upsertBridgeTransferRequest( + fygaroTopup(["fygaro_webhook"], "account-1"), + ) + + expect(result).toBe(true) + expect(mockedAxios.put).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ source_systems_seen: "fygaro_webhook" }), + expect.any(Object), + ) + }) + }) + it("keeps last-write-wins semantics for Cashout rows", async () => { mockedAxios.get.mockResolvedValue({ data: { @@ -435,6 +538,9 @@ describe("ErpNext.sumFygaroTopupGrossCentsSince", () => { // gross and wrongly lock the account out for a day. ["Bridge Transfer Request", "currency", "=", "USD"], ["Bridge Transfer Request", "status", "in", ["Fiat Received", "Completed"]], + // NOTE: email-attributed rows are excluded in code, not here — a Frappe + // `not like` filter would evaluate NULL for rows with an empty + // source_systems_seen and silently drop them from the window. // The window must filter on last_seen_at (written in UTC by this code), // NOT Frappe's `creation`, which is stored naive in the ERP site's // configured time zone — comparing that against a UTC cutoff would @@ -445,6 +551,13 @@ describe("ErpNext.sumFygaroTopupGrossCentsSince", () => { // limit_page_length 0 = no pagination cap; a truncated window would // under-count and quietly defeat the daily cap. expect(config.params.limit_page_length).toBe(0) + // source_systems_seen must be fetched — it is what marks a row as + // email-attributed, and the exclusion below cannot work without it. + expect(JSON.parse(config.params.fields)).toEqual([ + "request_id", + "amount", + "source_systems_seen", + ]) }) it("returns 0 for an empty window", async () => { @@ -495,4 +608,158 @@ describe("ErpNext.sumFygaroTopupGrossCentsSince", () => { expect(result).toBeInstanceOf(Error) }) + + it("excludes email-attributed rows — an unverified payer email must not burn the account's cap", async () => { + // A relative pays $125 for someone else's top-up with a blank + // customReference and their own email at checkout. The row is stamped onto + // the matched account for DISPLAY only; counting it here would consume the + // whole level-1 $125 daily allowance and bounce that account's own top-up + // hours later. Adversarially, anyone who knows a victim's email could lock + // them out of auto-credit for 24h with a single card payment. + mockedAxios.get.mockResolvedValue({ + data: { + data: [ + { + request_id: "fygaro:tx-stranger", + amount: "125.00", + source_systems_seen: "fygaro_webhook,email_attribution", + }, + { + request_id: "fygaro:tx-mine", + amount: "10.00", + source_systems_seen: "fygaro_webhook", + }, + ], + }, + }) + + expect(await client.sumFygaroTopupGrossCentsSince(params)).toBe(1000) + }) + + it("still counts rows with no source_systems_seen (absent marker is not an exemption)", async () => { + // Fails CLOSED: an unset/legacy source_systems_seen means "not + // email-attributed", so the row counts. The SQL-side alternative + // (`not like`) would have dropped exactly these rows and under-counted. + mockedAxios.get.mockResolvedValue({ + data: { + data: [ + { request_id: "fygaro:tx-1", amount: "25.00", source_systems_seen: null }, + { request_id: "fygaro:tx-2", amount: "10.00" }, + ], + }, + }) + + expect(await client.sumFygaroTopupGrossCentsSince(params)).toBe(3500) + }) + + it("does not treat a lookalike source system as email attribution", async () => { + // Matched on comma-separated members, not substrings, so a future + // "email_attribution_reviewed" marker cannot silently exempt a row. + mockedAxios.get.mockResolvedValue({ + data: { + data: [ + { + request_id: "fygaro:tx-1", + amount: "25.00", + source_systems_seen: "fygaro_webhook,email_attribution_reviewed", + }, + ], + }, + }) + + expect(await client.sumFygaroTopupGrossCentsSince(params)).toBe(2500) + }) + + it("skips an email-attributed row without failing on its amount", async () => { + // The exclusion runs before the fail-closed amount checks: an excluded row + // contributes nothing by design, so its amount is irrelevant and must not + // error the whole window. + mockedAxios.get.mockResolvedValue({ + data: { + data: [ + { + request_id: "fygaro:tx-stranger", + amount: null, + source_systems_seen: "fygaro_webhook,email_attribution", + }, + { + request_id: "fygaro:tx-mine", + amount: "10.00", + source_systems_seen: "fygaro_webhook", + }, + ], + }, + }) + + expect(await client.sumFygaroTopupGrossCentsSince(params)).toBe(1000) + }) +}) + +describe("ErpNext.getFeeDiscounts", () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it("requests only ACTIVE rows, with every field the validator reads", async () => { + mockedAxios.get.mockResolvedValue({ data: { data: [] } }) + + await client.getFeeDiscounts() + + const [url, config] = mockedAxios.get.mock.calls[0] + expect(url).toBe("https://erp.example/api/resource/Fee%20Discount") + // The active=1 filter is what stops a promo the operator ended from + // continuing to discount Flash's fee. (validateFeeDiscountDoc re-checks + // `active` too — belt and braces, since this reader fails OPEN and a lost + // filter would never alarm.) + expect(JSON.parse(config.params.filters)).toEqual([["active", "=", 1]]) + expect(JSON.parse(config.params.fields)).toEqual([ + "username", + "discount_percent", + "applies_to_topup", + "applies_to_cashout", + "active", + ]) + // No pagination cap: a truncated page would silently drop whitelisted + // users off the discount. + expect(config.params.limit_page_length).toBe(0) + }) + + it("returns the rows as-is for the validator to coerce", async () => { + const rows = [ + { + username: "civilizedbarbarian", + discount_percent: "25", + applies_to_topup: 1, + applies_to_cashout: 0, + active: 1, + }, + ] + mockedAxios.get.mockResolvedValue({ data: { data: rows } }) + + expect(await client.getFeeDiscounts()).toEqual(rows) + }) + + it("returns an empty list when no rows are active", async () => { + mockedAxios.get.mockResolvedValue({ data: { data: [] } }) + + expect(await client.getFeeDiscounts()).toEqual([]) + }) + + it("returns an error (not a silent empty list) when the response is not an array", async () => { + mockedAxios.get.mockResolvedValue({ data: { data: { username: "x" } } }) + + expect(await client.getFeeDiscounts()).toBeInstanceOf(FeeDiscountQueryError) + }) + + it("returns an error when the response has no data", async () => { + mockedAxios.get.mockResolvedValue({ data: {} }) + + expect(await client.getFeeDiscounts()).toBeInstanceOf(FeeDiscountQueryError) + }) + + it("returns the error rather than throwing when the request rejects", async () => { + mockedAxios.get.mockRejectedValue(new Error("erpnext down")) + + expect(await client.getFeeDiscounts()).toBeInstanceOf(FeeDiscountQueryError) + }) }) diff --git a/test/flash/unit/services/frappe/coerce.spec.ts b/test/flash/unit/services/frappe/coerce.spec.ts new file mode 100644 index 000000000..a878de158 --- /dev/null +++ b/test/flash/unit/services/frappe/coerce.spec.ts @@ -0,0 +1,55 @@ +/** + * The two ERPNext field coercions are shared by every raw-doctype reader + * (fee-discounts, fygaro-settings). They used to be copy-pasted per reader; + * these cases pin the contract in one place so teaching one reader a new + * encoding cannot silently diverge from the other. + */ +import { toBoolean, toFiniteNumber } from "@services/frappe/coerce" + +describe("toFiniteNumber", () => { + it.each([ + [12.5, 12.5], + [0, 0], + [-3, -3], + ["12.5", 12.5], + [" 7 ", 7], + ])("coerces %p to %p", (input, expected) => { + expect(toFiniteNumber(input)).toBe(expected) + }) + + it.each([ + ["a blank string", " "], + ["an empty string", ""], + ["a non-numeric string", "abc"], + ["null", null], + ["undefined", undefined], + ["NaN", NaN], + ["Infinity", Infinity], + ["an object", {}], + // A boolean is NOT silently 1/0 here — a Check field read as a number is a + // schema mistake the caller should reject, not paper over. + ["a boolean", true], + ])("returns undefined for %s", (_label, input) => { + expect(toFiniteNumber(input)).toBeUndefined() + }) +}) + +describe("toBoolean", () => { + it.each([[1], [true], ["1"]])("treats %p as true", (input) => { + expect(toBoolean(input)).toBe(true) + }) + + it.each([ + ["0", 0], + ['"0"', "0"], + ["false", false], + ["null", null], + ["undefined", undefined], + // Unrecognized encodings are false: a Check field ERPNext never emits this + // way must not turn a discount (or an auto-credit toggle) on by accident. + ['"true"', "true"], + ['"Yes"', "Yes"], + ])("treats %s as false", (_label, input) => { + expect(toBoolean(input)).toBe(false) + }) +}) diff --git a/test/flash/unit/services/frappe/fee-discounts.spec.ts b/test/flash/unit/services/frappe/fee-discounts.spec.ts new file mode 100644 index 000000000..b6915caa7 --- /dev/null +++ b/test/flash/unit/services/frappe/fee-discounts.spec.ts @@ -0,0 +1,205 @@ +jest.mock("@services/logger", () => ({ + baseLogger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})) + +jest.mock("@services/frappe/ErpNext", () => ({ + __esModule: true, + default: { getFeeDiscounts: (...args: unknown[]) => mockGetFeeDiscounts(...args) }, +})) + +const mockGetFeeDiscounts = jest.fn() + +import { FeeDiscountQueryError } from "@services/frappe/errors" +import { + getFlashFeeDiscountPercent, + validateFeeDiscountDoc, + _resetFeeDiscountsCache, +} from "@services/frappe/fee-discounts" + +const ROW = { + username: "civilizedbarbarian", + discount_percent: 50, + applies_to_topup: 1, + applies_to_cashout: 1, + active: 1, +} + +let now = 1_000_000 + +beforeEach(() => { + jest.clearAllMocks() + _resetFeeDiscountsCache() + now = 1_000_000 + jest.spyOn(Date, "now").mockImplementation(() => now) + mockGetFeeDiscounts.mockResolvedValue([{ ...ROW }]) +}) + +afterEach(() => { + ;(Date.now as jest.Mock).mockRestore?.() +}) + +describe("validateFeeDiscountDoc", () => { + it("maps a well-formed row", () => { + expect(validateFeeDiscountDoc({ ...ROW })).toEqual({ + username: "civilizedbarbarian", + discount: { discountPercent: 50, appliesToTopup: true, appliesToCashout: true }, + }) + }) + + it("coerces numeric strings and check-field encodings", () => { + expect( + validateFeeDiscountDoc({ + username: " bob ", + discount_percent: "12.5", + applies_to_topup: "1", + applies_to_cashout: 0, + active: 1, + }), + ).toEqual({ + username: "bob", + discount: { discountPercent: 12.5, appliesToTopup: true, appliesToCashout: false }, + }) + }) + + it("lowercases the username key (platform usernames are case-insensitive)", () => { + expect(validateFeeDiscountDoc({ ...ROW, username: "RegginaB" })).toEqual({ + username: "regginab", + discount: { discountPercent: 50, appliesToTopup: true, appliesToCashout: true }, + }) + }) + + it.each([ + ["blank username", { ...ROW, username: " " }], + ["missing username", { ...ROW, username: undefined }], + ["non-numeric percent", { ...ROW, discount_percent: "abc" }], + ["negative percent", { ...ROW, discount_percent: -1 }], + ["percent over 100", { ...ROW, discount_percent: 101 }], + // Unticking Active is how an operator ends a promo. The ERPNext query + // filters on active=1, but that filter must not be the ONLY thing + // enforcing it: this reader fails OPEN, so a dropped or malformed + // filter would keep every deactivated row discounting Flash's fee + // indefinitely with nothing to alarm on. + ["active unticked", { ...ROW, active: 0 }], + ["active missing", { ...ROW, active: undefined }], + ["active as an unrecognized string", { ...ROW, active: "no" }], + ])("rejects a row with %s", (_label, doc) => { + expect(validateFeeDiscountDoc(doc)).toBeUndefined() + }) + + it("accepts the check-field encodings ERPNext uses for Active", () => { + expect(validateFeeDiscountDoc({ ...ROW, active: "1" })).toBeDefined() + expect(validateFeeDiscountDoc({ ...ROW, active: true })).toBeDefined() + }) +}) + +describe("getFlashFeeDiscountPercent", () => { + it("returns the discount for a whitelisted user in a covered flow", async () => { + await expect( + getFlashFeeDiscountPercent({ username: "civilizedbarbarian", flow: "topup" }), + ).resolves.toBe(50) + await expect( + getFlashFeeDiscountPercent({ username: "civilizedbarbarian", flow: "cashout" }), + ).resolves.toBe(50) + }) + + it("matches case-insensitively between the whitelisted and registered casing", async () => { + // Usernames are case-insensitive platform-wide (findByUsername uses + // collation strength 2), so account.username carries registration case + // while the operator types an arbitrary casing into ERPNext. A user + // registered "RegginaB" whitelisted as "regginab" must still match — + // and vice versa. + mockGetFeeDiscounts.mockResolvedValue([{ ...ROW, username: "regginab" }]) + await expect( + getFlashFeeDiscountPercent({ username: "RegginaB", flow: "topup" }), + ).resolves.toBe(50) + + _resetFeeDiscountsCache() + mockGetFeeDiscounts.mockResolvedValue([{ ...ROW, username: "RegginaB" }]) + await expect( + getFlashFeeDiscountPercent({ username: "regginab", flow: "topup" }), + ).resolves.toBe(50) + }) + + it("returns 0 for users not on the whitelist", async () => { + await expect( + getFlashFeeDiscountPercent({ username: "someone-else", flow: "topup" }), + ).resolves.toBe(0) + }) + + it("returns 0 for an undefined username (accounts without one)", async () => { + await expect( + getFlashFeeDiscountPercent({ username: undefined, flow: "topup" }), + ).resolves.toBe(0) + expect(mockGetFeeDiscounts).not.toHaveBeenCalled() + }) + + it("scopes the discount to the flows the row covers", async () => { + mockGetFeeDiscounts.mockResolvedValue([ + { ...ROW, applies_to_topup: 1, applies_to_cashout: 0 }, + ]) + await expect( + getFlashFeeDiscountPercent({ username: "civilizedbarbarian", flow: "topup" }), + ).resolves.toBe(50) + await expect( + getFlashFeeDiscountPercent({ username: "civilizedbarbarian", flow: "cashout" }), + ).resolves.toBe(0) + }) + + it("fails open to 0 when the ERPNext read errors, and caches the failure", async () => { + mockGetFeeDiscounts.mockResolvedValue(new FeeDiscountQueryError("boom")) + await expect( + getFlashFeeDiscountPercent({ username: "civilizedbarbarian", flow: "topup" }), + ).resolves.toBe(0) + // The failure is memoised — no fetch storm during an outage. + await getFlashFeeDiscountPercent({ username: "civilizedbarbarian", flow: "topup" }) + expect(mockGetFeeDiscounts).toHaveBeenCalledTimes(1) + }) + + it("fails open to 0 when the reader throws unexpectedly", async () => { + mockGetFeeDiscounts.mockRejectedValue(new Error("network down")) + await expect( + getFlashFeeDiscountPercent({ username: "civilizedbarbarian", flow: "topup" }), + ).resolves.toBe(0) + }) + + it("returns 0 for a deactivated row even if ERPNext hands one back", async () => { + // Defence in depth against the active=1 query filter being lost: the row + // is on the whitelist but the operator ended the promo, so the user pays + // the standard fee. + mockGetFeeDiscounts.mockResolvedValue([{ ...ROW, active: 0 }]) + + await expect( + getFlashFeeDiscountPercent({ username: "civilizedbarbarian", flow: "topup" }), + ).resolves.toBe(0) + await expect( + getFlashFeeDiscountPercent({ username: "civilizedbarbarian", flow: "cashout" }), + ).resolves.toBe(0) + }) + + it("drops malformed rows without taking out the rest of the whitelist", async () => { + mockGetFeeDiscounts.mockResolvedValue([ + { username: "broken", discount_percent: "abc" }, + { ...ROW }, + ]) + await expect( + getFlashFeeDiscountPercent({ username: "civilizedbarbarian", flow: "topup" }), + ).resolves.toBe(50) + await expect( + getFlashFeeDiscountPercent({ username: "broken", flow: "topup" }), + ).resolves.toBe(0) + }) + + it("serves from cache within the TTL and re-reads after it", async () => { + await getFlashFeeDiscountPercent({ username: "civilizedbarbarian", flow: "topup" }) + now += 30_000 + await getFlashFeeDiscountPercent({ username: "civilizedbarbarian", flow: "topup" }) + expect(mockGetFeeDiscounts).toHaveBeenCalledTimes(1) + + now += 31_000 // past the 60s TTL + mockGetFeeDiscounts.mockResolvedValue([{ ...ROW, discount_percent: 75 }]) + await expect( + getFlashFeeDiscountPercent({ username: "civilizedbarbarian", flow: "topup" }), + ).resolves.toBe(75) + expect(mockGetFeeDiscounts).toHaveBeenCalledTimes(2) + }) +}) diff --git a/test/flash/unit/services/fygaro/webhook-server/fees.spec.ts b/test/flash/unit/services/fygaro/webhook-server/fees.spec.ts index 28b6f3162..1d3f885bb 100644 --- a/test/flash/unit/services/fygaro/webhook-server/fees.spec.ts +++ b/test/flash/unit/services/fygaro/webhook-server/fees.spec.ts @@ -263,3 +263,102 @@ describe("evaluateCreditGate", () => { expect(gate).toEqual({ credit: false, reason: "settings-unavailable" }) }) }) + +describe("flash-fee discount", () => { + // $10.00 gross with the canonical schedule: processor 79¢, full flash 20¢. + it.each([ + // discount% -> expected flash¢, net¢ + [0, 20, 901], + [25, 15, 906], // round(20 * 0.75) = 15 + [50, 10, 911], + [100, 0, 921], // full waiver: net = gross - processor only + ])("discounts the flash fee by %i%%", (discount, flash, net) => { + const fees = computeFygaroFees({ + grossCents: 1000, + settings: settings(), + flashFeeDiscountPercent: discount, + }) + expect(fees.processorFeeCents).toBe(79) // never discounted + expect(fees.flashFeeCents).toBe(flash) + expect(fees.netCents).toBe(net) + }) + + it("discounts the fixed flash margin component too", () => { + // 2% + $0.50 fixed on $10.00 = 20¢ + 50¢ = 70¢ full flash fee; 50% -> 35¢. + const fees = computeFygaroFees({ + grossCents: 1000, + settings: settings({ flashMarginFixed: 0.5 }), + flashFeeDiscountPercent: 50, + }) + expect(fees.flashFeeCents).toBe(35) + }) + + it("supports fractional discount percentages", () => { + // $500.00 gross -> full flash fee 1000¢; 12.5% off -> round(875) = 875¢. + const fees = computeFygaroFees({ + grossCents: 50000, + settings: settings(), + flashFeeDiscountPercent: 12.5, + }) + expect(fees.flashFeeCents).toBe(875) + }) + + it("clamps out-of-range discounts so garbage can never inflate the fee", () => { + const negative = computeFygaroFees({ + grossCents: 1000, + settings: settings(), + flashFeeDiscountPercent: -50, + }) + expect(negative.flashFeeCents).toBe(20) // treated as 0% + const over = computeFygaroFees({ + grossCents: 1000, + settings: settings(), + flashFeeDiscountPercent: 250, + }) + expect(over.flashFeeCents).toBe(0) // treated as 100% + }) + + it("flows through evaluateCreditGate into the credited fees", () => { + const base = { + creditEnabled: true, + currency: "USD", + grossCents: 1000, + level: 1, + priorDayGrossCents: 0, + } + const gate = evaluateCreditGate({ + ...base, + settings: settings(), + flashFeeDiscountPercent: 100, + }) + expect(gate).toMatchObject({ + credit: true, + fees: { netCents: 921, processorFeeCents: 79, flashFeeCents: 0 }, + }) + }) + + it("can rescue a non-positive-net payment (fee waiver makes the net positive)", () => { + // $0.60 gross: processor round(60*2.99/100)+49 = 51¢, full flash 1¢ -> + // net 8¢ ... still positive; use flashMarginFixed to force it negative. + // 2% + $0.10 fixed on $0.60 = 1¢ + 10¢ = 11¢ flash; net 60-51-11 = -2¢. + const base = { + creditEnabled: true, + currency: "USD", + grossCents: 60, + level: 1, + priorDayGrossCents: 0, + } + const withFee = evaluateCreditGate({ + ...base, + settings: settings({ flashMarginFixed: 0.1, minimumTopup: 0.1 }), + }) + expect(withFee).toEqual({ credit: false, reason: "non-positive-net" }) + + const waived = evaluateCreditGate({ + ...base, + settings: settings({ flashMarginFixed: 0.1, minimumTopup: 0.1 }), + flashFeeDiscountPercent: 100, + }) + expect(waived).toMatchObject({ credit: true, fees: { netCents: 9 } }) + }) +}) diff --git a/test/flash/unit/services/fygaro/webhook-server/payment.spec.ts b/test/flash/unit/services/fygaro/webhook-server/payment.spec.ts index e583d1c1c..d3a8ab6c7 100644 --- a/test/flash/unit/services/fygaro/webhook-server/payment.spec.ts +++ b/test/flash/unit/services/fygaro/webhook-server/payment.spec.ts @@ -27,9 +27,21 @@ jest.mock("@services/lock", () => ({ jest.mock("@services/mongoose", () => ({ AccountsRepository: () => ({ findByUsername: (...args: unknown[]) => mockFindByUsername(...args), + findByUserId: (...args: unknown[]) => mockFindByUserId(...args), }), })) +jest.mock("@services/kratos", () => ({ + IdentityRepository: () => ({ + getUserIdFromIdentifier: (...args: unknown[]) => mockGetUserIdFromIdentifier(...args), + }), +})) + +jest.mock("@services/frappe/fee-discounts", () => ({ + getFlashFeeDiscountPercent: (...args: unknown[]) => + mockGetFlashFeeDiscountPercent(...args), +})) + jest.mock("@services/frappe/BridgeTransferRequestWriter", () => ({ writeFygaroTopupRequest: (...args: unknown[]) => mockWriteFygaroTopup(...args), completeFygaroTopup: (...args: unknown[]) => mockCompleteFygaroTopup(...args), @@ -75,6 +87,9 @@ jest.mock("@services/fygaro/webhook-server/fygaro-settings", () => ({ const mockLockIdempotencyKey = jest.fn() const mockLockPaymentIdempotencyKey = jest.fn() const mockFindByUsername = jest.fn() +const mockFindByUserId = jest.fn() +const mockGetUserIdFromIdentifier = jest.fn() +const mockGetFlashFeeDiscountPercent = jest.fn() const mockWriteFygaroTopup = jest.fn() const mockCompleteFygaroTopup = jest.fn() const mockIsFygaroTopupCompleted = jest.fn() @@ -99,6 +114,10 @@ const DEFAULT_SETTINGS = { dailyTopupLimits: { 1: 125, 2: 1000, 3: 2500 }, } +import { + CouldNotFindAccountFromUsernameError, + UnknownRepositoryError, +} from "@domain/errors" import { ResourceAttemptsLockServiceError } from "@domain/lock" import { paymentHandler } from "@services/fygaro/webhook-server/routes/payment" @@ -137,7 +156,16 @@ beforeEach(() => { async (_key: unknown, fn: () => Promise) => fn(), ) mockIsFygaroTopupCompleted.mockResolvedValue(false) - mockFindByUsername.mockResolvedValue({ id: ACCOUNT_ID, level: 1 }) + mockFindByUsername.mockResolvedValue({ + id: ACCOUNT_ID, + level: 1, + username: VALID_BODY.customReference, + }) + // Email fallback attribution: default to "no identity for this email". + mockGetUserIdFromIdentifier.mockResolvedValue(new Error("IdentifierNotFoundError")) + mockFindByUserId.mockResolvedValue(new Error("CouldNotFindError")) + // Fee Discount whitelist: default to nobody discounted. + mockGetFlashFeeDiscountPercent.mockResolvedValue(0) mockWriteFygaroTopup.mockResolvedValue(true) mockSumFygaroLast24h.mockResolvedValue(0) mockCompleteFygaroTopup.mockResolvedValue(true) @@ -210,7 +238,12 @@ describe("fygaro paymentHandler", () => { }) it("treats an unknown username as unattributed", async () => { - mockFindByUsername.mockResolvedValue(new Error("CouldNotFindError")) + // The real repository signals "no account owns this username" with this + // exact class (AccountsRepository.findByUsername) — a bare Error would not + // distinguish a miss from a Mongo fault, which the next test pins apart. + mockFindByUsername.mockResolvedValue( + new CouldNotFindAccountFromUsernameError(VALID_BODY.customReference), + ) const res = makeRes() await paymentHandler(makeReq(VALID_BODY), res) @@ -221,6 +254,232 @@ describe("fygaro paymentHandler", () => { expect(res.json).toHaveBeenCalledWith({ status: "recorded", attributed: false }) }) + it("returns 500 on an account-lookup FAULT instead of falling back to the payer email", async () => { + // findByUsername returns CouldNotFindAccountFromUsernameError for a genuine + // miss and parseRepositoryError(err) — e.g. UnknownRepositoryError — for a + // Mongo timeout. Collapsing the two is a money bug on RE-DELIVERY: a + // perfectly-referenced payment that was already credited would be rewritten + // with the sticky `email_attribution` marker, which permanently exempts the + // row from sumFygaroTopupGrossCentsSince. The account's spent daily + // allowance would then read $0 and it could auto-credit its full cap a + // second time inside 24h — and the 200 ack would stop Fygaro retrying, so + // the payment strands for manual credit too. Fail transient: 500, retry. + mockFindByUsername.mockResolvedValue( + new UnknownRepositoryError("connection timed out"), + ) + // Her own checkout email DOES resolve to her account — exactly the state + // that made the collapsed branch look harmless. + mockGetUserIdFromIdentifier.mockResolvedValue("kratos-user-1") + mockFindByUserId.mockResolvedValue({ + id: ACCOUNT_ID, + level: 1, + username: VALID_BODY.customReference, + }) + const res = makeRes() + + await paymentHandler(makeReq(VALID_BODY), res) + + expect(res.status).toHaveBeenCalledWith(500) + expect(res.json).toHaveBeenCalledWith({ + error: "account lookup unavailable; will retry", + }) + // The payment IS recorded — unattributed. Bailing without a write would + // leave captured fiat with no server-side record if Fygaro's retry budget + // expires before Mongo recovers, which is the failure class this webhook + // exists to end. No account_id, and in particular never the sticky + // email-attribution marker over a row whose account_id is verifiable. + expect(mockWriteFygaroTopup).toHaveBeenCalledWith( + expect.objectContaining({ + transactionId: VALID_BODY.transactionId, + amount: "10.00", + accountId: undefined, + emailAttributed: false, + }), + ) + expect(mockWriteFygaroTopup).not.toHaveBeenCalledWith( + expect.objectContaining({ emailAttributed: true }), + ) + // The email fallback must not even be consulted. + expect(mockGetUserIdFromIdentifier).not.toHaveBeenCalled() + // No dedupe lock: taking the non-releasing timelock here would make the + // very next retry ack 200 "already_processed" and defeat the self-heal. + expect(mockLockIdempotencyKey).not.toHaveBeenCalled() + expect(mockCreditFygaroTopup).not.toHaveBeenCalled() + // Ops gets PAGED — payments are landing unattributed and uncredited, the + // same severity as the audit-write failure below. Static dedup key so one + // outage is one incident, not one page per in-flight payment. + expect(mockAlertBridge).toHaveBeenCalledWith( + expect.objectContaining({ + dedupKey: "fygaro:account-lookup-failed", + severity: "critical", + }), + ) + }) + + it("still answers 500 when the unattributed audit write also fails during a lookup fault", async () => { + // Both Mongo and ERPNext are down. The response must stay 500 so Fygaro + // retries; the failed write needs no extra handling beyond a log. + mockFindByUsername.mockResolvedValue( + new UnknownRepositoryError("connection timed out"), + ) + mockWriteFygaroTopup.mockResolvedValue(new Error("erpnext down")) + const res = makeRes() + + await paymentHandler(makeReq(VALID_BODY), res) + + expect(res.status).toHaveBeenCalledWith(500) + expect(res.json).toHaveBeenCalledWith({ + error: "account lookup unavailable; will retry", + }) + expect(mockLockIdempotencyKey).not.toHaveBeenCalled() + expect(mockCreditFygaroTopup).not.toHaveBeenCalled() + }) + + describe("payer-email fallback attribution (display-only)", () => { + const EMAIL_ACCOUNT_ID = "account-email-match" as AccountId + const KRATOS_USER_ID = "kratos-user-1" + + beforeEach(() => { + mockGetUserIdFromIdentifier.mockResolvedValue(KRATOS_USER_ID) + mockFindByUserId.mockResolvedValue({ + id: EMAIL_ACCOUNT_ID, + level: 1, + username: "reginab", + }) + }) + + it("stamps the email-matched account on the audit row and names it in the alert, without crediting", async () => { + mockFygaroConfig.credit = { enabled: true } + const res = makeRes() + + await paymentHandler(makeReq({ ...VALID_BODY, customReference: "" }), res) + + expect(mockGetUserIdFromIdentifier).toHaveBeenCalledWith(VALID_BODY.client.email) + expect(mockFindByUserId).toHaveBeenCalledWith(KRATOS_USER_ID) + expect(mockWriteFygaroTopup).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: EMAIL_ACCOUNT_ID, + emailAttributed: true, + }), + ) + expect(mockAlertBridge).toHaveBeenCalledWith( + expect.objectContaining({ + detail: expect.stringContaining("@reginab"), + context: expect.objectContaining({ email_matched_username: "reginab" }), + }), + ) + expect(mockNotifyOpsEvent).toHaveBeenCalledWith( + expect.objectContaining({ + phase: "fygaro-unattributed", + accountId: EMAIL_ACCOUNT_ID, + meta: expect.objectContaining({ emailMatchedUsername: "reginab" }), + }), + ) + // Email attribution is display-grade: the credit path must stay cold + // even with the deploy gate on. + expect(mockCreditFygaroTopup).not.toHaveBeenCalled() + expect(mockGetFygaroSettings).not.toHaveBeenCalled() + expect(res.json).toHaveBeenCalledWith({ status: "recorded", attributed: false }) + }) + + it("keeps the email match out of every gate — no daily-cap read, no credit", async () => { + // The checkout email is payer-typed and identity-unverified. A relative + // (or anyone who knows a victim's email) paying with a blank + // customReference must not touch the named account's daily allowance or + // its balance: the row is an audit/display artifact and nothing more. + // The read side is enforced in ErpNext.sumFygaroTopupGrossCentsSince, + // which skips rows marked email_attribution; here the webhook must not + // even consult the gate. + mockFygaroConfig.credit = { enabled: true } + const res = makeRes() + + await paymentHandler(makeReq({ ...VALID_BODY, customReference: "" }), res) + + expect(mockSumFygaroLast24h).not.toHaveBeenCalled() + expect(mockCreditFygaroTopup).not.toHaveBeenCalled() + expect(mockCompleteFygaroTopup).not.toHaveBeenCalled() + // The alert must read as a lead to confirm, not an instruction to credit. + expect(mockAlertBridge).toHaveBeenCalledWith( + expect.objectContaining({ + detail: expect.stringContaining("UNVERIFIED"), + }), + ) + }) + + it("lowercases the payer email before the kratos lookup (checkout keyboards auto-capitalize)", async () => { + // Mobile checkout keyboards auto-capitalize ("Regina@Example.com") while + // the stored Kratos identifier is lowercase; the lookup must normalize or + // attribution silently never fires for those payments. + const res = makeRes() + + await paymentHandler( + makeReq({ + ...VALID_BODY, + customReference: "", + client: { name: "Regina Bailey", email: " Regina@Example.COM " }, + }), + res, + ) + + expect(mockGetUserIdFromIdentifier).toHaveBeenCalledWith("regina@example.com") + expect(mockWriteFygaroTopup).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: EMAIL_ACCOUNT_ID, + emailAttributed: true, + }), + ) + }) + + it("never attempts email attribution when customReference resolved an account", async () => { + const res = makeRes() + + await paymentHandler(makeReq(VALID_BODY), res) + + expect(mockGetUserIdFromIdentifier).not.toHaveBeenCalled() + expect(mockWriteFygaroTopup).toHaveBeenCalledWith( + expect.objectContaining({ accountId: ACCOUNT_ID, emailAttributed: false }), + ) + }) + + it("leaves the row unattributed when the email matches no identity", async () => { + mockGetUserIdFromIdentifier.mockResolvedValue(new Error("IdentifierNotFoundError")) + const res = makeRes() + + await paymentHandler(makeReq({ ...VALID_BODY, customReference: "" }), res) + + expect(mockWriteFygaroTopup).toHaveBeenCalledWith( + expect.objectContaining({ accountId: undefined, emailAttributed: false }), + ) + expect(res.json).toHaveBeenCalledWith({ status: "recorded", attributed: false }) + }) + + it("records unattributed when the kratos lookup throws (best-effort, never breaks the webhook)", async () => { + mockGetUserIdFromIdentifier.mockRejectedValue(new Error("kratos down")) + const res = makeRes() + + await paymentHandler(makeReq({ ...VALID_BODY, customReference: "" }), res) + + expect(mockWriteFygaroTopup).toHaveBeenCalledWith( + expect.objectContaining({ accountId: undefined }), + ) + expect(res.json).toHaveBeenCalledWith({ status: "recorded", attributed: false }) + }) + + it("skips the lookup entirely for a payload without a client email", async () => { + const res = makeRes() + + await paymentHandler( + makeReq({ ...VALID_BODY, customReference: "", client: { name: "X" } }), + res, + ) + + expect(mockGetUserIdFromIdentifier).not.toHaveBeenCalled() + expect(mockWriteFygaroTopup).toHaveBeenCalledWith( + expect.objectContaining({ accountId: undefined }), + ) + }) + }) + it("returns 500 when the ERPNext audit write fails so Fygaro retries", async () => { mockWriteFygaroTopup.mockResolvedValue(new Error("erpnext down")) const res = makeRes() @@ -289,6 +548,68 @@ describe("fygaro paymentHandler", () => { expect(res.json).toHaveBeenCalledWith({ status: "success", credited: true }) }) + it("consults the Fee Discount whitelist for the account's username in the topup flow", async () => { + const res = makeRes() + + await paymentHandler(makeReq(VALID_BODY), res) + + expect(mockGetFlashFeeDiscountPercent).toHaveBeenCalledWith({ + username: VALID_BODY.customReference, + flow: "topup", + }) + }) + + it("credits with a discounted flash fee and promotes the discounted breakdown", async () => { + mockGetFlashFeeDiscountPercent.mockResolvedValue(50) + const res = makeRes() + + await paymentHandler(makeReq(VALID_BODY), res) + + // $10.00 gross -> $0.79 processor + $0.10 flash (50% off $0.20) -> $9.11 net + expect(mockCreditFygaroTopup).toHaveBeenCalledWith( + expect.objectContaining({ amountCents: 911 }), + ) + expect(mockCompleteFygaroTopup).toHaveBeenCalledWith( + expect.objectContaining({ + processorFee: "0.79", + flashFee: "0.10", + finalAmount: "9.11", + }), + ) + expect(res.json).toHaveBeenCalledWith({ status: "success", credited: true }) + }) + + it("waives the flash fee entirely at a 100% discount (processor fee still applies)", async () => { + mockGetFlashFeeDiscountPercent.mockResolvedValue(100) + const res = makeRes() + + await paymentHandler(makeReq(VALID_BODY), res) + + expect(mockCreditFygaroTopup).toHaveBeenCalledWith( + expect.objectContaining({ amountCents: 921 }), + ) + expect(mockCompleteFygaroTopup).toHaveBeenCalledWith( + expect.objectContaining({ + processorFee: "0.79", + flashFee: "0.00", + finalAmount: "9.21", + }), + ) + }) + + it("does not consult the whitelist when auto-credit is disabled in settings", async () => { + mockGetFygaroSettings.mockResolvedValue({ + ...DEFAULT_SETTINGS, + autoCreditEnabled: false, + }) + const res = makeRes() + + await paymentHandler(makeReq(VALID_BODY), res) + + expect(mockGetFlashFeeDiscountPercent).not.toHaveBeenCalled() + expect(res.json).toHaveBeenCalledWith({ status: "recorded", credited: false }) + }) + it("records without crediting and fires the generic critical when the credit fails", async () => { mockCreditFygaroTopup.mockResolvedValue( new FygaroCreditError("intraledger-send", "some send error"),