Fixed po sending comission #648

Merged
tech merged 1 commits from po-647 into main 2026-03-04 22:22:02 +00:00
2 changed files with 393 additions and 3 deletions

View File

@@ -2,6 +2,9 @@ package orchestrator
import ( import (
"context" "context"
"fmt"
"github.com/shopspring/decimal"
chainclient "github.com/tech/sendico/gateway/chain/client"
"github.com/tech/sendico/pkg/discovery" "github.com/tech/sendico/pkg/discovery"
"strings" "strings"
@@ -48,7 +51,7 @@ func (e *gatewayCryptoExecutor) ExecuteCrypto(ctx context.Context, req sexec.Ste
if err != nil { if err != nil {
return nil, err return nil, err
} }
amount, err := sourceAmount(req.Payment) amount, err := sourceAmount(req.Payment, action)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -90,6 +93,12 @@ func (e *gatewayCryptoExecutor) ExecuteCrypto(ctx context.Context, req sexec.Ste
return nil, refsErr return nil, refsErr
} }
step.ExternalRefs = refs step.ExternalRefs = refs
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.State = agg.StepStateCompleted
step.FailureCode = "" step.FailureCode = ""
step.FailureMsg = "" step.FailureMsg = ""
@@ -161,11 +170,24 @@ func sourceManagedWalletRef(payment *agg.Payment) (string, error) {
return ref, nil return ref, nil
} }
func sourceAmount(payment *agg.Payment) (*moneyv1.Money, error) { func sourceAmount(payment *agg.Payment, action model.RailOperation) (*moneyv1.Money, error) {
if payment == nil { if payment == nil {
return nil, merrors.InvalidArgument("crypto send: payment is required") return nil, merrors.InvalidArgument("crypto send: payment is required")
} }
money := effectiveSourceAmount(payment) var money *paymenttypes.Money
switch action {
case discovery.RailOperationFee:
resolved, ok, err := walletFeeAmount(payment)
if err != nil {
return nil, err
}
if !ok {
return nil, merrors.InvalidArgument("crypto send: wallet fee amount is required")
}
money = resolved
default:
money = effectiveSourceAmount(payment)
}
if money == nil { if money == nil {
return nil, merrors.InvalidArgument("crypto send: source amount is required") return nil, merrors.InvalidArgument("crypto send: source amount is required")
} }
@@ -180,6 +202,64 @@ func sourceAmount(payment *agg.Payment) (*moneyv1.Money, error) {
}, nil }, 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(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 { func effectiveSourceAmount(payment *agg.Payment) *paymenttypes.Money {
if payment == nil { if payment == nil {
return nil return nil
@@ -190,6 +270,77 @@ func effectiveSourceAmount(payment *agg.Payment) *paymenttypes.Money {
return payment.IntentSnapshot.Amount return payment.IntentSnapshot.Amount
} }
func walletFeeAmount(payment *agg.Payment) (*paymenttypes.Money, bool, error) {
if payment == nil || payment.QuoteSnapshot == nil || len(payment.QuoteSnapshot.FeeLines) == 0 {
return nil, false, nil
}
sourceCurrency := ""
if source := effectiveSourceAmount(payment); source != nil {
sourceCurrency = strings.TrimSpace(source.Currency)
}
total := decimal.Zero
currency := ""
for i, line := range payment.QuoteSnapshot.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("crypto send: 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("crypto send: wallet fee currency mismatch")
}
amountRaw := strings.TrimSpace(money.GetAmount())
amount, err := decimal.NewFromString(amountRaw)
if err != nil {
return nil, false, merrors.InvalidArgument(fmt.Sprintf("crypto send: 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 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")
}
func (e *gatewayCryptoExecutor) resolveDestination(payment *agg.Payment, action model.RailOperation) (*chainv1.TransferDestination, error) { func (e *gatewayCryptoExecutor) resolveDestination(payment *agg.Payment, action model.RailOperation) (*chainv1.TransferDestination, error) {
if payment == nil { if payment == nil {
return nil, merrors.InvalidArgument("crypto send: payment is required") return nil, merrors.InvalidArgument("crypto send: payment is required")

View File

@@ -195,6 +195,245 @@ func TestGatewayCryptoExecutor_ExecuteCrypto_MissingCardRoute(t *testing.T) {
} }
} }
func TestGatewayCryptoExecutor_ExecuteCrypto_SubmitsWalletFeeTransferOnSend(t *testing.T) {
orgID := bson.NewObjectID()
submitRequests := make([]*chainv1.SubmitTransferRequest, 0, 2)
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))
return nil, nil
}
},
}
resolver := &fakeGatewayInvokeResolver{client: client}
registry := &fakeGatewayRegistry{
items: []*model.GatewayInstanceDescriptor{
{
ID: "crypto_rail_gateway_arbitrum_sepolia",
InstanceID: "crypto_rail_gateway_arbitrum_sepolia",
Rail: discovery.RailCrypto,
InvokeURI: "grpc://crypto-gateway",
IsEnabled: true,
},
},
}
executor := &gatewayCryptoExecutor{
gatewayInvokeResolver: resolver,
gatewayRegistry: registry,
cardGatewayRoutes: map[string]CardGatewayRoute{
paymenttypes.DefaultCardsGatewayID: {FundingAddress: "TUA_DEST", FeeAddress: "TUA_FEE"},
},
}
req := sexec.StepRequest{
Payment: &agg.Payment{
OrganizationBoundBase: pm.OrganizationBoundBase{OrganizationRef: orgID},
PaymentRef: "payment-1",
IdempotencyKey: "idem-1",
IntentSnapshot: model.PaymentIntent{
Ref: "intent-1",
Source: model.PaymentEndpoint{
Type: model.EndpointTypeManagedWallet,
ManagedWallet: &model.ManagedWalletEndpoint{
ManagedWalletRef: "wallet-src",
},
},
Destination: model.PaymentEndpoint{
Type: model.EndpointTypeCard,
Card: &model.CardEndpoint{Pan: "4111111111111111"},
},
Amount: &paymenttypes.Money{Amount: "10", Currency: "USDT"},
},
QuoteSnapshot: &model.PaymentQuoteSnapshot{
DebitAmount: &paymenttypes.Money{Amount: "10.000000", Currency: "USDT"},
FeeLines: []*paymenttypes.FeeLine{
{
Money: &paymenttypes.Money{Amount: "0.70", Currency: "USDT"},
LineType: paymenttypes.PostingLineTypeFee,
Side: paymenttypes.EntrySideDebit,
Meta: map[string]string{"fee_target": "wallet"},
},
},
Route: &paymenttypes.QuoteRouteSpecification{
Hops: []*paymenttypes.QuoteRouteHop{
{Index: 1, Rail: "CRYPTO", Gateway: "crypto_rail_gateway_arbitrum_sepolia", InstanceID: "crypto_rail_gateway_arbitrum_sepolia", Role: paymenttypes.QuoteRouteHopRoleSource},
{Index: 4, Rail: "CARD", Gateway: paymenttypes.DefaultCardsGatewayID, InstanceID: paymenttypes.DefaultCardsGatewayID, Role: paymenttypes.QuoteRouteHopRoleDestination},
},
},
},
},
Step: xplan.Step{
StepRef: "hop_1_crypto_send",
StepCode: "hop.1.crypto.send",
Action: discovery.RailOperationSend,
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",
Attempt: 1,
},
}
out, err := executor.ExecuteCrypto(context.Background(), req)
if err != nil {
t.Fatalf("ExecuteCrypto returned error: %v", err)
}
if out == nil {
t.Fatal("expected output")
}
if got, want := len(submitRequests), 2; got != want {
t.Fatalf("submit transfer calls mismatch: got=%d want=%d", got, want)
}
principalReq := submitRequests[0]
if got, want := principalReq.GetAmount().GetAmount(), "10.000000"; got != want {
t.Fatalf("principal amount mismatch: got=%q want=%q", got, want)
}
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_FeeActionUsesWalletFeeAmount(t *testing.T) {
orgID := bson.NewObjectID()
var submitReq *chainv1.SubmitTransferRequest
client := &chainclient.Fake{
SubmitTransferFn: func(_ context.Context, req *chainv1.SubmitTransferRequest) (*chainv1.SubmitTransferResponse, error) {
submitReq = req
return &chainv1.SubmitTransferResponse{
Transfer: &chainv1.Transfer{
TransferRef: "trf-fee",
OperationRef: "op-fee",
},
}, nil
},
}
resolver := &fakeGatewayInvokeResolver{client: client}
registry := &fakeGatewayRegistry{
items: []*model.GatewayInstanceDescriptor{
{
ID: "crypto_rail_gateway_arbitrum_sepolia",
InstanceID: "crypto_rail_gateway_arbitrum_sepolia",
Rail: discovery.RailCrypto,
InvokeURI: "grpc://crypto-gateway",
IsEnabled: true,
},
},
}
executor := &gatewayCryptoExecutor{
gatewayInvokeResolver: resolver,
gatewayRegistry: registry,
cardGatewayRoutes: map[string]CardGatewayRoute{
paymenttypes.DefaultCardsGatewayID: {FundingAddress: "TUA_DEST", FeeAddress: "TUA_FEE"},
},
}
req := sexec.StepRequest{
Payment: &agg.Payment{
OrganizationBoundBase: pm.OrganizationBoundBase{OrganizationRef: orgID},
PaymentRef: "payment-1",
IdempotencyKey: "idem-1",
IntentSnapshot: model.PaymentIntent{
Ref: "intent-1",
Source: model.PaymentEndpoint{
Type: model.EndpointTypeManagedWallet,
ManagedWallet: &model.ManagedWalletEndpoint{
ManagedWalletRef: "wallet-src",
},
},
Destination: model.PaymentEndpoint{
Type: model.EndpointTypeCard,
Card: &model.CardEndpoint{Pan: "4111111111111111"},
},
Amount: &paymenttypes.Money{Amount: "10", Currency: "USDT"},
},
QuoteSnapshot: &model.PaymentQuoteSnapshot{
DebitAmount: &paymenttypes.Money{Amount: "10.000000", Currency: "USDT"},
FeeLines: []*paymenttypes.FeeLine{
{
Money: &paymenttypes.Money{Amount: "0.70", Currency: "USDT"},
LineType: paymenttypes.PostingLineTypeFee,
Side: paymenttypes.EntrySideDebit,
Meta: map[string]string{"fee_target": "wallet"},
},
},
Route: &paymenttypes.QuoteRouteSpecification{
Hops: []*paymenttypes.QuoteRouteHop{
{Index: 1, Rail: "CRYPTO", Gateway: "crypto_rail_gateway_arbitrum_sepolia", InstanceID: "crypto_rail_gateway_arbitrum_sepolia", Role: paymenttypes.QuoteRouteHopRoleSource},
{Index: 4, Rail: "CARD", Gateway: paymenttypes.DefaultCardsGatewayID, InstanceID: paymenttypes.DefaultCardsGatewayID, Role: paymenttypes.QuoteRouteHopRoleDestination},
},
},
},
},
Step: xplan.Step{
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_fee",
StepCode: "hop.1.crypto.fee",
Attempt: 1,
},
}
_, err := executor.ExecuteCrypto(context.Background(), req)
if err != nil {
t.Fatalf("ExecuteCrypto returned error: %v", err)
}
if submitReq == nil {
t.Fatal("expected transfer submission")
}
if got, want := submitReq.GetAmount().GetAmount(), "0.7"; got != want {
t.Fatalf("fee amount mismatch: got=%q want=%q", got, want)
}
if got, want := submitReq.GetDestination().GetExternalAddress(), "TUA_FEE"; got != want {
t.Fatalf("fee destination mismatch: got=%q want=%q", got, want)
}
}
type fakeGatewayInvokeResolver struct { type fakeGatewayInvokeResolver struct {
lastInvokeURI string lastInvokeURI string
client chainclient.Client client chainclient.Client