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
15 changes: 15 additions & 0 deletions dev/apollo-federation/supergraph.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -1096,6 +1096,21 @@ type FygaroTopupInfo
"""Flash margin percentage fee (e.g. 2.0 = 2.0%)."""
flashFeePercent: Float!

"""
Maximum gross card top-up per rolling 24h for level-1 accounts, in USD.
"""
l1DailyLimit: Float!

"""
Maximum gross card top-up per rolling 24h for level-2 accounts, in USD.
"""
l2DailyLimit: Float!

"""
Maximum gross card top-up per rolling 24h for level-3 (Business) accounts, in USD.
"""
l3DailyLimit: Float!

"""Minimum top-up amount, in USD."""
minimumAmount: Float!

Expand Down
3 changes: 3 additions & 0 deletions src/graphql/public/root/query/globals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ const GlobalsQuery = GT.Field({
processorFeeFixed: fygaroSettings.processorFeeFixed,
flashFeePercent: fygaroSettings.flashMarginPercent,
flashFeeFixed: fygaroSettings.flashMarginFixed,
l1DailyLimit: fygaroSettings.dailyTopupLimits[1],
l2DailyLimit: fygaroSettings.dailyTopupLimits[2],
l3DailyLimit: fygaroSettings.dailyTopupLimits[3],
}
: null,
}
Expand Down
15 changes: 15 additions & 0 deletions src/graphql/public/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -873,6 +873,21 @@
"""Flash margin percentage fee (e.g. 2.0 = 2.0%)."""
flashFeePercent: Float!

"""
Maximum gross card top-up per rolling 24h for level-1 accounts, in USD.
"""
l1DailyLimit: Float!

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

View workflow job for this annotation

GitHub Actions / GraphQL Inspector

Field 'l1DailyLimit' was added to object type 'FygaroTopupInfo'

Field 'l1DailyLimit' was added to object type 'FygaroTopupInfo'

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

View workflow job for this annotation

GitHub Actions / GraphQL Inspector

Field 'FygaroTopupInfo.l1DailyLimit' has description 'Maximum gross card top-up per rolling 24h for level-1 accounts, in USD.'

Field 'FygaroTopupInfo.l1DailyLimit' has description 'Maximum gross card top-up per rolling 24h for level-1 accounts, in USD.'

"""
Maximum gross card top-up per rolling 24h for level-2 accounts, in USD.
"""
l2DailyLimit: Float!

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

View workflow job for this annotation

GitHub Actions / GraphQL Inspector

Field 'l2DailyLimit' was added to object type 'FygaroTopupInfo'

Field 'l2DailyLimit' was added to object type 'FygaroTopupInfo'

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

View workflow job for this annotation

GitHub Actions / GraphQL Inspector

Field 'FygaroTopupInfo.l2DailyLimit' has description 'Maximum gross card top-up per rolling 24h for level-2 accounts, in USD.'

Field 'FygaroTopupInfo.l2DailyLimit' has description 'Maximum gross card top-up per rolling 24h for level-2 accounts, in USD.'

"""
Maximum gross card top-up per rolling 24h for level-3 (Business) accounts, in USD.
"""
l3DailyLimit: Float!

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

View workflow job for this annotation

GitHub Actions / GraphQL Inspector

Field 'l3DailyLimit' was added to object type 'FygaroTopupInfo'

Field 'l3DailyLimit' was added to object type 'FygaroTopupInfo'

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

View workflow job for this annotation

GitHub Actions / GraphQL Inspector

Field 'FygaroTopupInfo.l3DailyLimit' has description 'Maximum gross card top-up per rolling 24h for level-3 (Business) accounts, in USD.'

Field 'FygaroTopupInfo.l3DailyLimit' has description 'Maximum gross card top-up per rolling 24h for level-3 (Business) accounts, in USD.'

"""Minimum top-up amount, in USD."""
minimumAmount: Float!

