mirror of
https://github.com/luxfi/bank.git
synced 2026-10-30 17:02:07 +00:00
Modules declare go 1.27.1 and the build images follow. go fix applied the modernizers; omitzero is not applied, since it changes what the JSON says. Co-authored-by: Hanzo Dev <[email protected]>
185 lines
5.6 KiB
Go
185 lines
5.6 KiB
Go
package hooks
|
|
|
|
import (
|
|
"log/slog"
|
|
"math"
|
|
"slices"
|
|
"time"
|
|
|
|
"github.com/hanzoai/base/core"
|
|
"github.com/luxfi/bank/collections"
|
|
)
|
|
|
|
// Fee schedule: amount in basis points (1 bp = 0.01%).
|
|
// Applied to the transaction amount in minor units.
|
|
|
|
// Tiered fee rates by account entity type.
|
|
var tierFees = map[string]int64{
|
|
"individual": 50, // 0.50%
|
|
"business": 30, // 0.30%
|
|
}
|
|
|
|
// Conversion spread in basis points.
|
|
const conversionSpreadBP = 25 // 0.25%
|
|
|
|
// Volume discount thresholds (monthly volume in minor units → discount bp).
|
|
// Discounts subtract from the tier rate.
|
|
var volumeDiscounts = []struct {
|
|
threshold int64
|
|
discount int64
|
|
}{
|
|
{100_000_00, 5}, // > $100k/mo → 5bp off
|
|
{500_000_00, 10}, // > $500k/mo → 10bp off
|
|
{1_000_000_00, 15}, // > $1M/mo → 15bp off
|
|
}
|
|
|
|
// CalculateFee returns the fee in minor units for a transaction.
|
|
// entityType is "individual" or "business".
|
|
// txType is "payment", "conversion", etc.
|
|
// amount is in minor units. monthlyVolume is the rolling 30-day volume.
|
|
// rail is the payment rail (sepa, fps, ach, wire, swift, interac).
|
|
func CalculateFee(entityType, txType string, amount, monthlyVolume int64, rail ...PaymentRail) (feeAmount int64, feeType string) {
|
|
rateBP, ok := tierFees[entityType]
|
|
if !ok {
|
|
rateBP = tierFees["individual"] // default to highest
|
|
}
|
|
|
|
// Apply volume discount.
|
|
for _, volumeDiscount := range slices.Backward(volumeDiscounts) {
|
|
if monthlyVolume >= volumeDiscount.threshold {
|
|
rateBP -= volumeDiscount.discount
|
|
break
|
|
}
|
|
}
|
|
if rateBP < 0 {
|
|
rateBP = 0
|
|
}
|
|
|
|
feeType = "service_fee"
|
|
|
|
switch txType {
|
|
case "conversion":
|
|
// Conversion uses spread instead of tier fee.
|
|
feeAmount = amount * conversionSpreadBP / 10000
|
|
feeType = "conversion_spread"
|
|
return
|
|
|
|
case "payment":
|
|
// The platform's own charge, which every payment carries.
|
|
feeAmount = amount * rateBP / 10000
|
|
|
|
// A rail's flat fee is a network cost passed through, so it is added
|
|
// only when the caller knows which network carried the payment. This
|
|
// defaulted to SWIFT and named the fee after it, so a $100 payment was
|
|
// charged $35.50 — and "swift_fee" is not a type a fee row may hold, so
|
|
// every one of them was refused and no fee was recorded at all. Seven of
|
|
// the eight rails spell a name the collection rejects.
|
|
//
|
|
// Nothing records a rail on a transaction today, so nothing reaches
|
|
// this. When something does, the flat cost joins the service fee rather
|
|
// than renaming it: what the charge IS and which network carried it are
|
|
// two facts, and only the first is what a fee row records.
|
|
if len(rail) > 0 {
|
|
feeAmount += RailFee(rail[0])
|
|
}
|
|
return
|
|
|
|
default:
|
|
// Deposits, withdrawals, on-chain crypto sends and the testnet faucet
|
|
// carry no platform fee here — the schedule prices rails (payment) and
|
|
// FX (conversion), both of which arrive as those types. Charging the
|
|
// tier bp on a withdrawal wrote a fee row that was never debited, and on
|
|
// a crypto amount it was bp on micro-units. No fee, no phantom row.
|
|
return 0, feeType
|
|
}
|
|
}
|
|
|
|
// RegisterFeeHooks attaches a hook that creates a fee record after every
|
|
// transaction is created (when the fee is > 0).
|
|
func RegisterFeeHooks(app core.App) {
|
|
app.OnRecordCreateExecute(collections.TransactionCollectionName).BindFunc(func(e *core.RecordEvent) error {
|
|
if err := e.Next(); err != nil {
|
|
return err
|
|
}
|
|
|
|
amount := int64(math.Round(e.Record.GetFloat("amount")))
|
|
if amount <= 0 {
|
|
return nil
|
|
}
|
|
|
|
// Look up account to get entity type.
|
|
accountId := e.Record.GetString("account")
|
|
if accountId == "" {
|
|
return nil
|
|
}
|
|
|
|
account, err := app.FindRecordById(collections.AccountCollectionName, accountId)
|
|
if err != nil {
|
|
app.Logger().Warn("fees: could not find account for fee calculation",
|
|
slog.String("accountId", accountId),
|
|
)
|
|
return nil // don't block the transaction
|
|
}
|
|
|
|
entityType := account.GetString("entityType")
|
|
txType := e.Record.GetString("type")
|
|
|
|
// Get monthly volume for discount calculation.
|
|
monthlyVolume := getMonthlyVolume(app, accountId)
|
|
|
|
feeAmount, feeType := CalculateFee(entityType, txType, amount, monthlyVolume)
|
|
if feeAmount <= 0 {
|
|
return nil
|
|
}
|
|
|
|
// Create fee record.
|
|
feeCollection, err := app.FindCollectionByNameOrId(collections.FeeCollectionName)
|
|
if err != nil {
|
|
app.Logger().Error("fees: fee collection not found", slog.String("error", err.Error()))
|
|
return nil
|
|
}
|
|
|
|
feeRecord := core.NewRecord(feeCollection)
|
|
feeRecord.Set("transaction", e.Record.Id)
|
|
feeRecord.Set("type", feeType)
|
|
feeRecord.Set("amount", feeAmount)
|
|
feeRecord.Set("currency", e.Record.GetString("currency"))
|
|
|
|
if err := app.Save(feeRecord); err != nil {
|
|
app.Logger().Error("fees: failed to create fee record",
|
|
slog.String("transactionId", e.Record.Id),
|
|
slog.String("error", err.Error()),
|
|
)
|
|
}
|
|
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// getMonthlyVolume sums completed transaction amounts for an account in the
|
|
// last 30 days.
|
|
func getMonthlyVolume(app core.App, accountId string) int64 {
|
|
thirtyDaysAgo := time.Now().UTC().AddDate(0, 0, -30).Format("2006-01-02 15:04:05.000Z")
|
|
records, err := app.FindRecordsByFilter(
|
|
collections.TransactionCollectionName,
|
|
`account = {:accountId} && status = "completed" && created >= {:since}`,
|
|
"",
|
|
0,
|
|
0,
|
|
map[string]any{"accountId": accountId, "since": thirtyDaysAgo},
|
|
)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
|
|
// Normalize to USD cents so the volume-discount tier is currency-neutral —
|
|
// summing raw minor units would let crypto micro-units inflate volume ~10^4x
|
|
// and grant the discount by accident.
|
|
var total int64
|
|
for _, r := range records {
|
|
amt := int64(math.Round(r.GetFloat("amount")))
|
|
total += collections.USDCents(amt, r.GetString("currency"))
|
|
}
|
|
return total
|
|
}
|