Fully separated payment quotation and orchestration flows

This commit is contained in:
Stephan D
2026-02-11 17:25:44 +01:00
parent 9b8f59e05a
commit e116535926
112 changed files with 3204 additions and 8686 deletions

View File

@@ -0,0 +1,164 @@
package execution
import (
"strings"
"github.com/tech/sendico/payments/storage/model"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
)
const (
executionStepMetadataRole = "role"
executionStepMetadataStatus = "status"
executionStepRoleSource = "source"
executionStepRoleConsumer = "consumer"
executionStepCodeCardPayout = "card_payout"
)
func setExecutionStepRole(step *model.ExecutionStep, role string) {
role = strings.ToLower(strings.TrimSpace(role))
setExecutionStepMetadata(step, executionStepMetadataRole, role)
}
func setExecutionStepStatus(step *model.ExecutionStep, state model.OperationState) {
step.State = state
setExecutionStepMetadata(step, executionStepMetadataStatus, string(state))
}
func executionStepRole(step *model.ExecutionStep) string {
if step == nil {
return ""
}
if role := strings.TrimSpace(step.Metadata[executionStepMetadataRole]); role != "" {
return strings.ToLower(role)
}
if strings.EqualFold(step.Code, executionStepCodeCardPayout) {
return executionStepRoleConsumer
}
return executionStepRoleSource
}
func isSourceExecutionStep(step *model.ExecutionStep) bool {
return executionStepRole(step) == executionStepRoleSource
}
func sourceStepsConfirmed(plan *model.ExecutionPlan) bool {
if plan == nil || len(plan.Steps) == 0 {
return false
}
hasSource := false
for _, step := range plan.Steps {
if step == nil || !isSourceExecutionStep(step) {
continue
}
if step.State == model.OperationStateSkipped {
continue
}
hasSource = true
if step.State != model.OperationStateSuccess {
return false
}
}
return hasSource
}
func findExecutionStepByTransferRef(plan *model.ExecutionPlan, transferRef string) *model.ExecutionStep {
if plan == nil {
return nil
}
transferRef = strings.TrimSpace(transferRef)
if transferRef == "" {
return nil
}
for _, step := range plan.Steps {
if step == nil {
continue
}
if strings.EqualFold(strings.TrimSpace(step.TransferRef), transferRef) {
return step
}
}
return nil
}
func updateExecutionStepFromTransfer(plan *model.ExecutionPlan, event *chainv1.TransferStatusChangedEvent) *model.ExecutionStep {
if plan == nil || event == nil || event.GetTransfer() == nil {
return nil
}
transfer := event.GetTransfer()
transferRef := strings.TrimSpace(transfer.GetTransferRef())
if transferRef == "" {
return nil
}
if status := executionStepStatusFromTransferStatus(transfer.GetStatus()); status != "" {
var updated *model.ExecutionStep
for _, step := range plan.Steps {
if step == nil {
continue
}
if !strings.EqualFold(strings.TrimSpace(step.TransferRef), transferRef) {
continue
}
if step.TransferRef == "" {
step.TransferRef = transferRef
}
setExecutionStepStatus(step, status)
if updated == nil {
updated = step
}
}
return updated
}
return nil
}
func executionStepStatusFromTransferStatus(status chainv1.TransferStatus) model.OperationState {
switch status {
case chainv1.TransferStatus_TRANSFER_CREATED:
return model.OperationStatePlanned
case chainv1.TransferStatus_TRANSFER_PROCESSING:
return model.OperationStateProcessing
case chainv1.TransferStatus_TRANSFER_WAITING:
return model.OperationStateWaiting
case chainv1.TransferStatus_TRANSFER_SUCCESS:
return model.OperationStateSuccess
case chainv1.TransferStatus_TRANSFER_FAILED:
return model.OperationStateFailed
case chainv1.TransferStatus_TRANSFER_CANCELLED:
return model.OperationStateCancelled
default:
return model.OperationStatePlanned
}
}
func setExecutionStepMetadata(step *model.ExecutionStep, key, value string) {
if step == nil {
return
}
key = strings.TrimSpace(key)
if key == "" {
return
}
value = strings.TrimSpace(value)
if value == "" {
if step.Metadata != nil {
delete(step.Metadata, key)
if len(step.Metadata) == 0 {
step.Metadata = nil
}
}
return
}
if step.Metadata == nil {
step.Metadata = map[string]string{}
}
step.Metadata[key] = value
}

View File

