Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion dev/apollo-federation/supergraph.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -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!
}
Expand Down
17 changes: 16 additions & 1 deletion src/app/offers/CashoutManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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!)
Expand Down
47 changes: 44 additions & 3 deletions src/graphql/public/root/query/cashout-rate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -27,22 +28,62 @@ 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,
},
}),
})

const CashoutRateQuery = GT.Field({
type: GT.NonNull(CashoutRateType),
resolve: async () => {
resolve: async (
_: unknown,
__: Record<string, never>,
{ 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,
}
},
})
Expand Down
2 changes: 1 addition & 1 deletion src/graphql/public/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -705,9 +705,9 @@
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!

Check notice on line 710 in src/graphql/public/schema.graphql

View workflow job for this annotation

GitHub Actions / GraphQL Inspector

Field 'CashoutRate.feeBasisPoints' description changed from 'Flash cashout service fee in basis points, deducted from the USD amount before conversion.' to '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.'

Field 'CashoutRate.feeBasisPoints' description changed from 'Flash cashout service fee in basis points, deducted from the USD amount before conversion.' to '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.'
}

"""(Positive) Cent amount (1/100 of a dollar)"""
Expand Down
6 changes: 6 additions & 0 deletions src/services/alerts/dedup-key.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =>
Expand Down
12 changes: 11 additions & 1 deletion src/services/frappe/BridgeTransferRequestWriter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
BridgeTransferRequest,
BridgeTransferRequestStatus,
BridgeTransferRequestTransactionType,
EMAIL_ATTRIBUTION_SOURCE_SYSTEM,
} from "./models/BridgeTransferRequest"

type BridgeDepositEventObject = {
Expand Down Expand Up @@ -216,13 +217,20 @@ export const writeFygaroTopupRequest = async ({
amount,
currency,
accountId,
emailAttributed,
createdAt,
rawPayload,
}: {
transactionId: string
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<true | BridgeTransferRequestUpsertError> => {
Expand All @@ -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,
}),
Expand Down
127 changes: 120 additions & 7 deletions src/services/frappe/ErpNext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
CashoutDraftError,
CashoutSubmitError,
ExchangeRateQueryError,
FeeDiscountQueryError,
FygaroSettingsQueryError,
FygaroTopupHistoryQueryError,
JournalEntryDeleteError,
Expand All @@ -32,6 +33,7 @@ import {
BridgeTransferRequest,
BridgeTransferRequestStatus,
BridgeTransferRequestTransactionType,
EMAIL_ATTRIBUTION_SOURCE_SYSTEM,
toFrappeDatetime,
} from "./models/BridgeTransferRequest"
import { Filter } from "./SearchFilters"
Expand Down Expand Up @@ -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
Expand All @@ -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<string, string>
Expand Down Expand Up @@ -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<FeeDiscountDoc[] | FeeDiscountQueryError> {
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
Expand Down Expand Up @@ -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)}`,
{
Expand All @@ -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.
Expand Down Expand Up @@ -773,12 +870,28 @@ export class ErpNext {
payload: ReturnType<BridgeTransferRequest["toErpnext"]>,
existing: BridgeTransferRequestDoc,
): ReturnType<BridgeTransferRequest["toErpnext"]> {
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 (
Expand Down
31 changes: 31 additions & 0 deletions src/services/frappe/coerce.ts
Original file line number Diff line number Diff line change
@@ -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"
Loading
Loading