Added debit settlement amount calculation

This commit is contained in:
Stephan D
2026-02-06 17:50:11 +01:00
parent 17bc2a2a62
commit c8b8b1183b
44 changed files with 200 additions and 242 deletions

View File

@@ -112,6 +112,7 @@ func quoteSnapshotToModel(src *orchestratorv1.PaymentQuote) *model.PaymentQuoteS
}
return &model.PaymentQuoteSnapshot{
DebitAmount: moneyFromProto(src.GetDebitAmount()),
DebitSettlementAmount: moneyFromProto(src.GetDebitSettlementAmount()),
ExpectedSettlementAmount: moneyFromProto(src.GetExpectedSettlementAmount()),
ExpectedFeeTotal: moneyFromProto(src.GetExpectedFeeTotal()),
FeeLines: feeLinesFromProto(src.GetFeeLines()),
@@ -389,6 +390,7 @@ func modelQuoteToProto(src *model.PaymentQuoteSnapshot) *orchestratorv1.PaymentQ
}
return &orchestratorv1.PaymentQuote{
DebitAmount: protoMoney(src.DebitAmount),
DebitSettlementAmount: protoMoney(src.DebitSettlementAmount),
ExpectedSettlementAmount: protoMoney(src.ExpectedSettlementAmount),
ExpectedFeeTotal: protoMoney(src.ExpectedFeeTotal),
FeeLines: feeLinesToProto(src.FeeLines),

View File

@@ -0,0 +1,123 @@
package orchestrator
import (
"errors"
"github.com/tech/sendico/payments/orchestrator/storage/model"
"github.com/tech/sendico/pkg/mlogger"
"go.uber.org/zap"
)
type Liveness string
const (
StepFinal Liveness = "final"
StepRunnable Liveness = "runnable"
StepBlocked Liveness = "blocked"
StepDead Liveness = "dead"
)
func buildPaymentStepIndex(plan *model.PaymentPlan) map[string]*model.PaymentStep {
idx := make(map[string]*model.PaymentStep, len(plan.Steps))
for _, s := range plan.Steps {
idx[s.StepID] = s
}
return idx
}
func buildExecutionStepIndex(plan *model.ExecutionPlan) map[string]*model.ExecutionStep {
index := make(map[string]*model.ExecutionStep, len(plan.Steps))
for _, s := range plan.Steps {
if s == nil {
continue
}
index[s.Code] = s
}
return index
}
func stepLiveness(
logger mlogger.Logger,
step *model.ExecutionStep,
pStepIdx map[string]*model.PaymentStep,
eStepIdx map[string]*model.ExecutionStep,
) Liveness {
if step.IsTerminal() {
return StepFinal
}
pStep, ok := pStepIdx[step.Code]
if !ok {
logger.Error("step missing in payment plan",
zap.String("step_id", step.Code),
)
return StepDead
}
for _, depID := range pStep.DependsOn {
dep := eStepIdx[depID]
if dep == nil {
logger.Warn("dependency missing in execution plan",
zap.String("step_id", step.Code),
zap.String("dep_id", depID),
)
continue
}
switch dep.State {
case model.OperationStateFailed:
return StepDead
}
}
allSuccess := true
for _, depID := range pStep.DependsOn {
dep := eStepIdx[depID]
if dep == nil || dep.State != model.OperationStateSuccess {
allSuccess = false
break
}
}
if allSuccess {
return StepRunnable
}
return StepBlocked
}
func analyzeExecutionPlan(
logger mlogger.Logger,
payment *model.Payment,
) (bool, bool, error) {
if payment == nil || payment.ExecutionPlan == nil {
return true, false, nil
}
eIdx := buildExecutionStepIndex(payment.ExecutionPlan)
pIdx := buildPaymentStepIndex(payment.PaymentPlan)
hasRunnable := false
hasFailed := false
var rootErr error
for _, s := range payment.ExecutionPlan.Steps {
live := stepLiveness(logger, s, pIdx, eIdx)
if live == StepRunnable {
hasRunnable = true
}
if s.State == model.OperationStateFailed {
hasFailed = true
if rootErr == nil && s.Error != "" {
rootErr = errors.New(s.Error)
}
}
}
done := !hasRunnable
return done, hasFailed, rootErr
}

View File

@@ -2,7 +2,6 @@ package orchestrator
import (
"context"
"errors"
"github.com/tech/sendico/payments/orchestrator/storage"
"github.com/tech/sendico/payments/orchestrator/storage/model"
@@ -11,31 +10,6 @@ import (
"go.uber.org/zap"
)
func analyzeExecutionPlan(plan *model.ExecutionPlan) (done bool, failed bool, rootErr error) {
if plan == nil || len(plan.Steps) == 0 {
return true, false, nil
}
done = true
for _, s := range plan.Steps {
if s == nil {
continue
}
if s.State == model.OperationStateFailed {
failed = true
if rootErr == nil && s.Error != "" {
rootErr = errors.New(s.Error)
}
}
if !isStepFinal(s) { // created/waiting/processing
done = false
}
}
return done, failed, rootErr
}
func buildStepIndex(plan *model.PaymentPlan) map[string]int {
m := make(map[string]int, len(plan.Steps))
for i, s := range plan.Steps {
@@ -63,64 +37,6 @@ func isStepFinal(step *model.ExecutionStep) bool {
return false
}
func stepCodeIsDependent(code string, previousSteps []string) bool {
for _, ps := range previousSteps {
if ps == code {
return true
}
}
return false
}
func stepIsIndependent(
step *model.ExecutionStep,
plan *model.PaymentPlan,
execSteps map[string]*model.ExecutionStep,
) bool {
for _, s := range plan.Steps {
if s.StepID != step.Code {
continue
}
// Do not process step if it is already in a final state
if isStepFinal(step) {
return false
}
// If the step has no dependencies, it is independent
if len(s.DependsOn) == 0 {
return true
}
// All dependent steps must be successfully completed
for _, dep := range s.DependsOn {
depStep := execSteps[dep]
if depStep == nil || depStep.State != model.OperationStateSuccess {
return false
}
}
return true
}
return false
}
func planStep(execStep *model.ExecutionStep, plan *model.PaymentPlan) *model.PaymentStep {
if (execStep == nil) || (plan == nil) {
return nil
}
for _, step := range plan.Steps {
if step != nil {
if step.StepID == execStep.Code {
return step
}
}
}
return nil
}
func (p *paymentExecutor) pickIndependentSteps(
ctx context.Context,
l *zap.Logger,
@@ -262,14 +178,13 @@ func (p *paymentExecutor) executePaymentPlan(
// Execute steps
if err := p.pickWaitingSteps(ctx, logger, store, payment, quote); err != nil {
logger.Warn("Step execution returned infrastructure error", zap.Error(err))
return err
}
if err := store.Update(ctx, payment); err != nil {
return err
}
done, failed, rootErr := analyzeExecutionPlan(payment.ExecutionPlan)
done, failed, rootErr := analyzeExecutionPlan(logger, payment)
if !done {
return nil
}

View File

@@ -1,74 +1,12 @@
package orchestrator
import (
"sort"
"strings"
"github.com/tech/sendico/payments/orchestrator/storage/model"
"github.com/tech/sendico/pkg/merrors"
)
func planExecutionOrder(plan *model.PaymentPlan) ([]int, map[string]int, error) {
if plan == nil || len(plan.Steps) == 0 {
return nil, nil, merrors.InvalidArgument("payment plan: steps are required")
}
idToIndex := map[string]int{}
for idx, step := range plan.Steps {
if step == nil {
return nil, nil, merrors.InvalidArgument("payment plan: step is required")
}
id := planStepID(step, idx)
if _, exists := idToIndex[id]; exists {
return nil, nil, merrors.InvalidArgument("payment plan: duplicate step id")
}
idToIndex[id] = idx
}
indegree := make([]int, len(plan.Steps))
adj := make([][]int, len(plan.Steps))
for idx, step := range plan.Steps {
for _, dep := range step.DependsOn {
key := strings.TrimSpace(dep)
if key == "" {
continue
}
depIdx, ok := idToIndex[key]
if !ok {
return nil, nil, merrors.InvalidArgument("payment plan: dependency missing")
}
adj[depIdx] = append(adj[depIdx], idx)
indegree[idx]++
}
}
queue := make([]int, 0, len(plan.Steps))
for idx := range indegree {
if indegree[idx] == 0 {
queue = append(queue, idx)
}
}
sort.Ints(queue)
order := make([]int, 0, len(plan.Steps))
for len(queue) > 0 {
current := queue[0]
queue = queue[1:]
order = append(order, current)
for _, next := range adj[current] {
indegree[next]--
if indegree[next] == 0 {
queue = append(queue, next)
}
}
sort.Ints(queue)
}
if len(order) != len(plan.Steps) {
return nil, nil, merrors.InvalidArgument("payment plan: dependency cycle detected")
}
return order, idToIndex, nil
}
func executionStepsByCode(plan *model.ExecutionPlan) map[string]*model.ExecutionStep {
result := map[string]*model.ExecutionStep{}
if plan == nil {
@@ -115,9 +53,6 @@ func stepDependenciesReady(
merrors.InvalidArgument("payment plan: step is required")
}
// ------------------------------------------------------------
// DependsOn — это ПРОСТО готовность, не успех
// ------------------------------------------------------------
for _, dep := range step.DependsOn {
key := strings.TrimSpace(dep)
if key == "" {
@@ -126,18 +61,18 @@ func stepDependenciesReady(
execStep := execSteps[key]
if execStep == nil {
// шага ещё не было → ждём
// step has not been started
return false, true, false, nil
}
if execStep.State == model.OperationStateFailed ||
execStep.State == model.OperationStateCancelled {
// зависимость умерла → этот шаг уже невозможен
// dependency dead, step is impossible
return false, false, true, nil
}
if !execStep.ReadyForNext() {
// шаг ещё в процессе → ждём
// step is processed
return false, true, false, nil
}
}
@@ -203,11 +138,11 @@ func stepDependenciesReady(
}
if execStep.IsTerminal() {
// завершился не фейлом → блокируем
// complete with fail, block
return false, false, true, nil
}
// ещё выполняется → ждём
// still exexuting, wait
return false, true, false, nil
}
@@ -243,42 +178,6 @@ func stepDependenciesReady(
}
}
func commitAfterDependenciesSucceeded(step *model.PaymentStep, execSteps map[string]*model.ExecutionStep) bool {
if step == nil {
return false
}
commitAfter := step.CommitAfter
if len(commitAfter) == 0 {
commitAfter = step.DependsOn
}
if len(commitAfter) == 0 {
return false
}
for _, dep := range commitAfter {
key := strings.TrimSpace(dep)
if key == "" {
continue
}
execStep := execSteps[key]
if execStep == nil {
return false
}
switch execStep.State {
case model.OperationStateSuccess,
model.OperationStateSkipped:
continue
default:
return false
}
}
return true
}
func cardPayoutDependenciesConfirmed(
plan *model.PaymentPlan,
execPlan *model.ExecutionPlan,

View File

@@ -38,8 +38,8 @@ func (p *paymentExecutor) executePlanStep(
logger.Debug("Executing payment plan step")
if isStepFinal(execStep) {
logger.Debug("Step already in final state, skipping execution",
if execStep.IsTerminal() {
logger.Debug("Step already in terminal state, skipping execution",
zap.String("state", string(execStep.State)),
)
return false, nil
@@ -282,18 +282,21 @@ func (p *paymentExecutor) executeSendStep(
case model.RailProviderSettlement:
logger.Debug("Preparing provider settlement transfer")
amount, err := requireMoney(cloneMoney(payment.Intent.Amount), "provider settlement amount")
amount, err := requireMoney(cloneMoney(payment.LastQuote.DebitSettlementAmount), "provider settlement amount")
if err != nil {
logger.Warn("Invalid provider settlement amount", zap.Error(err))
logger.Warn("Invalid provider settlement amount", zap.Error(err), zap.Any("settlement", payment.LastQuote.DebitSettlementAmount))
return false, err
}
logger.Debug("Expected settlement amount", zap.String("amount", amount.Amount), zap.String("currency", amount.Currency))
fee, err := requireMoney(cloneMoney(payment.LastQuote.ExpectedFeeTotal), "provider settlement amount")
if err != nil {
logger.Warn("Invalid provider settlement amount", zap.Error(err))
logger.Warn("Invalid fee settlement amount", zap.Error(err))
return false, err
}
if fee.Currency != amount.Currency {
logger.Warn("Fee and amount currencies do not match")
logger.Warn("Fee and amount currencies do not match",
zap.String("amount_currency", amount.Currency), zap.String("fee_currency", fee.Currency),
)
return false, merrors.DataConflict("settlement payment: currencies mismatch")
}

View File

@@ -96,6 +96,7 @@ func (s *Service) buildPaymentQuote(ctx context.Context, orgRef string, req *orc
quote := &orchestratorv1.PaymentQuote{
DebitAmount: debitAmount,
DebitSettlementAmount: payAmount,
ExpectedSettlementAmount: settlementAmount,
ExpectedFeeTotal: feeTotal,
FeeLines: feeLines,