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

106
Makefile
View File

@@ -43,6 +43,7 @@ COMPOSE := docker compose -f docker-compose.dev.yml --env-file .env.dev
SERVICE ?=
BACKEND_GOCACHE ?= $(CURDIR)/.gocache
BACKEND_GOLANGCI_LINT_CACHE ?= $(CURDIR)/.golangci-cache
BACKEND_WAIT_TIMEOUT ?= 600
BACKEND_SERVICES := \
dev-discovery \
dev-fx-oracle \
@@ -64,6 +65,19 @@ BACKEND_SERVICES := \
dev-callbacks \
dev-bff-vault-agent \
dev-bff
BACKEND_STAGE_DISCOVERY := dev-discovery
BACKEND_STAGE_FX := dev-fx-oracle dev-fx-ingestor
BACKEND_STAGE_BILLING := dev-billing-fees dev-billing-documents
BACKEND_STAGE_GATEWAY_SIDECARS := dev-chain-gateway-vault-agent dev-tron-gateway-vault-agent
BACKEND_STAGE_GATEWAYS_LEDGER := dev-chain-gateway dev-tron-gateway dev-aurora-gateway dev-chsettle-gateway dev-ledger
BACKEND_STAGE_QUOTATION := dev-payments-quotation
BACKEND_STAGE_PAYMENTS_CORE := dev-payments-methods dev-payments-orchestrator
BACKEND_STAGE_CALLBACKS_AGENT := dev-callbacks-vault-agent
BACKEND_STAGE_CALLBACKS := dev-callbacks
BACKEND_STAGE_EDGE_FOUNDATION := dev-notification dev-bff-vault-agent
BACKEND_STAGE_EDGE := dev-bff
BACKEND_STAGE_EDGE_BUILD := dev-notification dev-bff
FRONTEND_SERVICE := dev-frontend
# Colors
GREEN := \033[0;32m
@@ -89,10 +103,10 @@ help:
@echo ""
@echo "$(YELLOW)Selective Operations:$(NC)"
@echo " make infra-up Start infrastructure only (mongo, nats, vault)"
@echo " make services-up Start application services only"
@echo " make backend-up Start backend services only (no infrastructure/frontend)"
@echo " make services-up Start application services only (ordered backend stages)"
@echo " make backend-up Start backend services only in ordered stages (no infrastructure/frontend)"
@echo " make backend-down Stop backend services only"
@echo " make backend-rebuild Rebuild and restart backend services only"
@echo " make backend-rebuild Rebuild and restart backend services in ordered stages"
@echo " make list-services List all available services"
@echo ""
@echo "$(YELLOW)Build Groups:$(NC)"
@@ -166,7 +180,8 @@ build:
# Start all services
up:
@echo "$(GREEN)Starting development environment...$(NC)"
@$(COMPOSE) up -d
@$(MAKE) --no-print-directory infra-up
@$(MAKE) --no-print-directory services-up
@echo ""
@echo "$(GREEN)✅ Development environment started!$(NC)"
@echo ""
@@ -272,39 +287,74 @@ infra-up:
# Services only (assumes infra is running)
services-up:
@echo "$(GREEN)Starting application services...$(NC)"
@$(COMPOSE) up -d \
dev-discovery \
dev-fx-oracle \
dev-fx-ingestor \
dev-billing-fees \
dev-billing-documents \
dev-ledger \
dev-payments-orchestrator \
dev-payments-quotation \
dev-payments-methods \
dev-chain-gateway \
dev-tron-gateway \
dev-aurora-gateway \
dev-chsettle-gateway \
dev-notification \
dev-callbacks \
dev-bff \
dev-frontend
@echo "$(GREEN)Starting application services with ordered backend stages...$(NC)"
@$(MAKE) --no-print-directory backend-up
@$(COMPOSE) up -d --no-deps --wait --wait-timeout $(BACKEND_WAIT_TIMEOUT) $(FRONTEND_SERVICE)
# Backend services only (no infrastructure, no frontend)
backend-up:
@echo "$(GREEN)Starting backend services only (no infra changes)...$(NC)"
@$(COMPOSE) up -d --no-deps $(BACKEND_SERVICES)
@echo "$(GREEN)Starting backend services only (ordered build+start stages, no infra changes)...$(NC)"
@echo "$(YELLOW)[1/8] discovery$(NC)"
@$(COMPOSE) build $(BACKEND_STAGE_DISCOVERY)
@$(COMPOSE) up -d --no-deps --wait --wait-timeout $(BACKEND_WAIT_TIMEOUT) $(BACKEND_STAGE_DISCOVERY)
@echo "$(YELLOW)[2/8] fx (oracle + ingestor)$(NC)"
@$(COMPOSE) build $(BACKEND_STAGE_FX)
@$(COMPOSE) up -d --no-deps --wait --wait-timeout $(BACKEND_WAIT_TIMEOUT) $(BACKEND_STAGE_FX)
@echo "$(YELLOW)[3/8] billing (fees + documents)$(NC)"
@$(COMPOSE) build $(BACKEND_STAGE_BILLING)
@$(COMPOSE) up -d --no-deps --wait --wait-timeout $(BACKEND_WAIT_TIMEOUT) $(BACKEND_STAGE_BILLING)
@echo "$(YELLOW)[4/8] gateways + ledger$(NC)"
@$(COMPOSE) build $(BACKEND_STAGE_GATEWAYS_LEDGER)
@$(COMPOSE) up -d --no-deps --wait --wait-timeout $(BACKEND_WAIT_TIMEOUT) $(BACKEND_STAGE_GATEWAY_SIDECARS)
@$(COMPOSE) up -d --no-deps --wait --wait-timeout $(BACKEND_WAIT_TIMEOUT) $(BACKEND_STAGE_GATEWAYS_LEDGER)
@echo "$(YELLOW)[5/8] quotation$(NC)"
@$(COMPOSE) build $(BACKEND_STAGE_QUOTATION)
@$(COMPOSE) up -d --no-deps --wait --wait-timeout $(BACKEND_WAIT_TIMEOUT) $(BACKEND_STAGE_QUOTATION)
@echo "$(YELLOW)[6/8] orchestrator + methods$(NC)"
@$(COMPOSE) build $(BACKEND_STAGE_PAYMENTS_CORE)
@$(COMPOSE) up -d --no-deps --wait --wait-timeout $(BACKEND_WAIT_TIMEOUT) $(BACKEND_STAGE_PAYMENTS_CORE)
@echo "$(YELLOW)[7/8] callbacks$(NC)"
@$(COMPOSE) build $(BACKEND_STAGE_CALLBACKS)
@$(COMPOSE) up -d --no-deps --wait --wait-timeout $(BACKEND_WAIT_TIMEOUT) $(BACKEND_STAGE_CALLBACKS_AGENT)
@$(COMPOSE) up -d --no-deps --wait --wait-timeout $(BACKEND_WAIT_TIMEOUT) $(BACKEND_STAGE_CALLBACKS)
@echo "$(YELLOW)[8/8] edge (notification + bff)$(NC)"
@$(COMPOSE) build $(BACKEND_STAGE_EDGE_BUILD)
@$(COMPOSE) up -d --no-deps --wait --wait-timeout $(BACKEND_WAIT_TIMEOUT) $(BACKEND_STAGE_EDGE_FOUNDATION)
@$(COMPOSE) up -d --no-deps --wait --wait-timeout $(BACKEND_WAIT_TIMEOUT) $(BACKEND_STAGE_EDGE)
backend-down:
@echo "$(YELLOW)Stopping backend services only...$(NC)"
@$(COMPOSE) stop $(BACKEND_SERVICES)
backend-rebuild:
@echo "$(GREEN)Rebuilding backend services only (no infra changes)...$(NC)"
@$(COMPOSE) build $(BACKEND_SERVICES)
@$(COMPOSE) up -d --no-deps --force-recreate $(BACKEND_SERVICES)
@echo "$(GREEN)Rebuilding backend services only (ordered stages, no infra changes)...$(NC)"
@echo "$(YELLOW)[1/8] discovery$(NC)"
@$(COMPOSE) build $(BACKEND_STAGE_DISCOVERY)
@$(COMPOSE) up -d --no-deps --force-recreate --wait --wait-timeout $(BACKEND_WAIT_TIMEOUT) $(BACKEND_STAGE_DISCOVERY)
@echo "$(YELLOW)[2/8] fx (oracle + ingestor)$(NC)"
@$(COMPOSE) build $(BACKEND_STAGE_FX)
@$(COMPOSE) up -d --no-deps --force-recreate --wait --wait-timeout $(BACKEND_WAIT_TIMEOUT) $(BACKEND_STAGE_FX)
@echo "$(YELLOW)[3/8] billing (fees + documents)$(NC)"
@$(COMPOSE) build $(BACKEND_STAGE_BILLING)
@$(COMPOSE) up -d --no-deps --force-recreate --wait --wait-timeout $(BACKEND_WAIT_TIMEOUT) $(BACKEND_STAGE_BILLING)
@echo "$(YELLOW)[4/8] gateways + ledger$(NC)"
@$(COMPOSE) build $(BACKEND_STAGE_GATEWAYS_LEDGER)
@$(COMPOSE) up -d --no-deps --wait --wait-timeout $(BACKEND_WAIT_TIMEOUT) $(BACKEND_STAGE_GATEWAY_SIDECARS)
@$(COMPOSE) up -d --no-deps --force-recreate --wait --wait-timeout $(BACKEND_WAIT_TIMEOUT) $(BACKEND_STAGE_GATEWAYS_LEDGER)
@echo "$(YELLOW)[5/8] quotation$(NC)"
@$(COMPOSE) build $(BACKEND_STAGE_QUOTATION)
@$(COMPOSE) up -d --no-deps --force-recreate --wait --wait-timeout $(BACKEND_WAIT_TIMEOUT) $(BACKEND_STAGE_QUOTATION)
@echo "$(YELLOW)[6/8] orchestrator + methods$(NC)"
@$(COMPOSE) build $(BACKEND_STAGE_PAYMENTS_CORE)
@$(COMPOSE) up -d --no-deps --force-recreate --wait --wait-timeout $(BACKEND_WAIT_TIMEOUT) $(BACKEND_STAGE_PAYMENTS_CORE)
@echo "$(YELLOW)[7/8] callbacks$(NC)"
@$(COMPOSE) build $(BACKEND_STAGE_CALLBACKS)
@$(COMPOSE) up -d --no-deps --wait --wait-timeout $(BACKEND_WAIT_TIMEOUT) $(BACKEND_STAGE_CALLBACKS_AGENT)
@$(COMPOSE) up -d --no-deps --force-recreate --wait --wait-timeout $(BACKEND_WAIT_TIMEOUT) $(BACKEND_STAGE_CALLBACKS)
@echo "$(YELLOW)[8/8] edge (notification + bff)$(NC)"
@$(COMPOSE) build $(BACKEND_STAGE_EDGE_BUILD)
@$(COMPOSE) up -d --no-deps --wait --wait-timeout $(BACKEND_WAIT_TIMEOUT) $(BACKEND_STAGE_EDGE_FOUNDATION)
@$(COMPOSE) up -d --no-deps --force-recreate --wait --wait-timeout $(BACKEND_WAIT_TIMEOUT) $(BACKEND_STAGE_EDGE)
@echo "$(GREEN)✅ Backend services rebuilt$(NC)"
# Status check

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)
}

