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

@@ -51,7 +51,7 @@ require (
github.com/docker/go-connections v0.5.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-chi/chi/v5 v5.2.4 // indirect
github.com/go-chi/chi/v5 v5.2.5 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect

View File

@@ -43,8 +43,8 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-chi/chi/v5 v5.2.4 h1:WtFKPHwlywe8Srng8j2BhOD9312j9cGUxG1SP4V2cR4=
github.com/go-chi/chi/v5 v5.2.4/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=

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,

View File

@@ -253,6 +253,7 @@ type Customer struct {
// PaymentQuoteSnapshot stores the latest quote info.
type PaymentQuoteSnapshot struct {
DebitAmount *paymenttypes.Money `bson:"debitAmount,omitempty" json:"debitAmount,omitempty"`
DebitSettlementAmount *paymenttypes.Money `bson:"debitSettlementAmount,omitempty" json:"debitSettlementAmount,omitempty"`
ExpectedSettlementAmount *paymenttypes.Money `bson:"expectedSettlementAmount,omitempty" json:"expectedSettlementAmount,omitempty"`
ExpectedFeeTotal *paymenttypes.Money `bson:"expectedFeeTotal,omitempty" json:"expectedFeeTotal,omitempty"`
FeeLines []*paymenttypes.FeeLine `bson:"feeLines,omitempty" json:"feeLines,omitempty"`
@@ -326,6 +327,10 @@ func (s *ExecutionStep) IsSuccess() bool {
return s.State == OperationStateSuccess
}
func (s *ExecutionStep) IsFailed() bool {
return s.State == OperationStateFailed
}
func (s *ExecutionStep) ReadyForNext() bool {
switch s.State {
case OperationStateSuccess,