@@ -0,0 +1,123 @@
package execution
import (
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model/account_role"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
)
const (
ExecutionStepRoleSource = executionStepRoleSource
ExecutionStepRoleConsumer = executionStepRoleConsumer
)
func SetExecutionStepRole(step *model.ExecutionStep, role string) {
setExecutionStepRole(step, role)
}
func SetExecutionStepStatus(step *model.ExecutionStep, state model.OperationState) {
setExecutionStepStatus(step, state)
}
func ExecutionStepRole(step *model.ExecutionStep) string {
return executionStepRole(step)
}
func IsSourceExecutionStep(step *model.ExecutionStep) bool {
return isSourceExecutionStep(step)
}
func SourceStepsConfirmed(plan *model.ExecutionPlan) bool {
return sourceStepsConfirmed(plan)
}
func FindExecutionStepByTransferRef(plan *model.ExecutionPlan, transferRef string) *model.ExecutionStep {
return findExecutionStepByTransferRef(plan, transferRef)
}
func UpdateExecutionStepFromTransfer(plan *model.ExecutionPlan, event *chainv1.TransferStatusChangedEvent) *model.ExecutionStep {
return updateExecutionStepFromTransfer(plan, event)
}
func ExecutionStepStatusFromTransferStatus(status chainv1.TransferStatus) model.OperationState {
return executionStepStatusFromTransferStatus(status)
}
func SetExecutionStepMetadata(step *model.ExecutionStep, key, value string) {
setExecutionStepMetadata(step, key, value)
}
func EnsureExecutionRefs(payment *model.Payment) *model.ExecutionRefs {
return ensureExecutionRefs(payment)
}
func ExecutionQuote(
payment *model.Payment,
quote *orchestratorv1.PaymentQuote,
quoteFromSnapshot func(*model.PaymentQuoteSnapshot) *orchestratorv1.PaymentQuote,
) *orchestratorv1.PaymentQuote {
return executionQuote(payment, quote, quoteFromSnapshot)
}
func EnsureExecutionPlanForPlan(payment *model.Payment, plan *model.PaymentPlan) *model.ExecutionPlan {
return ensureExecutionPlanForPlan(payment, plan)
}
func ExecutionPlanComplete(plan *model.ExecutionPlan) bool {
return executionPlanComplete(plan)
}
func BlockStepConfirmed(plan *model.PaymentPlan, execPlan *model.ExecutionPlan) bool {
return blockStepConfirmed(plan, execPlan)
}
func RoleHintsForStep(plan *model.PaymentPlan, idx int) (*account_role.AccountRole, *account_role.AccountRole) {
return roleHintsForStep(plan, idx)
}
func LinkRailObservation(payment *model.Payment, rail model.Rail, referenceID, dependsOn string) {
linkRailObservation(payment, rail, referenceID, dependsOn)
}
func PlanStepID(step *model.PaymentStep, idx int) string {
return planStepID(step, idx)
}
func DescribePlanStep(step *model.PaymentStep) string {
return describePlanStep(step)
}
func PlanStepIdempotencyKey(payment *model.Payment, idx int, step *model.PaymentStep) string {
return planStepIdempotencyKey(payment, idx, step)
}
func FailureCodeForStep(step *model.PaymentStep) model.PaymentFailureCode {
return failureCodeForStep(step)
}
func ExecutionStepsByCode(plan *model.ExecutionPlan) map[string]*model.ExecutionStep {
return executionStepsByCode(plan)
}
func PlanStepsByID(plan *model.PaymentPlan) map[string]*model.PaymentStep {
return planStepsByID(plan)
}
func StepDependenciesReady(
step *model.PaymentStep,
execSteps map[string]*model.ExecutionStep,
planSteps map[string]*model.PaymentStep,
requireSuccess bool,
) (ready bool, waiting bool, blocked bool, err error) {
return stepDependenciesReady(step, execSteps, planSteps, requireSuccess)
}
func CardPayoutDependenciesConfirmed(plan *model.PaymentPlan, execPlan *model.ExecutionPlan) bool {
return cardPayoutDependenciesConfirmed(plan, execPlan)
}
func AnalyzeExecutionPlan(logger mlogger.Logger, payment *model.Payment) (bool, bool, error) {
return analyzeExecutionPlan(logger, payment)
}

View File