View File

@@ -248,6 +248,12 @@ services:
NATS_PASSWORD: ${NATS_PASSWORD}
NATS_URL: nats://${NATS_USER}:${NATS_PASSWORD}@dev-nats:4222
DISCOVERY_METRICS_PORT: 9407
healthcheck:
test: ["CMD-SHELL", "wget -q -O- http://127.0.0.1:9405/health | grep -q '\"status\":\"ok\"'"]
interval: 10s
timeout: 5s
retries: 12
start_period: 40s
# --------------------------------------------------------------------------
# FX Oracle Service
@@ -286,6 +292,12 @@ services:
NATS_URL: nats://${NATS_USER}:${NATS_PASSWORD}@dev-nats:4222
FX_ORACLE_GRPC_PORT: 50051
FX_ORACLE_METRICS_PORT: 9400
healthcheck:
test: ["CMD-SHELL", "wget -q -O- http://127.0.0.1:9400/health | grep -q '\"status\":\"ok\"'"]
interval: 10s
timeout: 5s
retries: 12
start_period: 40s
# --------------------------------------------------------------------------
# Billing Fees Service
@@ -301,7 +313,7 @@ services:
depends_on:
dev-mongo-init: { condition: service_completed_successfully }
dev-nats: { condition: service_started }
dev-fx-oracle: { condition: service_started }
dev-fx-oracle: { condition: service_healthy }
volumes:
- ./api/billing/fees:/src/api/billing/fees
- ./api/billing/fees/config.dev.yml:/app/config.yml:ro
@@ -325,6 +337,12 @@ services:
NATS_URL: nats://${NATS_USER}:${NATS_PASSWORD}@dev-nats:4222
FEES_GRPC_PORT: 50060
FEES_METRICS_PORT: 9402
healthcheck:
test: ["CMD-SHELL", "wget -q -O- http://127.0.0.1:9402/health | grep -q '\"status\":\"ok\"'"]
interval: 10s
timeout: 5s
retries: 12
start_period: 40s
# --------------------------------------------------------------------------
# Billing Documents Service
@@ -362,6 +380,12 @@ services:
DOCUMENTS_MONGO_REPLICA_SET: dev-rs
DOCUMENTS_GRPC_PORT: 50061
DOCUMENTS_METRICS_PORT: 9409
healthcheck:
test: ["CMD-SHELL", "wget -q -O- http://127.0.0.1:9409/health | grep -q '\"status\":\"ok\"'"]
interval: 10s
timeout: 5s
retries: 12
start_period: 40s
# --------------------------------------------------------------------------
# Ledger Service
@@ -377,8 +401,8 @@ services:
depends_on:
dev-mongo-init: { condition: service_completed_successfully }
dev-nats: { condition: service_started }
dev-discovery: { condition: service_started }
dev-billing-fees: { condition: service_started }
dev-discovery: { condition: service_healthy }
dev-billing-fees: { condition: service_healthy }
volumes:
- ./api/ledger:/src/api/ledger
- ./api/ledger/config.dev.yml:/app/config.yml:ro
@@ -408,6 +432,12 @@ services:
NATS_URL: nats://${NATS_USER}:${NATS_PASSWORD}@dev-nats:4222
LEDGER_GRPC_PORT: 50052
LEDGER_METRICS_PORT: 9401
healthcheck:
test: ["CMD-SHELL", "wget -q -O- http://127.0.0.1:9401/health | grep -q '\"status\":\"ok\"'"]
interval: 10s
timeout: 5s
retries: 12
start_period: 40s
# --------------------------------------------------------------------------
# FX Ingestor Service
@@ -444,6 +474,12 @@ services:
NATS_PASSWORD: ${NATS_PASSWORD}
NATS_URL: nats://${NATS_USER}:${NATS_PASSWORD}@dev-nats:4222
FX_INGESTOR_METRICS_PORT: 9102
healthcheck:
test: ["CMD-SHELL", "wget -q -O- http://127.0.0.1:9102/health | grep -q '\"status\":\"ok\"'"]
interval: 10s
timeout: 5s
retries: 12
start_period: 40s
# --------------------------------------------------------------------------
# Payments Orchestrator Service
@@ -459,8 +495,8 @@ services:
depends_on:
dev-mongo-init: { condition: service_completed_successfully }
dev-nats: { condition: service_started }
dev-ledger: { condition: service_started }
dev-billing-fees: { condition: service_started }
dev-ledger: { condition: service_healthy }
dev-billing-fees: { condition: service_healthy }
volumes:
- ./api/payments/orchestrator:/src/api/payments/orchestrator
- ./api/payments/storage:/src/api/payments/storage
@@ -485,6 +521,12 @@ services:
NATS_URL: nats://${NATS_USER}:${NATS_PASSWORD}@dev-nats:4222
PAYMENTS_GRPC_PORT: 50062
PAYMENTS_METRICS_PORT: 9403
healthcheck:
test: ["CMD-SHELL", "wget -q -O- http://127.0.0.1:9403/health | grep -q '\"status\":\"ok\"'"]
interval: 10s
timeout: 5s
retries: 12
start_period: 40s
# --------------------------------------------------------------------------
# Payments Quotation Service
@@ -500,10 +542,10 @@ services:
depends_on:
dev-mongo-init: { condition: service_completed_successfully }
dev-nats: { condition: service_started }
dev-discovery: { condition: service_started }
dev-fx-oracle: { condition: service_started }
dev-billing-fees: { condition: service_started }
dev-chain-gateway: { condition: service_started }
dev-discovery: { condition: service_healthy }
dev-fx-oracle: { condition: service_healthy }
dev-billing-fees: { condition: service_healthy }
dev-chain-gateway: { condition: service_healthy }
volumes:
- ./api/payments/quotation:/src/api/payments/quotation
- ./api/payments/storage:/src/api/payments/storage
@@ -529,6 +571,12 @@ services:
FEES_ADDRESS: dev-billing-fees:50060
ORACLE_ADDRESS: dev-fx-oracle:50051
CHAIN_GATEWAY_ADDRESS: dev-chain-gateway:50053
healthcheck:
test: ["CMD-SHELL", "wget -q -O- http://127.0.0.1:9414/health | grep -q '\"status\":\"ok\"'"]
interval: 10s
timeout: 5s
retries: 12
start_period: 40s
# --------------------------------------------------------------------------
# Payments Methods Service
@@ -544,7 +592,7 @@ services:
depends_on:
dev-mongo-init: { condition: service_completed_successfully }
dev-nats: { condition: service_started }
dev-discovery: { condition: service_started }
dev-discovery: { condition: service_healthy }
volumes:
- ./api/payments/methods:/src/api/payments/methods
- ./api/payments/storage:/src/api/payments/storage
@@ -576,6 +624,12 @@ services:
NATS_URL: nats://${NATS_USER}:${NATS_PASSWORD}@dev-nats:4222
PAYMENTS_METHODS_GRPC_PORT: 50066
PAYMENTS_METHODS_METRICS_PORT: 9416
healthcheck:
test: ["CMD-SHELL", "wget -q -O- http://127.0.0.1:9416/health | grep -q '\"status\":\"ok\"'"]
interval: 10s
timeout: 5s
retries: 12
start_period: 40s
# --------------------------------------------------------------------------
# Chain Gateway Vault Agent (sidecar for AppRole authentication)
@@ -623,7 +677,7 @@ services:
depends_on:
dev-mongo-init: { condition: service_completed_successfully }
dev-nats: { condition: service_started }
dev-discovery: { condition: service_started }
dev-discovery: { condition: service_healthy }
dev-vault: { condition: service_healthy }
dev-chain-gateway-vault-agent: { condition: service_healthy }
volumes:
@@ -654,6 +708,12 @@ services:
VAULT_ADDR: ${VAULT_ADDR}
VAULT_TOKEN_FILE: /run/vault/token
CHAIN_GATEWAY_RPC_URL: ${CHAIN_GATEWAY_RPC_URL}
healthcheck:
test: ["CMD-SHELL", "wget -q -O- http://127.0.0.1:9406/health | grep -q '\"status\":\"ok\"'"]
interval: 10s
timeout: 5s
retries: 12
start_period: 40s
# --------------------------------------------------------------------------
# TRON Gateway Vault Agent (sidecar for AppRole authentication)
@@ -701,7 +761,7 @@ services:
depends_on:
dev-mongo-init: { condition: service_completed_successfully }
dev-nats: { condition: service_started }
dev-discovery: { condition: service_started }
dev-discovery: { condition: service_healthy }
dev-vault: { condition: service_healthy }
dev-tron-gateway-vault-agent: { condition: service_healthy }
volumes:
@@ -734,6 +794,12 @@ services:
TRON_GATEWAY_RPC_URL: ${TRON_GATEWAY_RPC_URL:-}
TRON_GATEWAY_GRPC_URL: ${TRON_GATEWAY_GRPC_URL:-}
TRON_GATEWAY_GRPC_TOKEN: ${TRON_GATEWAY_GRPC_TOKEN:-}
healthcheck:
test: ["CMD-SHELL", "wget -q -O- http://127.0.0.1:9408/health | grep -q '\"status\":\"ok\"'"]
interval: 10s
timeout: 5s
retries: 12
start_period: 40s
# --------------------------------------------------------------------------
# Aurora Gateway Service (simulated card payouts)
@@ -748,7 +814,7 @@ services:
restart: unless-stopped
depends_on:
dev-nats: { condition: service_started }
dev-discovery: { condition: service_started }
dev-discovery: { condition: service_healthy }
dev-vault: { condition: service_healthy }
volumes:
- ./api/gateway/aurora:/src/api/gateway/aurora
@@ -777,6 +843,12 @@ services:
AURORA_GATEWAY_METRICS_PORT: 9405
AURORA_GATEWAY_HTTP_PORT: 8084
VAULT_ADDR: ${VAULT_ADDR}
healthcheck:
test: ["CMD-SHELL", "wget -q -O- http://127.0.0.1:9405/health | grep -q '\"status\":\"ok\"'"]
interval: 10s
timeout: 5s
retries: 12
start_period: 40s
# --------------------------------------------------------------------------
# ChimeraSettle Gateway Service (simulated settlements)
@@ -792,7 +864,7 @@ services:
depends_on:
dev-mongo-init: { condition: service_completed_successfully }
dev-nats: { condition: service_started }
dev-discovery: { condition: service_started }
dev-discovery: { condition: service_healthy }
dev-vault: { condition: service_healthy }
volumes:
- ./api/gateway/chsettle:/src/api/gateway/chsettle
@@ -819,6 +891,12 @@ services:
CHSETTLE_GATEWAY_GRPC_PORT: 50080
CHSETTLE_GATEWAY_METRICS_PORT: 9406
VAULT_ADDR: ${VAULT_ADDR}
healthcheck:
test: ["CMD-SHELL", "wget -q -O- http://127.0.0.1:9406/health | grep -q '\"status\":\"ok\"'"]
interval: 10s
timeout: 5s
retries: 12
start_period: 40s
# --------------------------------------------------------------------------
# Notification Service
@@ -854,6 +932,12 @@ services:
MONGO_PASSWORD: ${MONGO_PASSWORD}
MONGO_AUTH_SOURCE: admin
MONGO_REPLICA_SET: dev-rs
healthcheck:
test: ["CMD-SHELL", "wget -q -O- \"http://127.0.0.1:8081$${API_ENDPOINT}/health\" | grep -q '\"status\":\"ok\"'"]
interval: 10s
timeout: 5s
retries: 12
start_period: 40s
# --------------------------------------------------------------------------
# Callbacks Vault Agent (sidecar for AppRole authentication)
@@ -928,6 +1012,12 @@ services:
CALLBACKS_METRICS_PORT: 9420
VAULT_ADDR: ${VAULT_ADDR}
VAULT_TOKEN_FILE: /run/vault/token
healthcheck:
test: ["CMD-SHELL", "wget -q -O- http://127.0.0.1:9420/health | grep -q '\"status\":\"ok\"'"]
interval: 10s
timeout: 5s
retries: 12
start_period: 40s
# --------------------------------------------------------------------------
# BFF Vault Agent (sidecar for AppRole authentication)
@@ -976,11 +1066,11 @@ services:
depends_on:
dev-mongo-init: { condition: service_completed_successfully }
dev-nats: { condition: service_started }
dev-ledger: { condition: service_started }
dev-payments-orchestrator: { condition: service_started }
dev-payments-quotation: { condition: service_started }
dev-payments-methods: { condition: service_started }
dev-chain-gateway: { condition: service_started }
dev-ledger: { condition: service_healthy }
dev-payments-orchestrator: { condition: service_healthy }
dev-payments-quotation: { condition: service_healthy }
dev-payments-methods: { condition: service_healthy }
dev-chain-gateway: { condition: service_healthy }
dev-bff-vault-agent: { condition: service_healthy }
volumes:
- ./api/edge/bff:/src/api/edge/bff
@@ -1020,6 +1110,12 @@ services:
API_ENDPOINT: /api/v1
VAULT_ADDR: ${VAULT_ADDR}
VAULT_TOKEN_FILE: /run/vault/token
healthcheck:
test: ["CMD-SHELL", "wget -q -O- \"http://127.0.0.1:8080$${API_ENDPOINT}/health\" | grep -q '\"status\":\"ok\"'"]
interval: 10s
timeout: 5s
retries: 12
start_period: 40s
# --------------------------------------------------------------------------
# Frontend (Flutter Web)