explicit fee execution

This commit is contained in:
Stephan D
2026-03-11 14:20:50 +01:00
parent b4b9507e7e
commit 15b03b1bc8
10 changed files with 511 additions and 182 deletions

View File

@@ -103,6 +103,108 @@ func TestCompile_ExternalToExternal_BridgeExpansion(t *testing.T) {
}
}
func TestCompile_ExternalToExternal_WithWalletFee_InsertsFeeStep(t *testing.T) {
compiler := New()
graph, err := compiler.Compile(Input{
IntentSnapshot: testIntent(model.PaymentKindPayout),
QuoteSnapshot: &model.PaymentQuoteSnapshot{
DebitAmount: &paymenttypes.Money{Amount: "5.842042", Currency: "USDT"},
FeeLines: []*paymenttypes.FeeLine{
{
Money: &paymenttypes.Money{Amount: "0.42", Currency: "USDT"},
Side: paymenttypes.EntrySideDebit,
Meta: map[string]string{"fee_target": "wallet"},
},
},
Route: &paymenttypes.QuoteRouteSpecification{
RouteRef: "route-1",
Hops: []*paymenttypes.QuoteRouteHop{
{
Index: 10,
Rail: "CRYPTO",
Gateway: "gw-crypto",
InstanceID: "crypto-1",
Role: paymenttypes.QuoteRouteHopRoleSource,
},
{
Index: 20,
Rail: "CARD",
Gateway: "gw-card",
InstanceID: "card-1",
Role: paymenttypes.QuoteRouteHopRoleDestination,
},
},
},
},
})
if err != nil {
t.Fatalf("Compile returned error: %v", err)
}
if graph == nil {
t.Fatal("expected graph")
}
if got, want := len(graph.Steps), 10; got != want {
t.Fatalf("expected 10 steps, got %d", got)
}
assertStep(t, graph.Steps[0], "hop.10.crypto.send", discovery.RailOperationSend, discovery.RailCrypto, model.ReportVisibilityBackoffice)
assertStep(t, graph.Steps[1], "hop.10.crypto.fee", discovery.RailOperationFee, discovery.RailCrypto, model.ReportVisibilityHidden)
assertStep(t, graph.Steps[2], "hop.10.crypto.observe", discovery.RailOperationObserveConfirm, discovery.RailCrypto, model.ReportVisibilityBackoffice)
assertStep(t, graph.Steps[3], "edge.10_20.ledger.credit", discovery.RailOperationExternalCredit, discovery.RailLedger, model.ReportVisibilityHidden)
assertStep(t, graph.Steps[4], "edge.10_20.ledger.block", discovery.RailOperationBlock, discovery.RailLedger, model.ReportVisibilityHidden)
assertStep(t, graph.Steps[5], "hop.20.card_payout.send", discovery.RailOperationSend, discovery.RailCardPayout, model.ReportVisibilityUser)
assertStep(t, graph.Steps[6], "hop.20.card_payout.observe", discovery.RailOperationObserveConfirm, discovery.RailCardPayout, model.ReportVisibilityUser)
assertStep(t, graph.Steps[7], "edge.10_20.ledger.debit", discovery.RailOperationExternalDebit, discovery.RailLedger, model.ReportVisibilityHidden)
assertStep(t, graph.Steps[8], "edge.10_20.ledger.release", discovery.RailOperationRelease, discovery.RailLedger, model.ReportVisibilityHidden)
assertStep(t, graph.Steps[9], "edge.10_20.ledger.release", discovery.RailOperationRelease, discovery.RailLedger, model.ReportVisibilityHidden)
if got, want := graph.Steps[1].DependsOn, []string{graph.Steps[0].StepRef}; !equalStringSlice(got, want) {
t.Fatalf("fee deps mismatch: got=%v want=%v", got, want)
}
if got, want := graph.Steps[2].DependsOn, []string{graph.Steps[1].StepRef}; !equalStringSlice(got, want) {
t.Fatalf("observe deps mismatch: got=%v want=%v", got, want)
}
if got, want := graph.Steps[3].DependsOn, []string{graph.Steps[2].StepRef}; !equalStringSlice(got, want) {
t.Fatalf("credit deps mismatch: got=%v want=%v", got, want)
}
}
func TestCompile_ExternalToExternal_IgnoresNonWalletFeeLines(t *testing.T) {
compiler := New()
graph, err := compiler.Compile(Input{
IntentSnapshot: testIntent(model.PaymentKindPayout),
QuoteSnapshot: &model.PaymentQuoteSnapshot{
DebitAmount: &paymenttypes.Money{Amount: "5.842042", Currency: "USDT"},
FeeLines: []*paymenttypes.FeeLine{
{
Money: &paymenttypes.Money{Amount: "0.42", Currency: "USDT"},
Side: paymenttypes.EntrySideDebit,
Meta: map[string]string{"fee_target": "ledger"},
},
},
Route: &paymenttypes.QuoteRouteSpecification{
Hops: []*paymenttypes.QuoteRouteHop{
{Index: 10, Rail: "CRYPTO", Role: paymenttypes.QuoteRouteHopRoleSource},
{Index: 20, Rail: "CARD", Role: paymenttypes.QuoteRouteHopRoleDestination},
},
},
},
})
if err != nil {
t.Fatalf("Compile returned error: %v", err)
}
if got, want := len(graph.Steps), 9; got != want {
t.Fatalf("expected 9 steps, got %d", got)
}
for i := range graph.Steps {
if got := graph.Steps[i].Action; got == discovery.RailOperationFee {
t.Fatalf("unexpected fee step at index %d: %+v", i, graph.Steps[i])
}
}
}
func TestCompile_InternalToExternal_UsesHoldAndSettlementBranches(t *testing.T) {
compiler := New()

View File

@@ -51,6 +51,30 @@ func (e *expansion) nextRef(base string) string {
return token + "_" + itoa(count+1)
}
func (e *expansion) needsWalletFeeStep(hop normalizedHop) bool {
if e == nil || len(e.walletFeeHops) == 0 {
return false
}
key := observedKey(hop)
if _, ok := e.walletFeeHops[key]; !ok {
return false
}
if _, emitted := e.walletFeeEmitted[key]; emitted {
return false
}
return true
}
func (e *expansion) markWalletFeeEmitted(hop normalizedHop) {
if e == nil {
return
}
if e.walletFeeEmitted == nil {
e.walletFeeEmitted = map[string]struct{}{}
}
e.walletFeeEmitted[observedKey(hop)] = struct{}{}
}
func normalizeStep(step Step) Step {
step.StepRef = strings.TrimSpace(step.StepRef)
step.StepCode = strings.TrimSpace(step.StepCode)

View File

@@ -0,0 +1,128 @@
package xplan
import (
"fmt"
"strings"
"github.com/shopspring/decimal"
"github.com/tech/sendico/pkg/discovery"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/merrors"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
)
func planWalletFeeHops(
hops []normalizedHop,
intent model.PaymentIntent,
quote *model.PaymentQuoteSnapshot,
) (map[string]struct{}, error) {
_, hasWalletFee, err := walletFeeAmountFromSnapshots(intent, quote)
if err != nil {
return nil, err
}
if !hasWalletFee {
return nil, nil
}
sourceHop, ok := sourceCryptoHop(hops)
if !ok {
return nil, nil
}
return map[string]struct{}{
observedKey(sourceHop): {},
}, nil
}
func sourceCryptoHop(hops []normalizedHop) (normalizedHop, bool) {
for i := range hops {
if hops[i].rail == discovery.RailCrypto && hops[i].role == paymenttypes.QuoteRouteHopRoleSource {
return hops[i], true
}
}
if len(hops) > 0 && hops[0].rail == discovery.RailCrypto {
return hops[0], true
}
return normalizedHop{}, false
}
func walletFeeAmountFromSnapshots(
intent model.PaymentIntent,
quote *model.PaymentQuoteSnapshot,
) (*paymenttypes.Money, bool, error) {
if quote == nil || len(quote.FeeLines) == 0 {
return nil, false, nil
}
sourceCurrency := ""
if source := feePlanningSourceAmount(intent, quote); source != nil {
sourceCurrency = strings.TrimSpace(source.Currency)
}
total := decimal.Zero
currency := ""
for i, line := range quote.FeeLines {
if !isWalletDebitFeeLine(line) {
continue
}
money := line.GetMoney()
if money == nil {
continue
}
lineCurrency := strings.TrimSpace(money.GetCurrency())
if lineCurrency == "" {
return nil, false, merrors.InvalidArgument(fmt.Sprintf("quote_snapshot.fee_lines[%d].money.currency is required", i))
}
if sourceCurrency != "" && !strings.EqualFold(sourceCurrency, lineCurrency) {
continue
}
if currency == "" {
currency = lineCurrency
} else if !strings.EqualFold(currency, lineCurrency) {
return nil, false, merrors.InvalidArgument("quote_snapshot.fee_lines wallet fee currency mismatch")
}
amountRaw := strings.TrimSpace(money.GetAmount())
amount, err := decimal.NewFromString(amountRaw)
if err != nil {
return nil, false, merrors.InvalidArgument(fmt.Sprintf("quote_snapshot.fee_lines[%d].money.amount is invalid", i))
}
if amount.Sign() < 0 {
amount = amount.Neg()
}
if amount.Sign() == 0 {
continue
}
total = total.Add(amount)
}
if total.Sign() <= 0 {
return nil, false, nil
}
return &paymenttypes.Money{
Amount: total.String(),
Currency: strings.ToUpper(strings.TrimSpace(currency)),
}, true, nil
}
func feePlanningSourceAmount(intent model.PaymentIntent, quote *model.PaymentQuoteSnapshot) *paymenttypes.Money {
if quote != nil && quote.DebitAmount != nil {
return quote.DebitAmount
}
return intent.Amount
}
func isWalletDebitFeeLine(line *paymenttypes.FeeLine) bool {
if line == nil {
return false
}
if line.GetSide() != paymenttypes.EntrySideDebit {
return false
}
meta := line.Meta
if len(meta) == 0 {
return false
}
return strings.EqualFold(strings.TrimSpace(meta["fee_target"]), "wallet")
}

View File

@@ -30,12 +30,16 @@ type expansion struct {
lastMainRef string
refSeq map[string]int
externalObserved map[string]string
walletFeeHops map[string]struct{}
walletFeeEmitted map[string]struct{}
}
func newExpansion() *expansion {
func newExpansion(walletFeeHops map[string]struct{}) *expansion {
return &expansion{
refSeq: map[string]int{},
externalObserved: map[string]string{},
walletFeeHops: walletFeeHops,
walletFeeEmitted: map[string]struct{}{},
}
}
@@ -86,7 +90,12 @@ func (s *svc) Compile(in Input) (graph *Graph, err error) {
return nil, err
}
ex := newExpansion()
walletFeeHops, err := planWalletFeeHops(hops, in.IntentSnapshot, in.QuoteSnapshot)
if err != nil {
return nil, err
}
ex := newExpansion(walletFeeHops)
appendGuards(ex, conditions)
if len(hops) == 1 {

View File

@@ -147,10 +147,19 @@ func (s *svc) ensureExternalObserved(ex *expansion, hop normalizedHop, intent mo
sendStep := makeRailSendStep(hop, intent)
sendRef := ex.appendMain(sendStep)
observeDependency := sendRef
if ex.needsWalletFeeStep(hop) {
feeStep := makeRailFeeStep(hop)
if observeDependency != "" {
feeStep.DependsOn = []string{observeDependency}
}
observeDependency = ex.appendMain(feeStep)
ex.markWalletFeeEmitted(hop)
}
observeStep := makeRailObserveStep(hop, intent)
if sendRef != "" {
observeStep.DependsOn = []string{sendRef}
if observeDependency != "" {
observeStep.DependsOn = []string{observeDependency}
}
observeRef := ex.appendMain(observeStep)
@@ -158,6 +167,20 @@ func (s *svc) ensureExternalObserved(ex *expansion, hop normalizedHop, intent mo
return observeRef, nil
}
func makeRailFeeStep(hop normalizedHop) Step {
return Step{
StepCode: singleHopCode(hop, "fee"),
Kind: StepKindRailSend,
Action: discovery.RailOperationFee,
Rail: hop.rail,
Gateway: hop.gateway,
InstanceID: hop.instanceID,
HopIndex: hop.index,
HopRole: hop.role,
Visibility: model.ReportVisibilityHidden,
}
}
func appendGuards(ex *expansion, conditions *paymenttypes.QuoteExecutionConditions) {
if conditions == nil {
return

View File

@@ -235,7 +235,7 @@ func resolveStepContext(
func kindForAction(action model.RailOperation) StepKind {
switch action {
case discovery.RailOperationSend, discovery.RailOperationFXConvert:
case discovery.RailOperationSend, discovery.RailOperationFXConvert, discovery.RailOperationFee:
return StepKindRailSend
case discovery.RailOperationObserveConfirm:
return StepKindRailObserve