@@ -0,0 +1,123 @@
package execution
import (
"errors"
"github.com/tech/sendico/payments/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

@@ -0,0 +1,219 @@
package execution
import (
"fmt"
"strings"
"github.com/google/uuid"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/model/account_role"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
)
func ensureExecutionRefs(payment *model.Payment) *model.ExecutionRefs {
if payment.Execution == nil {
payment.Execution = &model.ExecutionRefs{}
}
return payment.Execution
}
func executionQuote(
payment *model.Payment,
quote *orchestratorv1.PaymentQuote,
quoteFromSnapshot func(*model.PaymentQuoteSnapshot) *orchestratorv1.PaymentQuote,
) *orchestratorv1.PaymentQuote {
if quote != nil {
return quote
}
if payment != nil && payment.LastQuote != nil && quoteFromSnapshot != nil {
return quoteFromSnapshot(payment.LastQuote)
}
return &orchestratorv1.PaymentQuote{}
}
func ensureExecutionPlanForPlan(
payment *model.Payment,
plan *model.PaymentPlan,
) *model.ExecutionPlan {
if payment.ExecutionPlan != nil {
return payment.ExecutionPlan
}
exec := &model.ExecutionPlan{
Steps: make([]*model.ExecutionStep, 0, len(plan.Steps)),
}
for _, step := range plan.Steps {
if step == nil {
continue
}
exec.Steps = append(exec.Steps, &model.ExecutionStep{
Code: step.StepID,
State: model.OperationStatePlanned,
OperationRef: uuid.New().String(),
})
}
return exec
}
func executionPlanComplete(plan *model.ExecutionPlan) bool {
if plan == nil || len(plan.Steps) == 0 {
return false
}
for _, step := range plan.Steps {
if step == nil {
continue
}
if step.State == model.OperationStateSkipped {
continue
}
if step.State != model.OperationStateSuccess {
return false
}
}
return true
}
func blockStepConfirmed(plan *model.PaymentPlan, execPlan *model.ExecutionPlan) bool {
if plan == nil || execPlan == nil || len(plan.Steps) == 0 {
return false
}
execSteps := executionStepsByCode(execPlan)
for idx, step := range plan.Steps {
if step == nil || step.Action != model.RailOperationBlock {
continue
}
execStep := execSteps[planStepID(step, idx)]
if execStep == nil {
continue
}
if execStep.State == model.OperationStateSuccess {
return true
}
}
return false
}
func roleHintsForStep(plan *model.PaymentPlan, idx int) (*account_role.AccountRole, *account_role.AccountRole) {
if plan == nil || idx <= 0 {
return nil, nil
}
for i := idx - 1; i >= 0; i-- {
step := plan.Steps[i]
if step == nil {
continue
}
if step.Rail != model.RailLedger || step.Action != model.RailOperationMove {
continue
}
if step.ToRole != nil && strings.TrimSpace(string(*step.ToRole)) != "" {
role := *step.ToRole
return &role, nil
}
}
return nil, nil
}
func linkRailObservation(payment *model.Payment, rail model.Rail, referenceID, dependsOn string) {
if payment == nil || payment.PaymentPlan == nil {
return
}
ref := strings.TrimSpace(referenceID)
if ref == "" {
return
}
plan := payment.PaymentPlan
execPlan := ensureExecutionPlanForPlan(payment, plan)
if execPlan == nil {
return
}
dep := strings.TrimSpace(dependsOn)
for idx, planStep := range plan.Steps {
if planStep == nil {
continue
}
if planStep.Rail != rail || planStep.Action != model.RailOperationObserveConfirm {
continue
}
if dep != "" {
matched := false
for _, entry := range planStep.DependsOn {
if strings.EqualFold(strings.TrimSpace(entry), dep) {
matched = true
break
}
}
if !matched {
continue
}
}
if idx >= len(execPlan.Steps) {
continue
}
execStep := execPlan.Steps[idx]
if execStep == nil {
execStep = &model.ExecutionStep{Code: planStepID(planStep, idx), Description: describePlanStep(planStep)}
execPlan.Steps[idx] = execStep
}
if execStep.TransferRef == "" {
execStep.TransferRef = ref
}
}
}
func planStepID(step *model.PaymentStep, idx int) string {
if step != nil {
if val := strings.TrimSpace(step.StepID); val != "" {
return val
}
}
return fmt.Sprintf("plan_step_%d", idx)
}
func describePlanStep(step *model.PaymentStep) string {
if step == nil {
return ""
}
return strings.TrimSpace(fmt.Sprintf("%s %s", step.Rail, step.Action))
}
func planStepIdempotencyKey(payment *model.Payment, idx int, step *model.PaymentStep) string {
base := ""
if payment != nil {
base = strings.TrimSpace(payment.IdempotencyKey)
if base == "" {
base = strings.TrimSpace(payment.PaymentRef)
}
}
if base == "" {
base = "payment"
}
if step == nil {
return fmt.Sprintf("%s:plan:%d", base, idx)
}
stepID := strings.TrimSpace(step.StepID)
if stepID == "" {
stepID = fmt.Sprintf("%d", idx)
}
return fmt.Sprintf("%s:plan:%s:%s:%s", base, stepID, strings.ToLower(string(step.Rail)), strings.ToLower(string(step.Action)))
}
func failureCodeForStep(step *model.PaymentStep) model.PaymentFailureCode {
if step == nil {
return model.PaymentFailureCodePolicy
}
switch step.Rail {
case model.RailLedger:
if step.Action == model.RailOperationFXConvert {
return model.PaymentFailureCodeFX
}
return model.PaymentFailureCodeLedger
case model.RailCrypto:
return model.PaymentFailureCodeChain
default:
return model.PaymentFailureCodePolicy
}
}

View File

@@ -0,0 +1,226 @@
package execution
import (
"strings"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/merrors"
)
func executionStepsByCode(plan *model.ExecutionPlan) map[string]*model.ExecutionStep {
result := map[string]*model.ExecutionStep{}
if plan == nil {
return result
}
for _, step := range plan.Steps {
if step == nil {
continue
}
if code := strings.TrimSpace(step.Code); code != "" {
result[code] = step
}
}
return result
}
func planStepsByID(plan *model.PaymentPlan) map[string]*model.PaymentStep {
result := map[string]*model.PaymentStep{}
if plan == nil {
return result
}
for idx, step := range plan.Steps {
if step == nil {
continue
}
id := planStepID(step, idx)
if id == "" {
continue
}
result[id] = step
}
return result
}
func stepDependenciesReady(
step *model.PaymentStep,
execSteps map[string]*model.ExecutionStep,
planSteps map[string]*model.PaymentStep,
requireSuccess bool,
) (ready bool, waiting bool, blocked bool, err error) {
if step == nil {
return false, false, false,
merrors.InvalidArgument("payment plan: step is required")
}
for _, dep := range step.DependsOn {
key := strings.TrimSpace(dep)
if key == "" {
continue
}
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
}
}
// ------------------------------------------------------------
// Commit policies
// ------------------------------------------------------------
switch step.CommitPolicy {
case model.CommitPolicyImmediate, model.CommitPolicyUnspecified:
return true, false, false, nil
case model.CommitPolicyAfterSuccess:
commitAfter := step.CommitAfter
if len(commitAfter) == 0 {
commitAfter = step.DependsOn
}
for _, dep := range commitAfter {
key := strings.TrimSpace(dep)
if key == "" {
continue
}
execStep := execSteps[key]
if execStep == nil {
return false, true, false,
merrors.InvalidArgument("commit dependency missing")
}
if execStep.State == model.OperationStateFailed ||
execStep.State == model.OperationStateCancelled {
return false, false, true, nil
}
if !execStep.IsSuccess() {
return false, true, false, nil
}
}
return true, false, false, nil
case model.CommitPolicyAfterFailure:
commitAfter := step.CommitAfter
if len(commitAfter) == 0 {
commitAfter = step.DependsOn
}
for _, dep := range commitAfter {
key := strings.TrimSpace(dep)
if key == "" {
continue
}
execStep := execSteps[key]
if execStep == nil {
return false, true, false,
merrors.InvalidArgument("commit dependency missing")
}
if execStep.State == model.OperationStateFailed {
continue
}
if execStep.IsTerminal() {
// complete with fail, block
return false, false, true, nil
}
// still exexuting, wait
return false, true, false, nil
}
return true, false, false, nil
case model.CommitPolicyAfterCanceled:
commitAfter := step.CommitAfter
if len(commitAfter) == 0 {
commitAfter = step.DependsOn
}
for _, dep := range commitAfter {
key := strings.TrimSpace(dep)
if key == "" {
continue
}
execStep := execSteps[key]
if execStep == nil {
return false, true, false,
merrors.InvalidArgument("commit dependency missing")
}
if !execStep.IsTerminal() {
return false, true, false, nil
}
}
return true, false, false, nil
default:
return true, false, false, nil
}
}
func cardPayoutDependenciesConfirmed(
plan *model.PaymentPlan,
execPlan *model.ExecutionPlan,
) bool {
if execPlan == nil {
return false
}
if plan == nil || len(plan.Steps) == 0 {
return sourceStepsConfirmed(execPlan)
}
execSteps := executionStepsByCode(execPlan)
planSteps := planStepsByID(plan)
for _, step := range plan.Steps {
if step == nil {
continue
}
if step.Rail != model.RailCardPayout ||
step.Action != model.RailOperationSend {
continue
}
ready, waiting, blocked, err :=
stepDependenciesReady(step, execSteps, planSteps, true)
if err != nil || blocked {
// payout definitely cannot run
return false
}
if waiting {
// dependencies exist but are not finished yet
// payout must NOT run
return false
}
// only true when dependencies are REALLY satisfied
return ready
}
return false
}