extended aurora scenarios + payment operation amounts
This commit is contained in:
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/tech/sendico/pkg/db/storable"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
pm "github.com/tech/sendico/pkg/model"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
@@ -68,6 +69,8 @@ type StepExecution struct {
|
||||
FailureCode string `bson:"failureCode,omitempty" json:"failureCode,omitempty"`
|
||||
FailureMsg string `bson:"failureMsg,omitempty" json:"failureMsg,omitempty"`
|
||||
ExternalRefs []ExternalRef `bson:"externalRefs,omitempty" json:"externalRefs,omitempty"`
|
||||
ExecutedMoney *paymenttypes.Money `bson:"executedMoney,omitempty" json:"executedMoney,omitempty"`
|
||||
ConvertedMoney *paymenttypes.Money `bson:"convertedMoney,omitempty" json:"convertedMoney,omitempty"`
|
||||
}
|
||||
|
||||
// ExternalRef links step execution to an external operation.
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
)
|
||||
|
||||
type normalizedEvent struct {
|
||||
@@ -15,6 +16,7 @@ type normalizedEvent struct {
|
||||
targetState agg.StepState `bson:"targetState"`
|
||||
failureInfo *failureInfo `bson:"failure,omitempty"`
|
||||
forceAggregate *forceAggregate `bson:"forceAggregate,omitempty"`
|
||||
executedMoney *paymenttypes.Money
|
||||
}
|
||||
|
||||
type failureInfo struct {
|
||||
@@ -123,6 +125,7 @@ func normalizeGatewayEvent(src GatewayEvent) (*normalizedEvent, error) {
|
||||
targetState: target,
|
||||
failureInfo: buildFailureInfo(failureCode, failureMsg, normalizeTimePtr(src.OccurredAt)),
|
||||
forceAggregate: buildForceAggregate(src.TerminalFailure, needsAttention),
|
||||
executedMoney: normalizeEventMoney(src.ExecutedMoney),
|
||||
}
|
||||
ev.matchRefs = normalizeRefList([]agg.ExternalRef{
|
||||
{
|
||||
@@ -264,6 +267,21 @@ func normalizeCardStatus(status CardStatus) (CardStatus, bool) {
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeEventMoney(money *paymenttypes.Money) *paymenttypes.Money {
|
||||
if money == nil {
|
||||
return nil
|
||||
}
|
||||
amount := strings.TrimSpace(money.GetAmount())
|
||||
currency := strings.TrimSpace(money.GetCurrency())
|
||||
if amount == "" || currency == "" {
|
||||
return nil
|
||||
}
|
||||
return &paymenttypes.Money{
|
||||
Amount: amount,
|
||||
Currency: currency,
|
||||
}
|
||||
}
|
||||
|
||||
func mapFailureTarget(status any, retryable *bool) (agg.StepState, bool) {
|
||||
switch status {
|
||||
case GatewayStatusCreated, GatewayStatusProcessing, GatewayStatusWaiting:
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
@@ -81,6 +82,7 @@ type GatewayEvent struct {
|
||||
TransferRef string
|
||||
GatewayInstanceID string
|
||||
Status GatewayStatus
|
||||
ExecutedMoney *paymenttypes.Money
|
||||
FailureCode string
|
||||
FailureMsg string
|
||||
Retryable *bool
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xerr"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
@@ -124,6 +125,7 @@ func (s *svc) applyStepEvent(step *agg.StepExecution, event *normalizedEvent, sm
|
||||
|
||||
if out.State == target {
|
||||
changed = s.applyStepDiagnostics(&out, event) || changed
|
||||
changed = applyExecutedMoney(&out, event) || changed
|
||||
*step = out
|
||||
return changed, nil
|
||||
}
|
||||
@@ -140,11 +142,43 @@ func (s *svc) applyStepEvent(step *agg.StepExecution, event *normalizedEvent, sm
|
||||
out = next
|
||||
changed = changed || transitionChanged
|
||||
changed = s.applyStepDiagnostics(&out, event) || changed
|
||||
changed = applyExecutedMoney(&out, event) || changed
|
||||
|
||||
*step = out
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
func applyExecutedMoney(step *agg.StepExecution, event *normalizedEvent) bool {
|
||||
if step == nil || event == nil {
|
||||
return false
|
||||
}
|
||||
money := normalizeEventMoney(event.executedMoney)
|
||||
if money == nil {
|
||||
return false
|
||||
}
|
||||
if normalizeEventMoney(step.ExecutedMoney) != nil {
|
||||
return false
|
||||
}
|
||||
if stepMoneyEqual(step.ExecutedMoney, money) {
|
||||
return false
|
||||
}
|
||||
step.ExecutedMoney = money
|
||||
return true
|
||||
}
|
||||
|
||||
func stepMoneyEqual(left, right *paymenttypes.Money) bool {
|
||||
left = normalizeEventMoney(left)
|
||||
right = normalizeEventMoney(right)
|
||||
switch {
|
||||
case left == nil && right == nil:
|
||||
return true
|
||||
case left == nil || right == nil:
|
||||
return false
|
||||
default:
|
||||
return left.Amount == right.Amount && left.Currency == right.Currency
|
||||
}
|
||||
}
|
||||
|
||||
func transitionStepState(step agg.StepExecution, target agg.StepState, sm ostate.StateMachine) (agg.StepExecution, bool, error) {
|
||||
if step.State == target {
|
||||
return step, false, nil
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
@@ -32,6 +33,7 @@ func TestReconcile_GatewayWaiting_UpdatesRunningAndRefs(t *testing.T) {
|
||||
TransferRef: "tx-1",
|
||||
GatewayInstanceID: "gw-1",
|
||||
Status: GatewayStatusWaiting,
|
||||
ExecutedMoney: &paymenttypes.Money{Amount: "5.84", Currency: "USDT"},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -61,6 +63,15 @@ func TestReconcile_GatewayWaiting_UpdatesRunningAndRefs(t *testing.T) {
|
||||
if !hasRef(got.ExternalRefs, agg.ExternalRef{GatewayInstanceID: "gw-1", Kind: ExternalRefKindTransfer, Ref: "tx-1"}) {
|
||||
t.Fatalf("expected transfer_ref external reference")
|
||||
}
|
||||
if got.ExecutedMoney == nil {
|
||||
t.Fatal("expected executed money to be mapped")
|
||||
}
|
||||
if gotAmt, want := got.ExecutedMoney.Amount, "5.84"; gotAmt != want {
|
||||
t.Fatalf("executed money amount mismatch: got=%q want=%q", gotAmt, want)
|
||||
}
|
||||
if gotCur, want := got.ExecutedMoney.Currency, "USDT"; gotCur != want {
|
||||
t.Fatalf("executed money currency mismatch: got=%q want=%q", gotCur, want)
|
||||
}
|
||||
if out.Payment.State != agg.StateExecuting {
|
||||
t.Fatalf("aggregate state mismatch: got=%q want=%q", out.Payment.State, agg.StateExecuting)
|
||||
}
|
||||
@@ -109,6 +120,54 @@ func TestReconcile_GatewaySuccess_SettlesPayment(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcile_GatewayExecutedMoney_DoesNotOverrideExisting(t *testing.T) {
|
||||
now := time.Date(2026, time.January, 10, 11, 12, 13, 0, time.UTC)
|
||||
reconciler := New(Dependencies{Logger: zap.NewNop(), Now: func() time.Time { return now }})
|
||||
|
||||
out, err := reconciler.Reconcile(Input{
|
||||
Payment: &agg.Payment{
|
||||
PaymentRef: "p1",
|
||||
State: agg.StateCreated,
|
||||
Version: 1,
|
||||
StepExecutions: []agg.StepExecution{
|
||||
{
|
||||
StepRef: "s1",
|
||||
StepCode: "observe",
|
||||
State: agg.StepStatePending,
|
||||
Attempt: 1,
|
||||
ExecutedMoney: &paymenttypes.Money{
|
||||
Amount: "76.50",
|
||||
Currency: "RUB",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Event: Event{
|
||||
Gateway: &GatewayEvent{
|
||||
StepRef: "s1",
|
||||
Status: GatewayStatusSuccess,
|
||||
ExecutedMoney: &paymenttypes.Money{
|
||||
Amount: "7650",
|
||||
Currency: "RUB",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Reconcile returned error: %v", err)
|
||||
}
|
||||
step := out.Payment.StepExecutions[0]
|
||||
if step.ExecutedMoney == nil {
|
||||
t.Fatal("expected executed money")
|
||||
}
|
||||
if got, want := step.ExecutedMoney.Amount, "76.50"; got != want {
|
||||
t.Fatalf("executed money amount mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := step.ExecutedMoney.Currency, "RUB"; got != want {
|
||||
t.Fatalf("executed money currency mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcile_GatewayFailureMapping(t *testing.T) {
|
||||
now := time.Date(2026, time.January, 10, 11, 12, 13, 0, time.UTC)
|
||||
reconciler := New(Dependencies{Logger: zap.NewNop(), Now: func() time.Time { return now }})
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/tech/sendico/pkg/db/storable"
|
||||
pm "github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
@@ -108,6 +109,8 @@ func cloneStepExecutions(src []agg.StepExecution) []agg.StepExecution {
|
||||
step.Attempt = 1
|
||||
}
|
||||
step.ExternalRefs = cloneExternalRefs(step.ExternalRefs)
|
||||
step.ExecutedMoney = cloneStepMoney(step.ExecutedMoney)
|
||||
step.ConvertedMoney = cloneStepMoney(step.ConvertedMoney)
|
||||
step.StartedAt = cloneTime(step.StartedAt)
|
||||
step.CompletedAt = cloneTime(step.CompletedAt)
|
||||
out = append(out, step)
|
||||
@@ -115,6 +118,21 @@ func cloneStepExecutions(src []agg.StepExecution) []agg.StepExecution {
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneStepMoney(money *paymenttypes.Money) *paymenttypes.Money {
|
||||
if money == nil {
|
||||
return nil
|
||||
}
|
||||
amount := strings.TrimSpace(money.GetAmount())
|
||||
currency := strings.TrimSpace(money.GetCurrency())
|
||||
if amount == "" || currency == "" {
|
||||
return nil
|
||||
}
|
||||
return &paymenttypes.Money{
|
||||
Amount: amount,
|
||||
Currency: currency,
|
||||
}
|
||||
}
|
||||
|
||||
func cloneExternalRefs(refs []agg.ExternalRef) []agg.ExternalRef {
|
||||
if len(refs) == 0 {
|
||||
return nil
|
||||
|
||||
@@ -118,6 +118,24 @@ func TestMap_Success(t *testing.T) {
|
||||
if got, want := steps[0].GetUserLabel(), "Card payout"; got != want {
|
||||
t.Fatalf("user_label mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if steps[0].GetExecutedMoney() == nil {
|
||||
t.Fatal("expected executed_money to be mapped")
|
||||
}
|
||||
if got, want := steps[0].GetExecutedMoney().GetAmount(), "95"; got != want {
|
||||
t.Fatalf("executed_money.amount mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := steps[0].GetExecutedMoney().GetCurrency(), "USD"; got != want {
|
||||
t.Fatalf("executed_money.currency mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if steps[0].GetConvertedMoney() == nil {
|
||||
t.Fatal("expected converted_money to be mapped")
|
||||
}
|
||||
if got, want := steps[0].GetConvertedMoney().GetAmount(), "90"; got != want {
|
||||
t.Fatalf("converted_money.amount mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := steps[0].GetConvertedMoney().GetCurrency(), "EUR"; got != want {
|
||||
t.Fatalf("converted_money.currency mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := steps[1].GetReportVisibility(), orchestrationv2.ReportVisibility_REPORT_VISIBILITY_HIDDEN; got != want {
|
||||
t.Fatalf("report_visibility mismatch: got=%s want=%s", got.String(), want.String())
|
||||
}
|
||||
@@ -363,7 +381,15 @@ func newPaymentFixture() *agg.Payment {
|
||||
UserLabel: " Card payout ",
|
||||
State: agg.StepStateRunning,
|
||||
Attempt: 0,
|
||||
StartedAt: &startedAt,
|
||||
ExecutedMoney: &paymenttypes.Money{
|
||||
Amount: "95",
|
||||
Currency: "USD",
|
||||
},
|
||||
ConvertedMoney: &paymenttypes.Money{
|
||||
Amount: "90",
|
||||
Currency: "EUR",
|
||||
},
|
||||
StartedAt: &startedAt,
|
||||
},
|
||||
{
|
||||
StepRef: "s2",
|
||||
|
||||
@@ -46,6 +46,8 @@ func mapStepExecution(step agg.StepExecution, index int) (*orchestrationv2.StepE
|
||||
Refs: mapExternalRefs(step.StepCode, step.ExternalRefs),
|
||||
ReportVisibility: mapReportVisibility(step.ReportVisibility),
|
||||
UserLabel: strings.TrimSpace(step.UserLabel),
|
||||
ExecutedMoney: moneyToProto(step.ExecutedMoney),
|
||||
ConvertedMoney: moneyToProto(step.ConvertedMoney),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -85,6 +85,8 @@ func (defaultObserveConfirmExecutor) ExecuteObserveConfirm(_ context.Context, re
|
||||
step := req.StepExecution
|
||||
step.State = agg.StepStateRunning
|
||||
step.ExternalRefs = refs
|
||||
step.ExecutedMoney = inheritedExecutedMoney(req.Payment, req.Step, req.StepExecution)
|
||||
step.ConvertedMoney = inheritedConvertedMoney(req.Payment, req.Step, req.StepExecution)
|
||||
step.FailureCode = ""
|
||||
step.FailureMsg = ""
|
||||
return &sexec.ExecuteOutput{
|
||||
@@ -198,6 +200,46 @@ func inheritedExternalRefs(payment *agg.Payment, step xplan.Step, current agg.St
|
||||
return refs
|
||||
}
|
||||
|
||||
func inheritedExecutedMoney(payment *agg.Payment, step xplan.Step, current agg.StepExecution) *paymenttypes.Money {
|
||||
if money := cloneStepMoney(current.ExecutedMoney); money != nil {
|
||||
return money
|
||||
}
|
||||
if payment == nil || len(step.DependsOn) == 0 {
|
||||
return nil
|
||||
}
|
||||
index := stepIndexByRef(payment.StepExecutions)
|
||||
for i := range step.DependsOn {
|
||||
idx, ok := index[strings.TrimSpace(step.DependsOn[i])]
|
||||
if !ok || idx < 0 || idx >= len(payment.StepExecutions) {
|
||||
continue
|
||||
}
|
||||
if money := cloneStepMoney(payment.StepExecutions[idx].ExecutedMoney); money != nil {
|
||||
return money
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func inheritedConvertedMoney(payment *agg.Payment, step xplan.Step, current agg.StepExecution) *paymenttypes.Money {
|
||||
if money := cloneStepMoney(current.ConvertedMoney); money != nil {
|
||||
return money
|
||||
}
|
||||
if payment == nil || len(step.DependsOn) == 0 {
|
||||
return nil
|
||||
}
|
||||
index := stepIndexByRef(payment.StepExecutions)
|
||||
for i := range step.DependsOn {
|
||||
idx, ok := index[strings.TrimSpace(step.DependsOn[i])]
|
||||
if !ok || idx < 0 || idx >= len(payment.StepExecutions) {
|
||||
continue
|
||||
}
|
||||
if money := cloneStepMoney(payment.StepExecutions[idx].ConvertedMoney); money != nil {
|
||||
return money
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func appendExternalRefs(existing []agg.ExternalRef, additions ...agg.ExternalRef) []agg.ExternalRef {
|
||||
out := append([]agg.ExternalRef{}, existing...)
|
||||
seen := map[string]struct{}{}
|
||||
|
||||
@@ -122,6 +122,14 @@ func TestDefaultObserveConfirmExecutor_InheritsDependencyRefs(t *testing.T) {
|
||||
StepExecutions: []agg.StepExecution{
|
||||
{
|
||||
StepRef: "hop_1_crypto_send",
|
||||
ExecutedMoney: &paymenttypes.Money{
|
||||
Amount: "1.000000",
|
||||
Currency: "USDT",
|
||||
},
|
||||
ConvertedMoney: &paymenttypes.Money{
|
||||
Amount: "95.00",
|
||||
Currency: "EUR",
|
||||
},
|
||||
ExternalRefs: []agg.ExternalRef{
|
||||
{GatewayInstanceID: "crypto-gw", Kind: "operation_ref", Ref: "op-1"},
|
||||
{GatewayInstanceID: "crypto-gw", Kind: "transfer_ref", Ref: "trf-1"},
|
||||
@@ -161,4 +169,22 @@ func TestDefaultObserveConfirmExecutor_InheritsDependencyRefs(t *testing.T) {
|
||||
if got, want := out.StepExecution.ExternalRefs[0].Ref, "op-1"; got != want {
|
||||
t.Fatalf("first external ref value mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if out.StepExecution.ExecutedMoney == nil {
|
||||
t.Fatal("expected executed money to be inherited from dependency")
|
||||
}
|
||||
if got, want := out.StepExecution.ExecutedMoney.Amount, "1.000000"; got != want {
|
||||
t.Fatalf("executed money amount mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := out.StepExecution.ExecutedMoney.Currency, "USDT"; got != want {
|
||||
t.Fatalf("executed money currency mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if out.StepExecution.ConvertedMoney == nil {
|
||||
t.Fatal("expected converted money to be inherited from dependency")
|
||||
}
|
||||
if got, want := out.StepExecution.ConvertedMoney.Amount, "95.00"; got != want {
|
||||
t.Fatalf("converted money amount mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := out.StepExecution.ConvertedMoney.Currency, "EUR"; got != want {
|
||||
t.Fatalf("converted money currency mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -325,6 +325,8 @@ func normalizeExecutorOutput(current agg.StepExecution, out *sexec.ExecuteOutput
|
||||
next.Attempt = out.StepExecution.Attempt
|
||||
}
|
||||
next.ExternalRefs = out.StepExecution.ExternalRefs
|
||||
next.ExecutedMoney = cloneStepMoney(out.StepExecution.ExecutedMoney)
|
||||
next.ConvertedMoney = cloneStepMoney(out.StepExecution.ConvertedMoney)
|
||||
next.FailureCode = strings.TrimSpace(out.StepExecution.FailureCode)
|
||||
next.FailureMsg = strings.TrimSpace(out.StepExecution.FailureMsg)
|
||||
|
||||
@@ -437,6 +439,12 @@ func stepExecutionEqual(left, right agg.StepExecution) bool {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if !stepMoneyEqual(left.ExecutedMoney, right.ExecutedMoney) {
|
||||
return false
|
||||
}
|
||||
if !stepMoneyEqual(left.ConvertedMoney, right.ConvertedMoney) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package psvc
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
)
|
||||
|
||||
func cloneStepMoney(src *paymenttypes.Money) *paymenttypes.Money {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
amount := strings.TrimSpace(src.GetAmount())
|
||||
currency := strings.TrimSpace(src.GetCurrency())
|
||||
if amount == "" || currency == "" {
|
||||
return nil
|
||||
}
|
||||
return &paymenttypes.Money{
|
||||
Amount: amount,
|
||||
Currency: currency,
|
||||
}
|
||||
}
|
||||
|
||||
func stepMoneyEqual(left, right *paymenttypes.Money) bool {
|
||||
left = cloneStepMoney(left)
|
||||
right = cloneStepMoney(right)
|
||||
switch {
|
||||
case left == nil && right == nil:
|
||||
return true
|
||||
case left == nil || right == nil:
|
||||
return false
|
||||
default:
|
||||
return left.Amount == right.Amount && left.Currency == right.Currency
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/discovery"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
)
|
||||
|
||||
func (s *svc) prepareInput(in Input) (*preparedInput, error) {
|
||||
@@ -174,6 +175,8 @@ func (s *svc) normalizeStepExecution(exec agg.StepExecution, index int) (agg.Ste
|
||||
exec.Rail = model.ParseRail(string(exec.Rail))
|
||||
exec.ReportVisibility = model.NormalizeReportVisibility(exec.ReportVisibility)
|
||||
exec.ExternalRefs = cloneExternalRefs(exec.ExternalRefs)
|
||||
exec.ExecutedMoney = cloneStepMoney(exec.ExecutedMoney)
|
||||
exec.ConvertedMoney = cloneStepMoney(exec.ConvertedMoney)
|
||||
if exec.StepRef == "" {
|
||||
return agg.StepExecution{}, merrors.InvalidArgument("stepExecutions[" + itoa(index) + "].step_ref is required")
|
||||
}
|
||||
@@ -257,9 +260,26 @@ func normalizeStepState(state agg.StepState) (agg.StepState, bool) {
|
||||
func cloneStepExecution(exec agg.StepExecution) agg.StepExecution {
|
||||
out := exec
|
||||
out.ExternalRefs = cloneExternalRefs(exec.ExternalRefs)
|
||||
out.ExecutedMoney = cloneStepMoney(exec.ExecutedMoney)
|
||||
out.ConvertedMoney = cloneStepMoney(exec.ConvertedMoney)
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneStepMoney(money *paymenttypes.Money) *paymenttypes.Money {
|
||||
if money == nil {
|
||||
return nil
|
||||
}
|
||||
amount := strings.TrimSpace(money.GetAmount())
|
||||
currency := strings.TrimSpace(money.GetCurrency())
|
||||
if amount == "" || currency == "" {
|
||||
return nil
|
||||
}
|
||||
return &paymenttypes.Money{
|
||||
Amount: amount,
|
||||
Currency: currency,
|
||||
}
|
||||
}
|
||||
|
||||
func cloneExternalRefs(refs []agg.ExternalRef) []agg.ExternalRef {
|
||||
if len(refs) == 0 {
|
||||
return nil
|
||||
|
||||
@@ -170,6 +170,11 @@ func (e *gatewayCardPayoutExecutor) ExecuteCardPayout(ctx context.Context, req s
|
||||
step := req.StepExecution
|
||||
step.State = agg.StepStateCompleted
|
||||
step.ExternalRefs = externalRefs
|
||||
step.ExecutedMoney = cardMinorToMoney(responsePayout.GetAmountMinor(), responsePayout.GetCurrency())
|
||||
if step.ExecutedMoney == nil {
|
||||
step.ExecutedMoney = cardMinorToMoney(amountMinor, currency)
|
||||
}
|
||||
step.ConvertedMoney = nil
|
||||
step.FailureCode = ""
|
||||
step.FailureMsg = ""
|
||||
return &sexec.ExecuteOutput{StepExecution: step}, nil
|
||||
|
||||
@@ -100,9 +100,10 @@ func TestGatewayCardPayoutExecutor_ExecuteCardPayout_SubmitsCardPayout(t *testin
|
||||
InstanceID: paymenttypes.DefaultCardsGatewayID,
|
||||
},
|
||||
StepExecution: agg.StepExecution{
|
||||
StepRef: "hop_4_card_payout_send",
|
||||
StepCode: "hop.4.card_payout.send",
|
||||
Attempt: 1,
|
||||
StepRef: "hop_4_card_payout_send",
|
||||
StepCode: "hop.4.card_payout.send",
|
||||
Attempt: 1,
|
||||
ConvertedMoney: &paymenttypes.Money{Amount: "1.00", Currency: "USD"},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -116,6 +117,18 @@ func TestGatewayCardPayoutExecutor_ExecuteCardPayout_SubmitsCardPayout(t *testin
|
||||
if out.StepExecution.State != agg.StepStateCompleted {
|
||||
t.Fatalf("expected completed state, got=%q", out.StepExecution.State)
|
||||
}
|
||||
if out.StepExecution.ExecutedMoney == nil {
|
||||
t.Fatal("expected executed money to be recorded")
|
||||
}
|
||||
if got, want := out.StepExecution.ExecutedMoney.Amount, "76.50"; got != want {
|
||||
t.Fatalf("executed money amount mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := out.StepExecution.ExecutedMoney.Currency, "RUB"; got != want {
|
||||
t.Fatalf("executed money currency mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got := out.StepExecution.ConvertedMoney; got != nil {
|
||||
t.Fatalf("expected converted money to be cleared for card payout step, got=%+v", got)
|
||||
}
|
||||
if payoutReq == nil {
|
||||
t.Fatal("expected payout request to be submitted")
|
||||
}
|
||||
|
||||
@@ -93,6 +93,11 @@ func (e *gatewayCryptoExecutor) ExecuteCrypto(ctx context.Context, req sexec.Ste
|
||||
return nil, refsErr
|
||||
}
|
||||
step.ExternalRefs = refs
|
||||
step.ExecutedMoney = transferExecutedMoney(resp.GetTransfer())
|
||||
if step.ExecutedMoney == nil {
|
||||
step.ExecutedMoney = protoMoneyToPaymentMoney(amount)
|
||||
}
|
||||
step.ConvertedMoney = nil
|
||||
|
||||
if action == discovery.RailOperationSend {
|
||||
if err := e.submitWalletFeeTransfer(ctx, req, client, gateway, sourceWalletRef, operationRef, idempotencyKey); err != nil {
|
||||
|
||||
@@ -106,6 +106,15 @@ func TestGatewayCryptoExecutor_ExecuteCrypto_SubmitsTransfer(t *testing.T) {
|
||||
if out.StepExecution.State != agg.StepStateCompleted {
|
||||
t.Fatalf("expected completed state, got=%q", out.StepExecution.State)
|
||||
}
|
||||
if out.StepExecution.ExecutedMoney == nil {
|
||||
t.Fatal("expected executed money to be recorded")
|
||||
}
|
||||
if got, want := out.StepExecution.ExecutedMoney.Amount, "1.000000"; got != want {
|
||||
t.Fatalf("executed money amount mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := out.StepExecution.ExecutedMoney.Currency, "USDT"; got != want {
|
||||
t.Fatalf("executed money currency mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if submitReq == nil {
|
||||
t.Fatal("expected transfer submission request")
|
||||
}
|
||||
|
||||
@@ -159,6 +159,7 @@ func buildGatewayExecutionEvent(payment *agg.Payment, msg *pmodel.PaymentGateway
|
||||
TransferRef: transferRef,
|
||||
GatewayInstanceID: gatewayInstanceID,
|
||||
Status: status,
|
||||
ExecutedMoney: clonePaymentMoney(msg.ExecutedMoney),
|
||||
}
|
||||
|
||||
switch status {
|
||||
@@ -479,6 +480,7 @@ func (s *Service) pollObserveCandidate(ctx context.Context, payment *agg.Payment
|
||||
TransferRef: strings.TrimSpace(candidate.transferRef),
|
||||
GatewayInstanceID: resolvedObserveGatewayID(candidate.gatewayInstanceID, gateway),
|
||||
Status: status,
|
||||
ExecutedMoney: transferExecutedMoney(transfer),
|
||||
}
|
||||
switch status {
|
||||
case erecon.GatewayStatusFailed:
|
||||
@@ -544,6 +546,10 @@ func (s *Service) resolveObserveGateway(ctx context.Context, payment *agg.Paymen
|
||||
return item, nil
|
||||
}
|
||||
}
|
||||
if s.logger != nil {
|
||||
s.logger.Debug("Gateway for polling not found", zap.String("instance_id", hint.instanceID),
|
||||
zap.String("expected_rail", string(expectedRail)), zap.String("hint_rail", string(hint.rail)))
|
||||
}
|
||||
return nil, errors.New("observe polling: gateway instance not found")
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
pm "github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/pkg/payments/rail"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||
orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
@@ -43,9 +44,10 @@ func TestBuildGatewayExecutionEvent_MapsStatusAndMatchedStep(t *testing.T) {
|
||||
}
|
||||
|
||||
event, ok := buildGatewayExecutionEvent(payment, &pm.PaymentGatewayExecution{
|
||||
PaymentRef: payment.PaymentRef,
|
||||
Status: rail.OperationResultSuccess,
|
||||
TransferRef: "trf-1",
|
||||
PaymentRef: payment.PaymentRef,
|
||||
Status: rail.OperationResultSuccess,
|
||||
TransferRef: "trf-1",
|
||||
ExecutedMoney: &paymenttypes.Money{Amount: "5.84", Currency: "USDT"},
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("expected gateway execution event to be accepted")
|
||||
@@ -59,6 +61,15 @@ func TestBuildGatewayExecutionEvent_MapsStatusAndMatchedStep(t *testing.T) {
|
||||
if got, want := event.Status, erecon.GatewayStatusSuccess; got != want {
|
||||
t.Fatalf("status mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if event.ExecutedMoney == nil {
|
||||
t.Fatal("expected executed money to be mapped")
|
||||
}
|
||||
if got, want := event.ExecutedMoney.Amount, "5.84"; got != want {
|
||||
t.Fatalf("executed money amount mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := event.ExecutedMoney.Currency, "USDT"; got != want {
|
||||
t.Fatalf("executed money currency mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildGatewayExecutionEvent_FailedSetsTerminalNeedsAttentionHint(t *testing.T) {
|
||||
@@ -602,6 +613,7 @@ func TestPollObserveCandidate_UsesResolvedGatewayAfterInstanceRotation(t *testin
|
||||
TransferRef: req.GetTransferRef(),
|
||||
OperationRef: operationRef,
|
||||
Status: chainv1.TransferStatus_TRANSFER_SUCCESS,
|
||||
NetAmount: &moneyv1.Money{Amount: "460.00", Currency: "RUB"},
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
@@ -676,6 +688,15 @@ func TestPollObserveCandidate_UsesResolvedGatewayAfterInstanceRotation(t *testin
|
||||
if got, want := gw.GatewayInstanceID, "ea2600ce-3de6-4cc5-bd1e-e26ebaceb6b4"; got != want {
|
||||
t.Fatalf("gateway_instance_id mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if gw.ExecutedMoney == nil {
|
||||
t.Fatal("expected executed money in reconciled gateway event")
|
||||
}
|
||||
if got, want := gw.ExecutedMoney.Amount, "460.00"; got != want {
|
||||
t.Fatalf("executed money amount mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := gw.ExecutedMoney.Currency, "RUB"; got != want {
|
||||
t.Fatalf("executed money currency mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
var _ prepo.Repository = (*fakeExternalRuntimeRepo)(nil)
|
||||
|
||||
@@ -118,6 +118,8 @@ func (e *gatewayLedgerExecutor) ExecuteLedger(ctx context.Context, req sexec.Ste
|
||||
step.State = agg.StepStateCompleted
|
||||
step.FailureCode = ""
|
||||
step.FailureMsg = ""
|
||||
step.ExecutedMoney = protoMoneyToPaymentMoney(amount)
|
||||
step.ConvertedMoney = nil
|
||||
step.ExternalRefs = appendLedgerExternalRef(step.ExternalRefs, agg.ExternalRef{
|
||||
GatewayInstanceID: firstNonEmpty(strings.TrimSpace(req.Step.InstanceID), strings.TrimSpace(req.Step.Gateway)),
|
||||
Kind: erecon.ExternalRefKindLedger,
|
||||
|
||||
@@ -42,9 +42,10 @@ func TestGatewayLedgerExecutor_ExecuteLedger_CreditUsesSourceAmountAndDefaultRol
|
||||
Rail: discovery.RailLedger,
|
||||
},
|
||||
StepExecution: agg.StepExecution{
|
||||
StepRef: "edge_1_2_ledger_credit",
|
||||
StepCode: "edge.1_2.ledger.credit",
|
||||
Attempt: 1,
|
||||
StepRef: "edge_1_2_ledger_credit",
|
||||
StepCode: "edge.1_2.ledger.credit",
|
||||
Attempt: 1,
|
||||
ConvertedMoney: &paymenttypes.Money{Amount: "42", Currency: "EUR"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
@@ -71,6 +72,18 @@ func TestGatewayLedgerExecutor_ExecuteLedger_CreditUsesSourceAmountAndDefaultRol
|
||||
if got, want := out.StepExecution.State, agg.StepStateCompleted; got != want {
|
||||
t.Fatalf("state mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if out.StepExecution.ExecutedMoney == nil {
|
||||
t.Fatal("expected executed money to be recorded")
|
||||
}
|
||||
if got, want := out.StepExecution.ExecutedMoney.Amount, "1.000000"; got != want {
|
||||
t.Fatalf("executed money amount mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := out.StepExecution.ExecutedMoney.Currency, "USDT"; got != want {
|
||||
t.Fatalf("executed money currency mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got := out.StepExecution.ConvertedMoney; got != nil {
|
||||
t.Fatalf("expected converted money to be cleared for ledger step, got=%+v", got)
|
||||
}
|
||||
if len(out.StepExecution.ExternalRefs) != 1 {
|
||||
t.Fatalf("expected one external ref, got=%d", len(out.StepExecution.ExternalRefs))
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan"
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||
)
|
||||
@@ -55,6 +56,10 @@ func (e *gatewayProviderSettlementExecutor) ExecuteProviderSettlement(ctx contex
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
convertedAmount := settlementConvertedMoney(req.Payment)
|
||||
if convertedAmount == nil {
|
||||
return nil, merrors.InvalidArgument("settlement fx_convert: converted amount is required")
|
||||
}
|
||||
|
||||
stepRef := strings.TrimSpace(req.Step.StepRef)
|
||||
operationRef := strings.TrimSpace(req.Payment.PaymentRef) + ":" + stepRef
|
||||
@@ -93,12 +98,21 @@ func (e *gatewayProviderSettlementExecutor) ExecuteProviderSettlement(ctx contex
|
||||
return nil, refsErr
|
||||
}
|
||||
step.ExternalRefs = refs
|
||||
step.ExecutedMoney = protoMoneyToPaymentMoney(amount)
|
||||
step.ConvertedMoney = convertedAmount
|
||||
step.State = agg.StepStateCompleted
|
||||
step.FailureCode = ""
|
||||
step.FailureMsg = ""
|
||||
return &sexec.ExecuteOutput{StepExecution: step}, nil
|
||||
}
|
||||
|
||||
func settlementConvertedMoney(payment *agg.Payment) *paymenttypes.Money {
|
||||
if payment == nil || payment.QuoteSnapshot == nil {
|
||||
return nil
|
||||
}
|
||||
return clonePaymentMoney(payment.QuoteSnapshot.ExpectedSettlementAmount)
|
||||
}
|
||||
|
||||
func (e *gatewayProviderSettlementExecutor) resolveGateway(ctx context.Context, step xplan.Step) (*model.GatewayInstanceDescriptor, error) {
|
||||
if e.gatewayRegistry == nil {
|
||||
return nil, merrors.InvalidArgument("settlement fx_convert: gateway registry is required")
|
||||
|
||||
@@ -101,6 +101,24 @@ func TestGatewayProviderSettlementExecutor_ExecuteProviderSettlement_SubmitsTran
|
||||
if out.StepExecution.State != agg.StepStateCompleted {
|
||||
t.Fatalf("expected completed state, got=%q", out.StepExecution.State)
|
||||
}
|
||||
if out.StepExecution.ExecutedMoney == nil {
|
||||
t.Fatal("expected executed money to be recorded")
|
||||
}
|
||||
if got, want := out.StepExecution.ExecutedMoney.Amount, "1.000000"; got != want {
|
||||
t.Fatalf("executed money amount mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := out.StepExecution.ExecutedMoney.Currency, "USDT"; got != want {
|
||||
t.Fatalf("executed money currency mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if out.StepExecution.ConvertedMoney == nil {
|
||||
t.Fatal("expected converted money to be recorded for settlement fx_convert")
|
||||
}
|
||||
if got, want := out.StepExecution.ConvertedMoney.Amount, "76.63"; got != want {
|
||||
t.Fatalf("converted money amount mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := out.StepExecution.ConvertedMoney.Currency, "RUB"; got != want {
|
||||
t.Fatalf("converted money currency mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if submitReq == nil {
|
||||
t.Fatal("expected transfer submission request")
|
||||
}
|
||||
@@ -190,3 +208,64 @@ func TestGatewayProviderSettlementExecutor_ExecuteProviderSettlement_MissingSett
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayProviderSettlementExecutor_ExecuteProviderSettlement_MissingConvertedAmount(t *testing.T) {
|
||||
orgID := bson.NewObjectID()
|
||||
|
||||
executor := &gatewayProviderSettlementExecutor{
|
||||
gatewayInvokeResolver: &fakeGatewayInvokeResolver{
|
||||
client: &chainclient.Fake{},
|
||||
},
|
||||
gatewayRegistry: &fakeGatewayRegistry{
|
||||
items: []*model.GatewayInstanceDescriptor{
|
||||
{
|
||||
ID: "payment_gateway_settlement",
|
||||
InstanceID: "payment_gateway_settlement",
|
||||
Rail: discovery.RailProviderSettlement,
|
||||
InvokeURI: "grpc://tgsettle-gateway",
|
||||
IsEnabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := executor.ExecuteProviderSettlement(context.Background(), sexec.StepRequest{
|
||||
Payment: &agg.Payment{
|
||||
OrganizationBoundBase: pm.OrganizationBoundBase{OrganizationRef: orgID},
|
||||
PaymentRef: "payment-3",
|
||||
IdempotencyKey: "idem-3",
|
||||
IntentSnapshot: model.PaymentIntent{
|
||||
Ref: "intent-3",
|
||||
Source: model.PaymentEndpoint{
|
||||
Type: model.EndpointTypeManagedWallet,
|
||||
ManagedWallet: &model.ManagedWalletEndpoint{
|
||||
ManagedWalletRef: "wallet-src",
|
||||
},
|
||||
},
|
||||
Amount: &paymenttypes.Money{Amount: "1.000000", Currency: "USDT"},
|
||||
},
|
||||
QuoteSnapshot: &model.PaymentQuoteSnapshot{
|
||||
DebitAmount: &paymenttypes.Money{Amount: "1.000000", Currency: "USDT"},
|
||||
},
|
||||
},
|
||||
Step: xplan.Step{
|
||||
StepRef: "hop_2_settlement_fx_convert",
|
||||
StepCode: "hop.2.settlement.fx_convert",
|
||||
Action: discovery.RailOperationFXConvert,
|
||||
Rail: discovery.RailProviderSettlement,
|
||||
Gateway: "payment_gateway_settlement",
|
||||
InstanceID: "payment_gateway_settlement",
|
||||
},
|
||||
StepExecution: agg.StepExecution{
|
||||
StepRef: "hop_2_settlement_fx_convert",
|
||||
StepCode: "hop.2.settlement.fx_convert",
|
||||
Attempt: 1,
|
||||
},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "converted amount is required") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||
)
|
||||
|
||||
var cardMinorUnitScale = decimal.NewFromInt(100)
|
||||
|
||||
func clonePaymentMoney(src *paymenttypes.Money) *paymenttypes.Money {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
amount := strings.TrimSpace(src.GetAmount())
|
||||
currency := strings.TrimSpace(src.GetCurrency())
|
||||
if amount == "" || currency == "" {
|
||||
return nil
|
||||
}
|
||||
return &paymenttypes.Money{
|
||||
Amount: amount,
|
||||
Currency: currency,
|
||||
}
|
||||
}
|
||||
|
||||
func protoMoneyToPaymentMoney(src *moneyv1.Money) *paymenttypes.Money {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
amount := strings.TrimSpace(src.GetAmount())
|
||||
currency := strings.TrimSpace(src.GetCurrency())
|
||||
if amount == "" || currency == "" {
|
||||
return nil
|
||||
}
|
||||
return &paymenttypes.Money{Amount: amount, Currency: currency}
|
||||
}
|
||||
|
||||
func transferExecutedMoney(transfer *chainv1.Transfer) *paymenttypes.Money {
|
||||
if transfer == nil {
|
||||
return nil
|
||||
}
|
||||
if money := protoMoneyToPaymentMoney(transfer.GetNetAmount()); money != nil {
|
||||
return money
|
||||
}
|
||||
return protoMoneyToPaymentMoney(transfer.GetRequestedAmount())
|
||||
}
|
||||
|
||||
func cardMinorToMoney(amountMinor int64, currency string) *paymenttypes.Money {
|
||||
currency = strings.ToUpper(strings.TrimSpace(currency))
|
||||
if idx := strings.Index(currency, "-"); idx > 0 {
|
||||
currency = currency[:idx]
|
||||
}
|
||||
if currency == "" {
|
||||
return nil
|
||||
}
|
||||
amount := decimal.NewFromInt(amountMinor).Div(cardMinorUnitScale)
|
||||
return &paymenttypes.Money{
|
||||
Amount: amount.StringFixed(2),
|
||||
Currency: currency,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user