Expand Down
15 changes: 15 additions & 0 deletions src/graphql/public/types/object/fygaro-topup-info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,21 @@ const FygaroTopupInfo = GT.Object({
type: GT.NonNull(GT.Float),
description: "Flash margin fixed fee, in USD.",
},
l1DailyLimit: {
type: GT.NonNull(GT.Float),
description:
"Maximum gross card top-up per rolling 24h for level-1 accounts, in USD.",
},
l2DailyLimit: {
type: GT.NonNull(GT.Float),
description:
"Maximum gross card top-up per rolling 24h for level-2 accounts, in USD.",
},
l3DailyLimit: {
type: GT.NonNull(GT.Float),
description:
"Maximum gross card top-up per rolling 24h for level-3 (Business) accounts, in USD.",
},
}),
})

Expand Down
27 changes: 26 additions & 1 deletion src/services/frappe/BridgeTransferRequestWriter.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import ErpNext from "@services/frappe/ErpNext"
import { baseLogger } from "@services/logger"
import { BridgeTransferRequestUpsertError } from "@services/frappe/errors"
import {
BridgeTransferRequestUpsertError,
FygaroTopupHistoryQueryError,
} from "@services/frappe/errors"

import {
BridgeTransferRequest,
Expand Down Expand Up @@ -243,6 +246,28 @@ export const writeFygaroTopupRequest = async ({
)
}

// Gross cents this account was charged via Fygaro over the trailing 24h,
// excluding the given transaction's own audit row (which is written before the
// credit gate runs). Feeds the per-level daily top-up cap. An unconfigured
// ERPNext client is an error, not zero — the gate must fail closed rather
// than treat a missing history as a clean slate.
export const sumFygaroTopupGrossCentsLast24h = async ({
accountId,
excludeTransactionId,
}: {
accountId: AccountId | string
excludeTransactionId: string
}): Promise<number | FygaroTopupHistoryQueryError> => {
if (!ErpNext?.sumFygaroTopupGrossCentsSince) {
return new FygaroTopupHistoryQueryError("ERPNext client is not configured")
}
return ErpNext.sumFygaroTopupGrossCentsSince({
accountId: String(accountId),
since: new Date(Date.now() - 24 * 60 * 60 * 1000),
excludeRequestId: `fygaro:${excludeTransactionId}`,
})
}

// Whether this Fygaro payment was already fully processed (its audit row
// promoted to Completed by a prior delivery). Used as the processed-marker for
// webhook re-deliveries. A lookup failure degrades to false — the credit
Expand Down
104 changes: 104 additions & 0 deletions src/services/frappe/ErpNext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
CashoutSubmitError,
ExchangeRateQueryError,
FygaroSettingsQueryError,
FygaroTopupHistoryQueryError,
JournalEntryDeleteError,
SetDocTypeValueError,
UpgradeRequestCreateError,
Expand Down Expand Up @@ -100,6 +101,9 @@ export type FygaroSettingsDoc = {
auto_credit_limit?: number | string
minimum_topup?: number | string
auto_credit_enabled?: number | boolean | string
l1_daily_limit?: number | string
l2_daily_limit?: number | string
l3_daily_limit?: number | string
}

export class ErpNext {
Expand Down Expand Up @@ -408,6 +412,106 @@ export class ErpNext {
}
}

// 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
// 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
// `last_seen_at`, which this code writes in UTC on every upsert (a
// re-delivery bump only widens the window — fails closed), NOT Frappe's
// `creation`, which is stored naive in the ERP site's configured time zone:
// comparing that against a UTC-rendered cutoff would silently shrink the
// window by the site's UTC offset. Any read/shape/parse problem is an
// error, not zero — under-counting the window would quietly defeat the cap.
async sumFygaroTopupGrossCentsSince({
accountId,
since,
excludeRequestId,
}: {
accountId: string
since: Date
excludeRequestId: string
}): Promise<number | FygaroTopupHistoryQueryError> {
try {
const filters = JSON.stringify([
[BridgeTransferRequest.doctype, "provider", "=", "Fygaro"],
[
BridgeTransferRequest.doctype,
"transaction_type",
"=",
BridgeTransferRequestTransactionType.Topup,
],
[BridgeTransferRequest.doctype, "account_id", "=", accountId],
[BridgeTransferRequest.doctype, "currency", "=", "USD"],
[
BridgeTransferRequest.doctype,
"status",
"in",
[
BridgeTransferRequestStatus.FiatReceived,
BridgeTransferRequestStatus.Completed,
],
],
[
BridgeTransferRequest.doctype,
"last_seen_at",
">=",
toFrappeDatetime(since.toISOString()),
],
[BridgeTransferRequest.doctype, "request_id", "!=", excludeRequestId],
])
const fields = JSON.stringify(["request_id", "amount"])
const resp = await axios.get(
`${this.url}/api/resource/${encodeURIComponent(BridgeTransferRequest.doctype)}`,
{
params: { filters, fields, limit_page_length: 0 },
headers: this.headers,
},
)
const rows = resp.data?.data
if (!Array.isArray(rows)) {
return new FygaroTopupHistoryQueryError("No data in top-up history response")
}
let sumCents = 0
for (const row of rows as {
request_id?: string
amount?: number | string | null
}[]) {
// 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.
if (row.amount == null) {
return new FygaroTopupHistoryQueryError(
`Missing amount on ${row.request_id ?? "<unknown row>"}`,
)
}
const cents = Math.round(Number(row.amount) * 100)
if (!Number.isFinite(cents)) {
return new FygaroTopupHistoryQueryError(
`Non-numeric amount on ${row.request_id ?? "<unknown row>"}`,
)
}
sumCents += cents
}
return sumCents
} catch (err) {
const responseData = isAxiosError(err) ? err.response?.data : undefined
baseLogger.error(
{ err, responseData, accountId },
"Error summing Fygaro top-up history from ERPNext",
)
recordExceptionInCurrentSpan({
error: err,
attributes: { "erpnext.exception": responseData?.exception },
})
return new FygaroTopupHistoryQueryError(err)
}
}

async listBanks(): Promise<Bank[] | BanksQueryError> {
try {
const resp = await axios.get(`${this.url}/api/resource/Bank`, {
Expand Down
1 change: 1 addition & 0 deletions src/services/frappe/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@ export class BankAccountUpdateRequestQueryError extends ErpNextError {}
export class ExchangeRateQueryError extends ErpNextError {}
export class BridgeTransferRequestUpsertError extends ErpNextError {}
export class FygaroSettingsQueryError extends ErpNextError {}
export class FygaroTopupHistoryQueryError extends ErpNextError {}
38 changes: 36 additions & 2 deletions src/services/fygaro/webhook-server/fees.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,18 @@ export const computeFygaroFees = ({
// Why a payment was NOT auto-credited. `credit-disabled` is the deploy-level
// master gate (FygaroConfig.credit.enabled) and is handled silently; the rest
// are runtime conditions worth an ops alert because credit IS supposed to be on.
// `settings-unavailable` and `history-unavailable` are TRANSIENT (an ERPNext
// blip) — the route answers 500 so the provider retries and the read
// self-heals; every other reason is deterministic and acks 200.
export type RecordOnlyReason =
| "credit-disabled"
| "settings-unavailable"
| "auto-credit-disabled"
| "non-usd"
| "over-limit"
| "no-daily-limit-for-level"
| "history-unavailable"
| "daily-limit-exceeded"
| "under-minimum"
| "non-positive-net"

Expand All @@ -70,10 +76,18 @@ export type CreditGate =
* 2. Fygaro Settings available AND auto_credit_enabled
* 3. currency === "USD"
* 4. gross <= auto_credit_limit (inclusive upper bound on GROSS)
* 5. net > 0 (after fees)
* 6. gross >= minimum_topup (inclusive lower bound on GROSS)
* 5. the account level has a configured daily limit, the trailing-24h
* history read succeeded, and gross + prior-24h gross <= that limit
* (inclusive: a payment landing exactly ON the cap still credits)
* 6. net > 0 (after fees)
* 7. gross >= minimum_topup (inclusive lower bound on GROSS)
* The first failing gate names the record-only reason.
*
* The daily-limit gates count GROSS captured fiat (Fiat Received + Completed
* rows), not net credits: the cap answers "how much card volume may this user
* run per day", and gross is also the only number the client can reproduce
* locally to warn before charging the card.
*
* `under-minimum` is checked last (after the net gate) on purpose: a payment
* below the operator minimum is a valid, positive-net, in-limit USD top-up that
* is simply too small to auto-credit, so it records for manual handling. A
Expand All @@ -87,11 +101,18 @@ export const evaluateCreditGate = ({
currency,
settings,
grossCents,
level,
priorDayGrossCents,
}: {
creditEnabled: boolean
currency: string
settings: FygaroSettings | undefined
grossCents: number
// The recipient account's AccountLevel (0-3).
level: number
// Gross cents this account was charged over the trailing 24h (excluding the
// current payment), or undefined when the history read failed.
priorDayGrossCents: number | undefined
}): CreditGate => {
if (!creditEnabled) return { credit: false, reason: "credit-disabled" }
if (!settings) return { credit: false, reason: "settings-unavailable" }
Expand All @@ -102,6 +123,19 @@ export const evaluateCreditGate = ({
return { credit: false, reason: "over-limit" }
}

const dailyLimitUsd = settings.dailyTopupLimits[level]
if (dailyLimitUsd === undefined) {
// No configured allowance for this level (level 0, or a future level the
// settings row does not know) — fail closed to manual review.
return { credit: false, reason: "no-daily-limit-for-level" }
}
if (priorDayGrossCents === undefined) {
return { credit: false, reason: "history-unavailable" }
}
if (grossCents + priorDayGrossCents > Math.round(dailyLimitUsd * 100)) {
return { credit: false, reason: "daily-limit-exceeded" }
}

const fees = computeFygaroFees({ grossCents, settings })
if (fees.netCents <= 0) return { credit: false, reason: "non-positive-net" }
if (grossCents < Math.round(settings.minimumTopup * 100)) {
Expand Down
24 changes: 24 additions & 0 deletions src/services/fygaro/webhook-server/fygaro-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,16 @@ export type FygaroSettings = {
autoCreditLimit: number // USD
minimumTopup: number // USD
autoCreditEnabled: boolean
// Per-account-level daily top-up caps in GROSS USD, keyed by AccountLevel.
// Levels 1-3 are always present (validation rejects the row otherwise);
// indexing by an arbitrary level yields `number | undefined`, and levels
// absent here (e.g. level 0) have no top-up allowance so they fail the
// credit gate.
dailyTopupLimits: { [level: number]: number | undefined } & {
1: number
2: number
3: number
}
}

const CACHE_TTL_MS = 60_000
Expand Down Expand Up @@ -58,6 +68,9 @@ export const validateFygaroSettings = (
const flashMarginFixed = toFiniteNumber(doc.flash_margin_fixed)
const autoCreditLimit = toFiniteNumber(doc.auto_credit_limit)
const minimumTopup = toFiniteNumber(doc.minimum_topup)
const l1DailyLimit = toFiniteNumber(doc.l1_daily_limit)
const l2DailyLimit = toFiniteNumber(doc.l2_daily_limit)
const l3DailyLimit = toFiniteNumber(doc.l3_daily_limit)

const numbers = [
processorFeePercent,
Expand All @@ -66,8 +79,14 @@ export const validateFygaroSettings = (
flashMarginFixed,
autoCreditLimit,
minimumTopup,
l1DailyLimit,
l2DailyLimit,
l3DailyLimit,
]
// A missing or negative fee/limit is not something we can safely credit off.
// The daily limits are equally load-bearing: a doctype row from before the
// limit fields existed (ERP not yet migrated/saved) hard-stops auto-credit
// rather than crediting uncapped — deploy the ERP fields first.
if (numbers.some((n) => n === undefined || n < 0)) return undefined

return {
Expand All @@ -79,6 +98,11 @@ export const validateFygaroSettings = (
autoCreditLimit: autoCreditLimit as number,
minimumTopup: minimumTopup as number,
autoCreditEnabled: toBoolean(doc.auto_credit_enabled),
dailyTopupLimits: {
1: l1DailyLimit as number,
2: l2DailyLimit as number,
3: l3DailyLimit as number,
},
}
}

Expand Down
Loading
Loading