Orchestrator refactoring + planned amounts

This commit is contained in:
Stephan D
2026-03-11 20:04:10 +01:00
parent 208b4283d0
commit f578278205
111 changed files with 2485 additions and 1517 deletions

View File

@@ -91,18 +91,27 @@ type PaymentEndpoint struct {
}
type PaymentOperation struct {
StepRef string `json:"stepRef,omitempty"`
Code string `json:"code,omitempty"`
State string `json:"state,omitempty"`
Label string `json:"label,omitempty"`
StepRef string `json:"stepRef,omitempty"`
Code string `json:"code,omitempty"`
State string `json:"state,omitempty"`
Label string `json:"label,omitempty"`
Money *PaymentOperationMoney `json:"money,omitempty"`
OperationRef string `json:"operationRef,omitempty"`
Gateway string `json:"gateway,omitempty"`
FailureCode string `json:"failureCode,omitempty"`
FailureReason string `json:"failureReason,omitempty"`
StartedAt time.Time `json:"startedAt,omitempty"`
CompletedAt time.Time `json:"completedAt,omitempty"`
}
type PaymentOperationMoney struct {
Planned *PaymentOperationMoneySnapshot `json:"planned,omitempty"`
Executed *PaymentOperationMoneySnapshot `json:"executed,omitempty"`
}
type PaymentOperationMoneySnapshot struct {
Amount *paymenttypes.Money `json:"amount,omitempty"`
ConvertedAmount *paymenttypes.Money `json:"convertedAmount,omitempty"`
OperationRef string `json:"operationRef,omitempty"`
Gateway string `json:"gateway,omitempty"`
FailureCode string `json:"failureCode,omitempty"`
FailureReason string `json:"failureReason,omitempty"`
StartedAt time.Time `json:"startedAt,omitempty"`
CompletedAt time.Time `json:"completedAt,omitempty"`
}
type paymentQuoteResponse struct {
@@ -581,19 +590,20 @@ func toUserVisibleOperations(steps []*orchestrationv2.StepExecution) []PaymentOp
func toPaymentOperation(step *orchestrationv2.StepExecution) PaymentOperation {
operationRef, gateway := operationRefAndGateway(step.GetStepCode(), step.GetRefs())
amount := normalizeOperationMoney(toMoney(step.GetExecutedMoney()))
convertedAmount := normalizeOperationMoney(toMoney(step.GetConvertedMoney()))
plannedAmount := stepPlannedAmount(step)
plannedConvertedAmount := stepPlannedConvertedAmount(step)
executedAmount := stepExecutedAmount(step)
executedConvertedAmount := stepExecutedConvertedAmount(step)
op := PaymentOperation{
StepRef: step.GetStepRef(),
Code: step.GetStepCode(),
State: enumJSONName(step.GetState().String()),
Label: strings.TrimSpace(step.GetUserLabel()),
Amount: amount,
ConvertedAmount: convertedAmount,
OperationRef: operationRef,
Gateway: gateway,
StartedAt: timestampAsTime(step.GetStartedAt()),
CompletedAt: timestampAsTime(step.GetCompletedAt()),
StepRef: step.GetStepRef(),
Code: step.GetStepCode(),
State: enumJSONName(step.GetState().String()),
Label: strings.TrimSpace(step.GetUserLabel()),
Money: toOperationMoney(plannedAmount, plannedConvertedAmount, executedAmount, executedConvertedAmount),
OperationRef: operationRef,
Gateway: gateway,
StartedAt: timestampAsTime(step.GetStartedAt()),
CompletedAt: timestampAsTime(step.GetCompletedAt()),
}
failure := step.GetFailure()
if failure == nil {
@@ -607,6 +617,89 @@ func toPaymentOperation(step *orchestrationv2.StepExecution) PaymentOperation {
return op
}
func stepPlannedAmount(step *orchestrationv2.StepExecution) *paymenttypes.Money {
if step == nil {
return nil
}
if money := step.GetMoney(); money != nil {
if planned := money.GetPlanned(); planned != nil {
if normalized := normalizeOperationMoney(toMoney(planned.GetAmount())); normalized != nil {
return normalized
}
}
}
return nil
}
func stepPlannedConvertedAmount(step *orchestrationv2.StepExecution) *paymenttypes.Money {
if step == nil {
return nil
}
if money := step.GetMoney(); money != nil {
if planned := money.GetPlanned(); planned != nil {
if normalized := normalizeOperationMoney(toMoney(planned.GetConvertedAmount())); normalized != nil {
return normalized
}
}
}
return nil
}
func stepExecutedAmount(step *orchestrationv2.StepExecution) *paymenttypes.Money {
if step == nil {
return nil
}
if money := step.GetMoney(); money != nil {
if executed := money.GetExecuted(); executed != nil {
if normalized := normalizeOperationMoney(toMoney(executed.GetAmount())); normalized != nil {
return normalized
}
}
}
return nil
}
func stepExecutedConvertedAmount(step *orchestrationv2.StepExecution) *paymenttypes.Money {
if step == nil {
return nil
}
if money := step.GetMoney(); money != nil {
if executed := money.GetExecuted(); executed != nil {
if normalized := normalizeOperationMoney(toMoney(executed.GetConvertedAmount())); normalized != nil {
return normalized
}
}
}
return nil
}
func toOperationMoney(
plannedAmount *paymenttypes.Money,
plannedConvertedAmount *paymenttypes.Money,
executedAmount *paymenttypes.Money,
executedConvertedAmount *paymenttypes.Money,
) *PaymentOperationMoney {
planned := toOperationMoneySnapshot(plannedAmount, plannedConvertedAmount)
executed := toOperationMoneySnapshot(executedAmount, executedConvertedAmount)
if planned == nil && executed == nil {
return nil
}
return &PaymentOperationMoney{
Planned: planned,
Executed: executed,
}
}
func toOperationMoneySnapshot(amount *paymenttypes.Money, convertedAmount *paymenttypes.Money) *PaymentOperationMoneySnapshot {
if amount == nil && convertedAmount == nil {
return nil
}
return &PaymentOperationMoneySnapshot{
Amount: amount,
ConvertedAmount: convertedAmount,
}
}
func normalizeOperationMoney(value *paymenttypes.Money) *paymenttypes.Money {
if value == nil {
return nil

View File

@@ -343,83 +343,235 @@ func TestToPaymentOperation_MapsAmount(t *testing.T) {
State: orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_COMPLETED,
})
if got := op.Amount; got != nil {
t.Fatalf("expected nil amount without executed_money, got=%+v", got)
}
if got := op.ConvertedAmount; got != nil {
t.Fatalf("expected no converted_amount for non-fx operation, got=%+v", got)
if got := op.Money; got != nil {
t.Fatalf("expected nil money payload without step money, got=%+v", got)
}
}
func TestToPaymentOperation_PrefersExecutedMoney(t *testing.T) {
op := toPaymentOperation(&orchestrationv2.StepExecution{
StepRef: "step-4b",
StepCode: "hop.4.card_payout.send",
State: orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_COMPLETED,
ExecutedMoney: &moneyv1.Money{Amount: "99.95", Currency: "EUR"},
StepRef: "step-4b",
StepCode: "hop.4.card_payout.send",
State: orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_COMPLETED,
Money: &orchestrationv2.StepExecutionMoney{
Planned: &orchestrationv2.StepExecutionMoneySnapshot{
Amount: &moneyv1.Money{Amount: "88.00", Currency: "EUR"},
},
Executed: &orchestrationv2.StepExecutionMoneySnapshot{
Amount: &moneyv1.Money{Amount: "99.95", Currency: "EUR"},
},
},
})
if op.Amount == nil {
t.Fatal("expected amount to be mapped")
if op.Money == nil || op.Money.Executed == nil || op.Money.Executed.Amount == nil {
t.Fatal("expected executed amount to be mapped")
}
if got, want := op.Amount.Amount, "99.95"; got != want {
t.Fatalf("amount.value mismatch: got=%q want=%q", got, want)
if got, want := op.Money.Executed.Amount.Amount, "99.95"; got != want {
t.Fatalf("executed amount.value mismatch: got=%q want=%q", got, want)
}
if got, want := op.Amount.Currency, "EUR"; got != want {
t.Fatalf("amount.currency mismatch: got=%q want=%q", got, want)
if got, want := op.Money.Executed.Amount.Currency, "EUR"; got != want {
t.Fatalf("executed amount.currency mismatch: got=%q want=%q", got, want)
}
if got := op.ConvertedAmount; got != nil {
t.Fatalf("expected no converted_amount for non-fx operation, got=%+v", got)
if op.Money.Planned == nil || op.Money.Planned.Amount == nil {
t.Fatal("expected planned amount to be exposed")
}
if got, want := op.Money.Planned.Amount.Amount, "88.00"; got != want {
t.Fatalf("planned amount.value mismatch: got=%q want=%q", got, want)
}
if got, want := op.Money.Planned.Amount.Currency, "EUR"; got != want {
t.Fatalf("planned amount.currency mismatch: got=%q want=%q", got, want)
}
if got := op.Money.Executed.ConvertedAmount; got != nil {
t.Fatalf("expected no executed converted_amount for non-fx operation, got=%+v", got)
}
}
func TestToPaymentOperation_UsesPlannedMoneyBeforeExecution(t *testing.T) {
op := toPaymentOperation(&orchestrationv2.StepExecution{
StepRef: "step-4c",
StepCode: "hop.4.card_payout.send",
State: orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_PENDING,
Money: &orchestrationv2.StepExecutionMoney{
Planned: &orchestrationv2.StepExecutionMoneySnapshot{
Amount: &moneyv1.Money{Amount: "77.10", Currency: "USD"},
},
},
})
if op.Money == nil || op.Money.Planned == nil || op.Money.Planned.Amount == nil {
t.Fatal("expected planned amount from structured planned money")
}
if got, want := op.Money.Planned.Amount.Amount, "77.10"; got != want {
t.Fatalf("planned amount.value mismatch: got=%q want=%q", got, want)
}
if got, want := op.Money.Planned.Amount.Currency, "USD"; got != want {
t.Fatalf("planned amount.currency mismatch: got=%q want=%q", got, want)
}
if got := op.Money.Executed; got != nil {
t.Fatalf("expected no executed snapshot before execution, got=%+v", got)
}
}
func TestToPaymentOperation_UsesStructuredMoneyEnvelope(t *testing.T) {
op := toPaymentOperation(&orchestrationv2.StepExecution{
StepRef: "step-4d",
StepCode: "hop.4.card_payout.send",
State: orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_PENDING,
Money: &orchestrationv2.StepExecutionMoney{
Planned: &orchestrationv2.StepExecutionMoneySnapshot{
Amount: &moneyv1.Money{Amount: "66.00", Currency: "USD"},
},
Executed: &orchestrationv2.StepExecutionMoneySnapshot{
Amount: &moneyv1.Money{Amount: "67.00", Currency: "USD"},
},
},
})
if op.Money == nil || op.Money.Executed == nil || op.Money.Executed.Amount == nil {
t.Fatal("expected amount from structured executed money")
}
if got, want := op.Money.Executed.Amount.Amount, "67.00"; got != want {
t.Fatalf("executed amount.value mismatch: got=%q want=%q", got, want)
}
if op.Money.Planned == nil || op.Money.Planned.Amount == nil {
t.Fatal("expected planned amount from structured money")
}
if got, want := op.Money.Planned.Amount.Amount, "66.00"; got != want {
t.Fatalf("planned amount.value mismatch: got=%q want=%q", got, want)
}
}
func TestToPaymentOperation_MapsFxTwoAmounts(t *testing.T) {
op := toPaymentOperation(&orchestrationv2.StepExecution{
StepRef: "step-5",
StepCode: "hop.2.settlement.fx_convert",
State: orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_COMPLETED,
ConvertedMoney: &moneyv1.Money{Amount: "100.00", Currency: "EUR"},
StepRef: "step-5",
StepCode: "hop.2.settlement.fx_convert",
State: orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_COMPLETED,
Money: &orchestrationv2.StepExecutionMoney{
Executed: &orchestrationv2.StepExecutionMoneySnapshot{
ConvertedAmount: &moneyv1.Money{Amount: "100.00", Currency: "EUR"},
},
},
})
if got := op.Amount; got != nil {
if op.Money == nil || op.Money.Executed == nil {
t.Fatal("expected executed snapshot to be mapped")
}
if got := op.Money.Executed.Amount; got != nil {
t.Fatalf("expected nil base amount without executed_money, got=%+v", got)
}
if op.ConvertedAmount == nil {
if op.Money.Executed.ConvertedAmount == nil {
t.Fatal("expected fx converted amount to be mapped")
}
if got, want := op.ConvertedAmount.Amount, "100.00"; got != want {
if got, want := op.Money.Executed.ConvertedAmount.Amount, "100.00"; got != want {
t.Fatalf("converted amount.value mismatch: got=%q want=%q", got, want)
}
if got, want := op.ConvertedAmount.Currency, "EUR"; got != want {
if got, want := op.Money.Executed.ConvertedAmount.Currency, "EUR"; got != want {
t.Fatalf("converted amount.currency mismatch: got=%q want=%q", got, want)
}
}
func TestToPaymentOperation_UsesPlannedFxAmountsBeforeExecution(t *testing.T) {
op := toPaymentOperation(&orchestrationv2.StepExecution{
StepRef: "step-5b",
StepCode: "hop.2.settlement.fx_convert",
State: orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_PENDING,
Money: &orchestrationv2.StepExecutionMoney{
Planned: &orchestrationv2.StepExecutionMoneySnapshot{
Amount: &moneyv1.Money{Amount: "109.50", Currency: "USDT"},
ConvertedAmount: &moneyv1.Money{Amount: "100.00", Currency: "EUR"},
},
},
})
if op.Money == nil || op.Money.Planned == nil {
t.Fatal("expected planned snapshot from structured money")
}
if got, want := op.Money.Planned.Amount.Amount, "109.50"; got != want {
t.Fatalf("base amount.value mismatch: got=%q want=%q", got, want)
}
if got, want := op.Money.Planned.Amount.Currency, "USDT"; got != want {
t.Fatalf("base amount.currency mismatch: got=%q want=%q", got, want)
}
if op.Money.Planned.ConvertedAmount == nil {
t.Fatal("expected fx converted amount from structured planned money")
}
if got, want := op.Money.Planned.ConvertedAmount.Amount, "100.00"; got != want {
t.Fatalf("converted amount.value mismatch: got=%q want=%q", got, want)
}
if got, want := op.Money.Planned.ConvertedAmount.Currency, "EUR"; got != want {
t.Fatalf("converted amount.currency mismatch: got=%q want=%q", got, want)
}
if got := op.Money.Executed; got != nil {
t.Fatalf("expected nil executed snapshot before execution, got=%+v", got)
}
}
func TestToPaymentOperation_UsesStructuredFxMoneyEnvelope(t *testing.T) {
op := toPaymentOperation(&orchestrationv2.StepExecution{
StepRef: "step-5c",
StepCode: "hop.2.settlement.fx_convert",
State: orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_COMPLETED,
Money: &orchestrationv2.StepExecutionMoney{
Planned: &orchestrationv2.StepExecutionMoneySnapshot{
Amount: &moneyv1.Money{Amount: "109.50", Currency: "USDT"},
ConvertedAmount: &moneyv1.Money{Amount: "100.00", Currency: "EUR"},
},
Executed: &orchestrationv2.StepExecutionMoneySnapshot{
Amount: &moneyv1.Money{Amount: "110.00", Currency: "USDT"},
ConvertedAmount: &moneyv1.Money{Amount: "101.00", Currency: "EUR"},
},
},
})
if op.Money == nil || op.Money.Executed == nil || op.Money.Planned == nil {
t.Fatal("expected snapshots from structured money")
}
if got, want := op.Money.Executed.Amount.Amount, "110.00"; got != want {
t.Fatalf("amount.value mismatch: got=%q want=%q", got, want)
}
if got, want := op.Money.Executed.ConvertedAmount.Amount, "101.00"; got != want {
t.Fatalf("converted amount.value mismatch: got=%q want=%q", got, want)
}
if op.Money.Planned.Amount == nil || op.Money.Planned.ConvertedAmount == nil {
t.Fatal("expected planned amounts from structured money")
}
if got, want := op.Money.Planned.Amount.Amount, "109.50"; got != want {
t.Fatalf("planned amount.value mismatch: got=%q want=%q", got, want)
}
if got, want := op.Money.Planned.ConvertedAmount.Amount, "100.00"; got != want {
t.Fatalf("planned converted amount.value mismatch: got=%q want=%q", got, want)
}
}
func TestToPaymentOperation_FxWithExecutedMoney_StillProvidesTwoAmounts(t *testing.T) {
op := toPaymentOperation(&orchestrationv2.StepExecution{
StepRef: "step-6",
StepCode: "hop.2.settlement.fx_convert",
State: orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_COMPLETED,
ExecutedMoney: &moneyv1.Money{Amount: "109.50", Currency: "USDT"},
ConvertedMoney: &moneyv1.Money{Amount: "100.00", Currency: "EUR"},
StepRef: "step-6",
StepCode: "hop.2.settlement.fx_convert",
State: orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_COMPLETED,
Money: &orchestrationv2.StepExecutionMoney{
Executed: &orchestrationv2.StepExecutionMoneySnapshot{
Amount: &moneyv1.Money{Amount: "109.50", Currency: "USDT"},
ConvertedAmount: &moneyv1.Money{Amount: "100.00", Currency: "EUR"},
},
},
})
if op.Amount == nil {
if op.Money == nil || op.Money.Executed == nil || op.Money.Executed.Amount == nil {
t.Fatal("expected fx base amount to be mapped")
}
if got, want := op.Amount.Amount, "109.50"; got != want {
if got, want := op.Money.Executed.Amount.Amount, "109.50"; got != want {
t.Fatalf("base amount.value mismatch: got=%q want=%q", got, want)
}
if got, want := op.Amount.Currency, "USDT"; got != want {
if got, want := op.Money.Executed.Amount.Currency, "USDT"; got != want {
t.Fatalf("base amount.currency mismatch: got=%q want=%q", got, want)
}
if op.ConvertedAmount == nil {
if op.Money.Executed.ConvertedAmount == nil {
t.Fatal("expected fx quote amount to be mapped")
}
if got, want := op.ConvertedAmount.Amount, "100.00"; got != want {
if got, want := op.Money.Executed.ConvertedAmount.Amount, "100.00"; got != want {
t.Fatalf("converted amount.value mismatch: got=%q want=%q", got, want)
}
if got, want := op.ConvertedAmount.Currency, "EUR"; got != want {
if got, want := op.Money.Executed.ConvertedAmount.Currency, "EUR"; got != want {
t.Fatalf("converted amount.currency mismatch: got=%q want=%q", got, want)
}
}