Files
zeekayandHanzo Dev 452585e716 Go 1.27.1, and the code it can say more simply
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]>
2026-09-16 19:27:58 -07:00

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
}