diff --git a/api/payments/orchestrator/internal/service/orchestrator/card_payout_executor.go b/api/payments/orchestrator/internal/service/orchestrator/card_payout_executor.go index fccbcfe8..2b2fe5ec 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/card_payout_executor.go +++ b/api/payments/orchestrator/internal/service/orchestrator/card_payout_executor.go @@ -58,7 +58,7 @@ func (e *gatewayCardPayoutExecutor) ExecuteCardPayout(ctx context.Context, req s stepToken := cardPayoutStepToken(req.Step) operationRef := cardPayoutOperationRef(req.Payment, stepToken) - payoutRef := cardPayoutRef(req.Payment, stepToken) + payoutRef := cardPayoutRef(req.Payment) idempotencyKey := cardPayoutIdempotencyKey(req.Payment, stepToken) projectID := cardPayoutProjectID(req.Payment) customer := cardPayoutCustomerFromPayment(req.Payment, card, req.Step.Metadata) @@ -219,12 +219,12 @@ func cardPayoutOperationRef(payment *agg.Payment, stepToken string) string { return joinRef(base, stepToken) } -func cardPayoutRef(payment *agg.Payment, stepToken string) string { +func cardPayoutRef(payment *agg.Payment) string { base := "" if payment != nil { 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 { diff --git a/api/payments/orchestrator/internal/service/orchestrator/card_payout_executor_test.go b/api/payments/orchestrator/internal/service/orchestrator/card_payout_executor_test.go index a75b7306..a834ee56 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/card_payout_executor_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/card_payout_executor_test.go @@ -104,7 +104,7 @@ func TestGatewayCardPayoutExecutor_ExecuteCardPayout_SubmitsCardPayout(t *testin if payoutReq == nil { 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) } if got, want := payoutReq.GetOperationRef(), "payment-1:hop_4_card_payout_send"; got != want { diff --git a/api/payments/orchestrator/internal/service/orchestrator/ledger_executor.go b/api/payments/orchestrator/internal/service/orchestrator/ledger_executor.go index 5b1288d7..ba17f60a 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/ledger_executor.go +++ b/api/payments/orchestrator/internal/service/orchestrator/ledger_executor.go @@ -3,6 +3,7 @@ package orchestrator import ( "context" "fmt" + "github.com/shopspring/decimal" "github.com/tech/sendico/pkg/discovery" "strings" @@ -68,6 +69,10 @@ func (e *gatewayLedgerExecutor) ExecuteLedger(ctx context.Context, req sexec.Ste organizationRef := req.Payment.OrganizationRef.Hex() description := ledgerDescription(req.Step) 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 switch action { @@ -77,6 +82,7 @@ func (e *gatewayLedgerExecutor) ExecuteLedger(ctx context.Context, req sexec.Ste OrganizationRef: organizationRef, Money: amount, Description: description, + Charges: charges, Metadata: metadata, Role: ledgerRoleToProto(roles.to), }) @@ -86,6 +92,7 @@ func (e *gatewayLedgerExecutor) ExecuteLedger(ctx context.Context, req sexec.Ste OrganizationRef: organizationRef, Money: amount, Description: description, + Charges: charges, Metadata: metadata, Role: ledgerRoleToProto(roles.from), }) @@ -183,6 +190,111 @@ func settlementMoneyForLedger(payment *agg.Payment, source *paymenttypes.Money) 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 { return settlement } diff --git a/api/payments/orchestrator/internal/service/orchestrator/ledger_executor_test.go b/api/payments/orchestrator/internal/service/orchestrator/ledger_executor_test.go index f53abe2e..3ced6336 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/ledger_executor_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/ledger_executor_test.go @@ -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) { orgID := bson.NewObjectID() payment := testLedgerExecutorPayment(orgID)