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

View File

@@ -99,11 +99,6 @@ func (e *gatewayCryptoExecutor) ExecuteCrypto(ctx context.Context, req sexec.Ste
}
step.ConvertedMoney = nil
if action == discovery.RailOperationSend {
if err := e.submitWalletFeeTransfer(ctx, req, client, gateway, sourceWalletRef, operationRef, idempotencyKey); err != nil {
return nil, err
}
}
step.State = agg.StepStateCompleted
step.FailureCode = ""
step.FailureMsg = ""
@@ -207,64 +202,6 @@ func sourceAmount(payment *agg.Payment, action model.RailOperation) (*moneyv1.Mo
}, nil
}
func (e *gatewayCryptoExecutor) submitWalletFeeTransfer(
ctx context.Context,
req sexec.StepRequest,
client chainclient.Client,
gateway *model.GatewayInstanceDescriptor,
sourceWalletRef string,
operationRef string,
idempotencyKey string,
) error {
if req.Payment == nil {
return merrors.InvalidArgument("crypto send: payment is required")
}
feeAmount, ok, err := walletFeeAmount(req.Payment)
if err != nil {
return err
}
if !ok {
return nil
}
destination, err := e.resolveDestination(ctx, client, req.Payment, discovery.RailOperationFee)
if err != nil {
return err
}
feeMoney := &moneyv1.Money{
Amount: strings.TrimSpace(feeAmount.GetAmount()),
Currency: strings.TrimSpace(feeAmount.GetCurrency()),
}
resp, err := client.SubmitTransfer(ctx, &chainv1.SubmitTransferRequest{
IdempotencyKey: strings.TrimSpace(idempotencyKey) + ":fee",
OrganizationRef: req.Payment.OrganizationRef.Hex(),
SourceWalletRef: sourceWalletRef,
Destination: destination,
Amount: feeMoney,
OperationRef: strings.TrimSpace(operationRef) + ":fee",
IntentRef: strings.TrimSpace(req.Payment.IntentSnapshot.Ref),
PaymentRef: strings.TrimSpace(req.Payment.PaymentRef),
Metadata: transferMetadata(req.Step),
})
if err != nil {
return err
}
if resp == nil || resp.GetTransfer() == nil {
return merrors.Internal("crypto send: fee transfer response is missing")
}
if _, err := transferExternalRefs(resp.GetTransfer(), firstNonEmpty(
strings.TrimSpace(req.Step.InstanceID),
strings.TrimSpace(gateway.InstanceID),
strings.TrimSpace(req.Step.Gateway),
strings.TrimSpace(gateway.ID),
)); err != nil {
return err
}
return nil
}
func effectiveSourceAmount(payment *agg.Payment) *paymenttypes.Money {
if payment == nil {
return nil

View File

@@ -204,32 +204,19 @@ func TestGatewayCryptoExecutor_ExecuteCrypto_MissingCardRoute(t *testing.T) {
}
}
func TestGatewayCryptoExecutor_ExecuteCrypto_SubmitsWalletFeeTransferOnSend(t *testing.T) {
func TestGatewayCryptoExecutor_ExecuteCrypto_SendDoesNotSubmitWalletFeeTransfer(t *testing.T) {
orgID := bson.NewObjectID()
submitRequests := make([]*chainv1.SubmitTransferRequest, 0, 2)
submitRequests := make([]*chainv1.SubmitTransferRequest, 0, 1)
client := &chainclient.Fake{
SubmitTransferFn: func(_ context.Context, req *chainv1.SubmitTransferRequest) (*chainv1.SubmitTransferResponse, error) {
submitRequests = append(submitRequests, req)
switch len(submitRequests) {
case 1:
return &chainv1.SubmitTransferResponse{
Transfer: &chainv1.Transfer{
TransferRef: "trf-principal",
OperationRef: "op-principal",
},
}, nil
case 2:
return &chainv1.SubmitTransferResponse{
Transfer: &chainv1.Transfer{
TransferRef: "trf-fee",
OperationRef: "op-fee",
},
}, nil
default:
t.Fatalf("unexpected transfer submission call %d", len(submitRequests))
panic("unreachable")
}
return &chainv1.SubmitTransferResponse{
Transfer: &chainv1.Transfer{
TransferRef: "trf-principal",
OperationRef: "op-principal",
},
}, nil
},
}
resolver := &fakeGatewayInvokeResolver{client: client}
@@ -311,7 +298,7 @@ func TestGatewayCryptoExecutor_ExecuteCrypto_SubmitsWalletFeeTransferOnSend(t *t
if out == nil {
t.Fatal("expected output")
}
if got, want := len(submitRequests), 2; got != want {
if got, want := len(submitRequests), 1; got != want {
t.Fatalf("submit transfer calls mismatch: got=%d want=%d", got, want)
}
@@ -322,29 +309,12 @@ func TestGatewayCryptoExecutor_ExecuteCrypto_SubmitsWalletFeeTransferOnSend(t *t
if got, want := principalReq.GetDestination().GetExternalAddress(), "TUA_DEST"; got != want {
t.Fatalf("principal destination mismatch: got=%q want=%q", got, want)
}
feeReq := submitRequests[1]
if got, want := feeReq.GetAmount().GetAmount(), "0.7"; got != want {
t.Fatalf("fee amount mismatch: got=%q want=%q", got, want)
}
if got, want := feeReq.GetAmount().GetCurrency(), "USDT"; got != want {
t.Fatalf("fee currency mismatch: got=%q want=%q", got, want)
}
if got, want := feeReq.GetDestination().GetExternalAddress(), "TUA_FEE"; got != want {
t.Fatalf("fee destination mismatch: got=%q want=%q", got, want)
}
if got, want := feeReq.GetOperationRef(), "payment-1:hop_1_crypto_send:fee"; got != want {
t.Fatalf("fee operation_ref mismatch: got=%q want=%q", got, want)
}
if got, want := feeReq.GetIdempotencyKey(), "idem-1:hop_1_crypto_send:fee"; got != want {
t.Fatalf("fee idempotency_key mismatch: got=%q want=%q", got, want)
}
}
func TestGatewayCryptoExecutor_ExecuteCrypto_ResolvesFeeAddressFromFeeWalletRef(t *testing.T) {
func TestGatewayCryptoExecutor_ExecuteCrypto_FeeActionResolvesFeeAddressFromFeeWalletRef(t *testing.T) {
orgID := bson.NewObjectID()
submitRequests := make([]*chainv1.SubmitTransferRequest, 0, 2)
submitRequests := make([]*chainv1.SubmitTransferRequest, 0, 1)
var managedWalletReq *chainv1.GetManagedWalletRequest
client := &chainclient.Fake{
GetManagedWalletFn: func(_ context.Context, req *chainv1.GetManagedWalletRequest) (*chainv1.GetManagedWalletResponse, error) {
@@ -358,25 +328,12 @@ func TestGatewayCryptoExecutor_ExecuteCrypto_ResolvesFeeAddressFromFeeWalletRef(
},
SubmitTransferFn: func(_ context.Context, req *chainv1.SubmitTransferRequest) (*chainv1.SubmitTransferResponse, error) {
submitRequests = append(submitRequests, req)
switch len(submitRequests) {
case 1:
return &chainv1.SubmitTransferResponse{
Transfer: &chainv1.Transfer{
TransferRef: "trf-principal",
OperationRef: "op-principal",
},
}, nil
case 2:
return &chainv1.SubmitTransferResponse{
Transfer: &chainv1.Transfer{
TransferRef: "trf-fee",
OperationRef: "op-fee",
},
}, nil
default:
t.Fatalf("unexpected transfer submission call %d", len(submitRequests))
panic("unreachable")
}
return &chainv1.SubmitTransferResponse{
Transfer: &chainv1.Transfer{
TransferRef: "trf-fee",
OperationRef: "op-fee",
},
}, nil
},
}
resolver := &fakeGatewayInvokeResolver{client: client}
@@ -437,16 +394,16 @@ func TestGatewayCryptoExecutor_ExecuteCrypto_ResolvesFeeAddressFromFeeWalletRef(
},
},
Step: xplan.Step{
StepRef: "hop_1_crypto_send",
StepCode: "hop.1.crypto.send",
Action: discovery.RailOperationSend,
StepRef: "hop_1_crypto_fee",
StepCode: "hop.1.crypto.fee",
Action: discovery.RailOperationFee,
Rail: discovery.RailCrypto,
Gateway: "crypto_rail_gateway_arbitrum_sepolia",
InstanceID: "crypto_rail_gateway_arbitrum_sepolia",
},
StepExecution: agg.StepExecution{
StepRef: "hop_1_crypto_send",
StepCode: "hop.1.crypto.send",
StepRef: "hop_1_crypto_fee",
StepCode: "hop.1.crypto.fee",
Attempt: 1,
},
}
@@ -461,10 +418,13 @@ func TestGatewayCryptoExecutor_ExecuteCrypto_ResolvesFeeAddressFromFeeWalletRef(
if got, want := managedWalletReq.GetWalletRef(), "fee-wallet-ref"; got != want {
t.Fatalf("fee wallet ref lookup mismatch: got=%q want=%q", got, want)
}
if got, want := len(submitRequests), 2; got != want {
if got, want := len(submitRequests), 1; got != want {
t.Fatalf("submit transfer calls mismatch: got=%d want=%d", got, want)
}
feeReq := submitRequests[1]
feeReq := submitRequests[0]
if got, want := feeReq.GetAmount().GetAmount(), "0.7"; got != want {
t.Fatalf("fee amount mismatch: got=%q want=%q", got, want)
}
if got, want := feeReq.GetDestination().GetExternalAddress(), "TUA_FEE_FROM_WALLET"; got != want {
t.Fatalf("fee destination mismatch: got=%q want=%q", got, want)
}