fixed payment reference #571

Merged
tech merged 1 commits from tg-567 into main 2026-02-27 13:22:19 +00:00
4 changed files with 309 additions and 4 deletions
Showing only changes of commit 6fe6b7a932 - Show all commits

View File

@@ -58,7 +58,7 @@ func (e *gatewayCardPayoutExecutor) ExecuteCardPayout(ctx context.Context, req s
stepToken := cardPayoutStepToken(req.Step) stepToken := cardPayoutStepToken(req.Step)
operationRef := cardPayoutOperationRef(req.Payment, stepToken) operationRef := cardPayoutOperationRef(req.Payment, stepToken)
payoutRef := cardPayoutRef(req.Payment, stepToken) payoutRef := cardPayoutRef(req.Payment)
idempotencyKey := cardPayoutIdempotencyKey(req.Payment, stepToken) idempotencyKey := cardPayoutIdempotencyKey(req.Payment, stepToken)
projectID := cardPayoutProjectID(req.Payment) projectID := cardPayoutProjectID(req.Payment)
customer := cardPayoutCustomerFromPayment(req.Payment, card, req.Step.Metadata) customer := cardPayoutCustomerFromPayment(req.Payment, card, req.Step.Metadata)
@@ -219,12 +219,12 @@ func cardPayoutOperationRef(payment *agg.Payment, stepToken string) string {
return joinRef(base, stepToken) return joinRef(base, stepToken)
} }
func cardPayoutRef(payment *agg.Payment, stepToken string) string { func cardPayoutRef(payment *agg.Payment) string {
base := "" base := ""
if payment != nil { if payment != nil {
base = firstNonEmpty(strings.TrimSpace(payment.PaymentRef), strings.TrimSpace(payment.IdempotencyKey), "card_payout") base = firstNonEmpty(strings.TrimSpace(payment.PaymentRef), strings.TrimSpace(payment.IdempotencyKey), "card_payout")
} }
return joinRef(base, stepToken) return base
} }
func cardPayoutIdempotencyKey(payment *agg.Payment, stepToken string) string { func cardPayoutIdempotencyKey(payment *agg.Payment, stepToken string) string {

View File

@@ -104,7 +104,7 @@ func TestGatewayCardPayoutExecutor_ExecuteCardPayout_SubmitsCardPayout(t *testin
if payoutReq == nil { if payoutReq == nil {
t.Fatal("expected payout request to be submitted") t.Fatal("expected payout request to be submitted")
} }
if got, want := payoutReq.GetPayoutId(), "payment-1:hop_4_card_payout_send"; got != want { if got, want := payoutReq.GetPayoutId(), "payment-1"; got != want {
t.Fatalf("payout_id mismatch: got=%q want=%q", got, want) t.Fatalf("payout_id mismatch: got=%q want=%q", got, want)
} }
if got, want := payoutReq.GetOperationRef(), "payment-1:hop_4_card_payout_send"; got != want { if got, want := payoutReq.GetOperationRef(), "payment-1:hop_4_card_payout_send"; got != want {

View File

@@ -3,6 +3,7 @@ package orchestrator
import ( import (
"context" "context"
"fmt" "fmt"
"github.com/shopspring/decimal"
"github.com/tech/sendico/pkg/discovery" "github.com/tech/sendico/pkg/discovery"
"strings" "strings"
@@ -68,6 +69,10 @@ func (e *gatewayLedgerExecutor) ExecuteLedger(ctx context.Context, req sexec.Ste
organizationRef := req.Payment.OrganizationRef.Hex() organizationRef := req.Payment.OrganizationRef.Hex()
description := ledgerDescription(req.Step) description := ledgerDescription(req.Step)
metadata := ledgerTransferMetadata(req.Payment, req.Step, roles) metadata := ledgerTransferMetadata(req.Payment, req.Step, roles)
charges, err := ledgerChargesForStep(req.Payment, req.Step, action, amount)
if err != nil {
return nil, err
}
var resp *ledgerv1.PostResponse var resp *ledgerv1.PostResponse
switch action { switch action {
@@ -77,6 +82,7 @@ func (e *gatewayLedgerExecutor) ExecuteLedger(ctx context.Context, req sexec.Ste
OrganizationRef: organizationRef, OrganizationRef: organizationRef,
Money: amount, Money: amount,
Description: description, Description: description,
Charges: charges,
Metadata: metadata, Metadata: metadata,
Role: ledgerRoleToProto(roles.to), Role: ledgerRoleToProto(roles.to),
}) })
@@ -86,6 +92,7 @@ func (e *gatewayLedgerExecutor) ExecuteLedger(ctx context.Context, req sexec.Ste
OrganizationRef: organizationRef, OrganizationRef: organizationRef,
Money: amount, Money: amount,
Description: description, Description: description,
Charges: charges,
Metadata: metadata, Metadata: metadata,
Role: ledgerRoleToProto(roles.from), Role: ledgerRoleToProto(roles.from),
}) })
@@ -183,6 +190,111 @@ func settlementMoneyForLedger(payment *agg.Payment, source *paymenttypes.Money)
return source return source
} }
func ledgerChargesForStep(payment *agg.Payment, step xplan.Step, action model.RailOperation, amount *moneyv1.Money) ([]*ledgerv1.PostingLine, error) {
if !shouldAttachLedgerCharges(payment, step, action) {
return nil, nil
}
if payment == nil || payment.QuoteSnapshot == nil {
return nil, nil
}
currency := ""
if amount != nil {
currency = strings.TrimSpace(amount.GetCurrency())
}
return feeLinesToLedgerCharges(payment.QuoteSnapshot.FeeLines, currency)
}
func shouldAttachLedgerCharges(payment *agg.Payment, step xplan.Step, action model.RailOperation) bool {
switch action {
case discovery.RailOperationExternalCredit, discovery.RailOperationExternalDebit:
default:
return false
}
fromRail, toRail, ok := ledgerBoundaryRails(payment, step)
if !ok {
// Prefer attaching on external debits when boundary metadata is unavailable.
return action == discovery.RailOperationExternalDebit
}
switch action {
case discovery.RailOperationExternalCredit:
return isLedgerExternalRail(fromRail) && isLedgerInternalRail(toRail)
case discovery.RailOperationExternalDebit:
return isLedgerExternalRail(toRail)
default:
return false
}
}
func feeLinesToLedgerCharges(lines []*paymenttypes.FeeLine, currency string) ([]*ledgerv1.PostingLine, error) {
if len(lines) == 0 {
return nil, nil
}
result := make([]*ledgerv1.PostingLine, 0, len(lines))
for i := range lines {
line := lines[i]
if line == nil || line.GetMoney() == nil {
continue
}
accountRef := strings.TrimSpace(line.GetLedgerAccountRef())
if accountRef == "" {
continue
}
lineCurrency := strings.TrimSpace(line.GetMoney().GetCurrency())
if currency != "" && !strings.EqualFold(lineCurrency, currency) {
continue
}
amount, err := decimal.NewFromString(strings.TrimSpace(line.GetMoney().GetAmount()))
if err != nil {
return nil, merrors.InvalidArgument(fmt.Sprintf("ledger step: fee_lines[%d].money.amount is invalid", i))
}
amount = normalizeChargeAmountForSide(amount, line.GetSide())
result = append(result, &ledgerv1.PostingLine{
LedgerAccountRef: accountRef,
Money: &moneyv1.Money{
Amount: amount.String(),
Currency: lineCurrency,
},
LineType: feeLineTypeToLedger(line.GetLineType()),
})
}
if len(result) == 0 {
return nil, nil
}
return result, nil
}
func normalizeChargeAmountForSide(amount decimal.Decimal, side paymenttypes.EntrySide) decimal.Decimal {
switch side {
case paymenttypes.EntrySideDebit:
if amount.Sign() > 0 {
return amount.Neg()
}
case paymenttypes.EntrySideCredit:
if amount.Sign() < 0 {
return amount.Neg()
}
}
return amount
}
func feeLineTypeToLedger(lineType paymenttypes.PostingLineType) ledgerv1.LineType {
switch lineType {
case paymenttypes.PostingLineTypeSpread:
return ledgerv1.LineType_LINE_SPREAD
case paymenttypes.PostingLineTypeReversal:
return ledgerv1.LineType_LINE_REVERSAL
default:
// Ledger supports fee/spread/reversal only; map TAX/UNSPECIFIED to fee.
return ledgerv1.LineType_LINE_FEE
}
}
func payoutMoneyForLedger(_ *agg.Payment, settlement *paymenttypes.Money) *paymenttypes.Money { func payoutMoneyForLedger(_ *agg.Payment, settlement *paymenttypes.Money) *paymenttypes.Money {
return settlement return settlement
} }

View File

@@ -222,6 +222,199 @@ func TestGatewayLedgerExecutor_ExecuteLedger_ExternalDebitUsesPostDebitWithCharg
} }
} }
func TestGatewayLedgerExecutor_ExecuteLedger_ExternalDebitForwardsQuoteFeeLinesAsCharges(t *testing.T) {
orgID := bson.NewObjectID()
payment := testLedgerExecutorPayment(orgID)
payment.QuoteSnapshot.FeeLines = []*paymenttypes.FeeLine{
{
LedgerAccountRef: "fee-ledger-1",
Money: &paymenttypes.Money{Amount: "2.50", Currency: "RUB"},
LineType: paymenttypes.PostingLineTypeFee,
Side: paymenttypes.EntrySideDebit,
},
{
LedgerAccountRef: "spread-ledger-1",
Money: &paymenttypes.Money{Amount: "1.25", Currency: "RUB"},
LineType: paymenttypes.PostingLineTypeSpread,
Side: paymenttypes.EntrySideCredit,
},
{
LedgerAccountRef: "mismatch-ledger-1",
Money: &paymenttypes.Money{Amount: "5", Currency: "USDT"},
LineType: paymenttypes.PostingLineTypeFee,
Side: paymenttypes.EntrySideDebit,
},
}
var postReq *ledgerv1.PostDebitRequest
executor := &gatewayLedgerExecutor{
ledgerClient: &ledgerclient.Fake{
PostExternalDebitWithChargesFn: func(_ context.Context, req *ledgerv1.PostDebitRequest) (*ledgerv1.PostResponse, error) {
postReq = req
return &ledgerv1.PostResponse{JournalEntryRef: "entry-ext-debit-fees"}, nil
},
},
}
_, err := executor.ExecuteLedger(context.Background(), sexec.StepRequest{
Payment: payment,
Step: xplan.Step{
StepRef: "edge_3_4_ledger_debit",
StepCode: "edge.3_4.ledger.debit",
Action: discovery.RailOperationExternalDebit,
Rail: discovery.RailLedger,
Metadata: map[string]string{
"mode": "finalize_debit",
},
},
StepExecution: agg.StepExecution{
StepRef: "edge_3_4_ledger_debit",
StepCode: "edge.3_4.ledger.debit",
Attempt: 1,
},
})
if err != nil {
t.Fatalf("ExecuteLedger returned error: %v", err)
}
if postReq == nil {
t.Fatal("expected external debit request")
}
if got, want := len(postReq.GetCharges()), 2; got != want {
t.Fatalf("charges count mismatch: got=%d want=%d", got, want)
}
if got, want := postReq.GetCharges()[0].GetLedgerAccountRef(), "fee-ledger-1"; got != want {
t.Fatalf("charges[0].ledger_account_ref mismatch: got=%q want=%q", got, want)
}
if got, want := postReq.GetCharges()[0].GetMoney().GetCurrency(), "RUB"; got != want {
t.Fatalf("charges[0].money.currency mismatch: got=%q want=%q", got, want)
}
if got, want := postReq.GetCharges()[0].GetMoney().GetAmount(), "-2.5"; got != want {
t.Fatalf("charges[0].money.amount mismatch: got=%q want=%q", got, want)
}
if got, want := postReq.GetCharges()[0].GetLineType(), ledgerv1.LineType_LINE_FEE; got != want {
t.Fatalf("charges[0].line_type mismatch: got=%v want=%v", got, want)
}
if got, want := postReq.GetCharges()[1].GetLedgerAccountRef(), "spread-ledger-1"; got != want {
t.Fatalf("charges[1].ledger_account_ref mismatch: got=%q want=%q", got, want)
}
if got, want := postReq.GetCharges()[1].GetMoney().GetAmount(), "1.25"; got != want {
t.Fatalf("charges[1].money.amount mismatch: got=%q want=%q", got, want)
}
if got, want := postReq.GetCharges()[1].GetLineType(), ledgerv1.LineType_LINE_SPREAD; got != want {
t.Fatalf("charges[1].line_type mismatch: got=%v want=%v", got, want)
}
}
func TestGatewayLedgerExecutor_ExecuteLedger_ExternalCreditSkipsChargesForExternalToExternalBoundary(t *testing.T) {
orgID := bson.NewObjectID()
payment := testLedgerExecutorPayment(orgID)
payment.QuoteSnapshot.FeeLines = []*paymenttypes.FeeLine{
{
LedgerAccountRef: "fee-ledger-1",
Money: &paymenttypes.Money{Amount: "1.00", Currency: "USDT"},
LineType: paymenttypes.PostingLineTypeFee,
Side: paymenttypes.EntrySideDebit,
},
}
var postReq *ledgerv1.PostCreditRequest
executor := &gatewayLedgerExecutor{
ledgerClient: &ledgerclient.Fake{
PostExternalCreditWithChargesFn: func(_ context.Context, req *ledgerv1.PostCreditRequest) (*ledgerv1.PostResponse, error) {
postReq = req
return &ledgerv1.PostResponse{JournalEntryRef: "entry-ext-credit-no-dup"}, nil
},
},
}
_, err := executor.ExecuteLedger(context.Background(), sexec.StepRequest{
Payment: payment,
Step: xplan.Step{
StepRef: "edge_1_2_ledger_credit",
StepCode: "edge.1_2.ledger.credit",
Action: discovery.RailOperationExternalCredit,
Rail: discovery.RailLedger,
},
StepExecution: agg.StepExecution{
StepRef: "edge_1_2_ledger_credit",
StepCode: "edge.1_2.ledger.credit",
Attempt: 1,
},
})
if err != nil {
t.Fatalf("ExecuteLedger returned error: %v", err)
}
if postReq == nil {
t.Fatal("expected external credit request")
}
if got, want := len(postReq.GetCharges()), 0; got != want {
t.Fatalf("charges count mismatch: got=%d want=%d", got, want)
}
}
func TestGatewayLedgerExecutor_ExecuteLedger_ExternalCreditForwardsChargesForExternalToInternalBoundary(t *testing.T) {
orgID := bson.NewObjectID()
payment := testLedgerExecutorPayment(orgID)
payment.QuoteSnapshot.ExpectedSettlementAmount = &paymenttypes.Money{Amount: "10", Currency: "USD"}
payment.QuoteSnapshot.Route = &paymenttypes.QuoteRouteSpecification{
Hops: []*paymenttypes.QuoteRouteHop{
{Index: 1, Rail: "CRYPTO", Role: paymenttypes.QuoteRouteHopRoleSource},
{Index: 2, Rail: "LEDGER", Role: paymenttypes.QuoteRouteHopRoleTransit},
},
}
payment.QuoteSnapshot.FeeLines = []*paymenttypes.FeeLine{
{
LedgerAccountRef: "fee-ledger-1",
Money: &paymenttypes.Money{Amount: "0.75", Currency: "USD"},
LineType: paymenttypes.PostingLineTypeTax, // mapped to LINE_FEE in ledger
Side: paymenttypes.EntrySideDebit,
},
}
var postReq *ledgerv1.PostCreditRequest
executor := &gatewayLedgerExecutor{
ledgerClient: &ledgerclient.Fake{
PostExternalCreditWithChargesFn: func(_ context.Context, req *ledgerv1.PostCreditRequest) (*ledgerv1.PostResponse, error) {
postReq = req
return &ledgerv1.PostResponse{JournalEntryRef: "entry-ext-credit-fees"}, nil
},
},
}
_, err := executor.ExecuteLedger(context.Background(), sexec.StepRequest{
Payment: payment,
Step: xplan.Step{
StepRef: "edge_1_2_ledger_credit",
StepCode: "edge.1_2.ledger.credit",
Action: discovery.RailOperationExternalCredit,
Rail: discovery.RailLedger,
},
StepExecution: agg.StepExecution{
StepRef: "edge_1_2_ledger_credit",
StepCode: "edge.1_2.ledger.credit",
Attempt: 1,
},
})
if err != nil {
t.Fatalf("ExecuteLedger returned error: %v", err)
}
if postReq == nil {
t.Fatal("expected external credit request")
}
if got, want := postReq.GetMoney().GetCurrency(), "USD"; got != want {
t.Fatalf("money.currency mismatch: got=%q want=%q", got, want)
}
if got, want := len(postReq.GetCharges()), 1; got != want {
t.Fatalf("charges count mismatch: got=%d want=%d", got, want)
}
if got, want := postReq.GetCharges()[0].GetMoney().GetAmount(), "-0.75"; got != want {
t.Fatalf("charges[0].money.amount mismatch: got=%q want=%q", got, want)
}
if got, want := postReq.GetCharges()[0].GetLineType(), ledgerv1.LineType_LINE_FEE; got != want {
t.Fatalf("charges[0].line_type mismatch: got=%v want=%v", got, want)
}
}
func TestGatewayLedgerExecutor_ExecuteLedger_FinalizeDebitUsesHoldToTransitAndSettlementAmount(t *testing.T) { func TestGatewayLedgerExecutor_ExecuteLedger_FinalizeDebitUsesHoldToTransitAndSettlementAmount(t *testing.T) {
orgID := bson.NewObjectID() orgID := bson.NewObjectID()
payment := testLedgerExecutorPayment(orgID) payment := testLedgerExecutorPayment(orgID)