Files
bank/hooks/payments.go
zeekay 9aad21388d refactor(hooks): remove CurrencyCloud direct ingest, take normalized events
bankd no longer registers /v1/bank/webhooks/currencycloud/* or
/v1/bank/webhooks/ifx/* endpoints — those were a layering leak. All
provider webhooks now arrive normalized via the existing HMAC-protected
/v1/bank/webhooks/payments/callback after forexd ingests and translates
them.

payments.go: paymentCallbackPayload accepts the normalized shape
(provider, eventType, externalId, status, reason). A transaction is
resolved either by internal id or by the provider-issued externalId
stored in the ccTransactionId column.

mapCallbackStatus learns "cancelled"/"canceled" so the full lifecycle
from forexd round-trips intact.
2026-05-24 00:43:00 -07:00

345 lines
10 KiB
Go

package hooks
import (
"bytes"
"encoding/json"
"fmt"
"log/slog"
"math"
"net/http"
"os"
"time"
"github.com/hanzoai/base/apis"
"github.com/hanzoai/base/core"
"github.com/hanzoai/base/tools/hook"
"github.com/luxfi/bank/collections"
)
// Allowed status transitions. Any transition not listed is rejected.
var allowedTransitions = map[string][]string{
"pending": {"processing", "failed", "cancelled"},
"processing": {"completed", "failed", "cancelled"},
}
func validTransition(from, to string) bool {
for _, s := range allowedTransitions[from] {
if s == to {
return true
}
}
return false
}
// RegisterPaymentHooks attaches hooks for payment lifecycle:
// - Validate balance before debit transaction creation
// - Enforce status transition rules on update
// - Hold funds on create, settle/reverse on status change
// - Route outbound payments to forex service
// - Webhook endpoint for payment callbacks
func RegisterPaymentHooks(app core.App) {
// Pre-create: validate balance for debits.
app.OnRecordCreate(collections.TransactionCollectionName).BindFunc(func(e *core.RecordEvent) error {
if e.Record.GetString("direction") != "debit" {
return e.Next()
}
amount := int64(math.Round(e.Record.GetFloat("amount")))
if amount <= 0 {
return apis.NewBadRequestError("amount must be positive", nil)
}
accountId := e.Record.GetString("account")
currency := e.Record.GetString("currency")
// Atomic balance check + hold within a transaction to prevent races.
err := app.RunInTransaction(func(txApp core.App) error {
bal, err := txApp.FindFirstRecordByFilter(
collections.BalanceCollectionName,
`account = {:accountId} && currency = {:currency}`,
map[string]any{"accountId": accountId, "currency": currency},
)
if err != nil {
return apis.NewBadRequestError("no balance found for currency", nil)
}
available := int64(math.Round(bal.GetFloat("available")))
if available < amount {
return apis.NewBadRequestError("insufficient balance", nil)
}
// Floor check: ensure available would not go negative.
if available-amount < 0 {
return apis.NewBadRequestError("insufficient balance", nil)
}
// Hold funds atomically: available -= amount, held += amount.
held := int64(math.Round(bal.GetFloat("held")))
bal.Set("available", available-amount)
bal.Set("held", held+amount)
return txApp.Save(bal)
})
if err != nil {
return err
}
return e.Next()
})
// Post-create: route outbound payments to forex service.
// (Balance hold is already done in pre-create transaction above.)
app.OnRecordCreateExecute(collections.TransactionCollectionName).BindFunc(func(e *core.RecordEvent) error {
if err := e.Next(); err != nil {
return err
}
if e.Record.GetString("direction") != "debit" {
return nil
}
txType := e.Record.GetString("type")
if txType == "payment" || txType == "conversion" {
go routeToForex(app, e.Record)
}
return nil
})
// Pre-update: enforce status transition rules.
app.OnRecordUpdate(collections.TransactionCollectionName).BindFunc(func(e *core.RecordEvent) error {
oldStatus := e.Record.Original().GetString("status")
newStatus := e.Record.GetString("status")
if oldStatus != newStatus && !validTransition(oldStatus, newStatus) {
return apis.NewBadRequestError(
fmt.Sprintf("invalid status transition: %s -> %s", oldStatus, newStatus), nil,
)
}
return e.Next()
})
// Post-update: settle balances on status change.
app.OnRecordUpdateExecute(collections.TransactionCollectionName).BindFunc(func(e *core.RecordEvent) error {
if err := e.Next(); err != nil {
return err
}
oldStatus := e.Record.Original().GetString("status")
newStatus := e.Record.GetString("status")
if oldStatus == newStatus {
return nil
}
accountId := e.Record.GetString("account")
currency := e.Record.GetString("currency")
amount := int64(math.Round(e.Record.GetFloat("amount")))
direction := e.Record.GetString("direction")
switch {
case newStatus == "completed" && direction == "debit":
// Funds were already removed from available during hold.
// Just release the hold counter.
updateBalance(app, accountId, currency, 0, -amount)
case newStatus == "completed" && direction == "credit":
// Incoming funds: credit available.
updateBalance(app, accountId, currency, amount, 0)
case (newStatus == "failed" || newStatus == "cancelled") && direction == "debit":
// Reverse hold: restore available, release held.
updateBalance(app, accountId, currency, amount, -amount)
}
return nil
})
// Webhook endpoint for external payment service callbacks.
app.OnServe().Bind(&hook.Handler[*core.ServeEvent]{
Id: "bankPaymentCallbacks",
Func: func(e *core.ServeEvent) error {
e.Router.POST("/v1/bank/webhooks/payments/callback", handlePaymentCallback(app)).
Bind(RequireHMACAuth())
return e.Next()
},
})
}
// updateBalance atomically adjusts available and held amounts.
func updateBalance(app core.App, accountId, currency string, availableDelta, heldDelta int64) {
bal, err := app.FindFirstRecordByFilter(
collections.BalanceCollectionName,
`account = {:accountId} && currency = {:currency}`,
map[string]any{"accountId": accountId, "currency": currency},
)
if err != nil {
// For credits to a new currency, create the balance record.
if availableDelta > 0 && heldDelta == 0 {
createBalance(app, accountId, currency, availableDelta)
} else {
app.Logger().Error("payments: balance not found",
slog.String("accountId", accountId),
slog.String("currency", currency),
)
}
return
}
newAvailable := int64(math.Round(bal.GetFloat("available"))) + availableDelta
newHeld := int64(math.Round(bal.GetFloat("held"))) + heldDelta
// F13: Floor check — never allow negative available balance.
if newAvailable < 0 {
app.Logger().Error("payments: balance floor violation — available would go negative",
slog.String("accountId", accountId),
slog.String("currency", currency),
slog.Int64("newAvailable", newAvailable),
)
return
}
bal.Set("available", newAvailable)
bal.Set("held", newHeld)
if err := app.Save(bal); err != nil {
app.Logger().Error("payments: failed to update balance", slog.String("error", err.Error()))
}
}
func createBalance(app core.App, accountId, currency string, amount int64) {
col, err := app.FindCollectionByNameOrId(collections.BalanceCollectionName)
if err != nil {
return
}
bal := core.NewRecord(col)
bal.Set("account", accountId)
bal.Set("currency", currency)
bal.Set("available", amount)
bal.Set("held", 0)
_ = app.Save(bal)
}
// routeToForex sends outbound payment details to the forex service.
func routeToForex(app core.App, record *core.Record) {
forexURL := os.Getenv("FOREX_SERVICE_URL")
if forexURL == "" {
app.Logger().Warn("payments: FOREX_SERVICE_URL not set, skipping outbound routing")
return
}
payload := map[string]any{
"transactionId": record.Id,
"amount": record.GetFloat("amount"),
"currency": record.GetString("currency"),
"type": record.GetString("type"),
"beneficiary": record.GetString("beneficiary"),
"reference": record.GetString("reference"),
}
body, err := json.Marshal(payload)
if err != nil {
app.Logger().Error("payments: marshal failed", slog.String("error", err.Error()))
return
}
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Post(forexURL+"/v1/payments", "application/json", bytes.NewReader(body))
if err != nil {
app.Logger().Error("payments: forex request failed",
slog.String("transactionId", record.Id),
slog.String("error", err.Error()),
)
return
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
app.Logger().Error("payments: forex returned error",
slog.String("transactionId", record.Id),
slog.Int("status", resp.StatusCode),
)
}
}
// paymentCallbackPayload is the normalized shape forexd POSTs here for any
// provider webhook. TransactionID resolves an internal record by id;
// ExternalID resolves by the provider-issued reference stored in
// ccTransactionId. Exactly one must be set.
type paymentCallbackPayload struct {
TransactionID string `json:"transactionId,omitempty"`
ExternalID string `json:"externalId,omitempty"`
Provider string `json:"provider,omitempty"`
EventType string `json:"eventType,omitempty"`
Status string `json:"status"`
Reason string `json:"reason,omitempty"`
}
func handlePaymentCallback(app core.App) func(*core.RequestEvent) error {
return func(e *core.RequestEvent) error {
var p paymentCallbackPayload
if err := e.BindBody(&p); err != nil {
return e.JSON(http.StatusBadRequest, map[string]string{"error": "invalid payload"})
}
if p.TransactionID == "" && p.ExternalID == "" {
return e.JSON(http.StatusBadRequest, map[string]string{"error": "missing transactionId or externalId"})
}
record, err := lookupTransaction(app, p.TransactionID, p.ExternalID)
if err != nil {
// Return 200 so the caller does not retry unknown transactions.
return e.JSON(http.StatusOK, map[string]string{"status": "ignored"})
}
newStatus := mapCallbackStatus(p.Status)
oldStatus := record.GetString("status")
if !validTransition(oldStatus, newStatus) {
app.Logger().Warn("payments: callback transition rejected",
slog.String("transactionId", p.TransactionID),
slog.String("from", oldStatus),
slog.String("to", newStatus),
)
return e.JSON(http.StatusOK, map[string]string{"status": "ignored"})
}
record.Set("status", newStatus)
if p.Reason != "" {
record.Set("reason", p.Reason)
}
if err := app.Save(record); err != nil {
return e.JSON(http.StatusInternalServerError, map[string]string{"error": "update failed"})
}
return e.JSON(http.StatusOK, map[string]string{"status": "ok"})
}
}
func mapCallbackStatus(s string) string {
switch s {
case "completed", "settled":
return "completed"
case "failed", "rejected":
return "failed"
case "cancelled", "canceled":
return "cancelled"
case "processing", "in_progress":
return "processing"
default:
return "pending"
}
}
// lookupTransaction resolves a transaction record by internal id or by the
// provider-issued external id stored in the ccTransactionId column.
func lookupTransaction(app core.App, internalID, externalID string) (*core.Record, error) {
if internalID != "" {
return app.FindRecordById(collections.TransactionCollectionName, internalID)
}
return app.FindFirstRecordByFilter(
collections.TransactionCollectionName,
"ccTransactionId = {:extId}",
map[string]any{"extId": externalID},
)
}