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

@@ -64,7 +64,7 @@ require (
golang.org/x/net v0.51.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
golang.org/x/text v0.35.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c // indirect
google.golang.org/protobuf v1.36.11 // indirect
)

View File

@@ -249,8 +249,8 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
@@ -258,8 +258,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c h1:xgCzyF2LFIO/0X2UAoVRiXKU5Xg6VjToG4i2/ecSswk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU=
google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=

View File

@@ -49,7 +49,7 @@ require (
golang.org/x/net v0.51.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
golang.org/x/text v0.35.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c // indirect
google.golang.org/protobuf v1.36.11
)

View File

@@ -199,8 +199,8 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
@@ -208,8 +208,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c h1:xgCzyF2LFIO/0X2UAoVRiXKU5Xg6VjToG4i2/ecSswk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU=
google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=

View File

@@ -42,8 +42,8 @@ require (
golang.org/x/net v0.51.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
golang.org/x/text v0.35.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c // indirect
google.golang.org/grpc v1.79.2 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)

View File

@@ -199,8 +199,8 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
@@ -208,8 +208,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c h1:xgCzyF2LFIO/0X2UAoVRiXKU5Xg6VjToG4i2/ecSswk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU=
google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=

View File

@@ -157,7 +157,7 @@ require (
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/text v0.35.0 // indirect
golang.org/x/time v0.15.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c // indirect
)

View File

@@ -380,8 +380,8 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -401,8 +401,8 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 h1:tu/dtnW1o3wfaxCOjSLn5IRX4YDcJrtlpzYkhHhGaC4=
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171/go.mod h1:M5krXqk4GhBKvB596udGL3UyjL4I1+cTbK0orROM9ng=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c h1:xgCzyF2LFIO/0X2UAoVRiXKU5Xg6VjToG4i2/ecSswk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU=
google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=

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)
}
}

View File

@@ -56,9 +56,9 @@ require (
golang.org/x/net v0.51.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/text v0.35.0 // indirect
golang.org/x/time v0.15.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c // indirect
google.golang.org/grpc v1.79.2 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)

View File

@@ -232,8 +232,8 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -243,8 +243,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c h1:xgCzyF2LFIO/0X2UAoVRiXKU5Xg6VjToG4i2/ecSswk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU=
google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=

View File

@@ -46,8 +46,8 @@ require (
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
golang.org/x/text v0.35.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c // indirect
google.golang.org/grpc v1.79.2 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)

View File

@@ -199,8 +199,8 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
@@ -208,8 +208,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c h1:xgCzyF2LFIO/0X2UAoVRiXKU5Xg6VjToG4i2/ecSswk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU=
google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=

View File

@@ -47,6 +47,6 @@ require (
golang.org/x/net v0.51.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
golang.org/x/text v0.35.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c // indirect
)

View File

@@ -199,8 +199,8 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
@@ -208,8 +208,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c h1:xgCzyF2LFIO/0X2UAoVRiXKU5Xg6VjToG4i2/ecSswk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU=
google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=

View File

@@ -25,6 +25,6 @@ require (
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/text v0.35.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)

View File

@@ -160,8 +160,8 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=

View File

@@ -50,6 +50,6 @@ require (
golang.org/x/net v0.51.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
golang.org/x/text v0.35.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c // indirect
)

View File

@@ -201,8 +201,8 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
@@ -210,8 +210,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c h1:xgCzyF2LFIO/0X2UAoVRiXKU5Xg6VjToG4i2/ecSswk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU=
google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=

View File

@@ -32,12 +32,12 @@ require (
github.com/casbin/mongodb-adapter/v4 v4.3.0 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/consensys/gnark-crypto v0.19.2 // indirect
github.com/consensys/gnark-crypto v0.20.0 // indirect
github.com/crate-crypto/go-eth-kzg v1.5.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/deckarep/golang-set/v2 v2.8.0 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect
github.com/ethereum/c-kzg-4844/v2 v2.1.6 // indirect
github.com/ethereum/c-kzg-4844/v2 v2.1.7 // indirect
github.com/go-chi/chi/v5 v5.2.5 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-logr/logr v1.4.3 // indirect
@@ -89,7 +89,7 @@ require (
golang.org/x/net v0.51.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/text v0.35.0 // indirect
golang.org/x/time v0.15.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c // indirect
)

View File

@@ -40,8 +40,8 @@ github.com/cockroachdb/redact v1.1.5 h1:u1PMllDkdFfPWaNGMyLD1+so+aq3uUItthCFqzwP
github.com/cockroachdb/redact v1.1.5/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg=
github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 h1:zuQyyAKVxetITBuuhv3BI9cMrmStnpT18zmgmTxunpo=
github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06/go.mod h1:7nc4anLGjupUW/PeY5qiNYsdNXj7zopG+eqsS7To5IQ=
github.com/consensys/gnark-crypto v0.19.2 h1:qrEAIXq3T4egxqiliFFoNrepkIWVEeIYwt3UL0fvS80=
github.com/consensys/gnark-crypto v0.19.2/go.mod h1:rT23F0XSZqE0mUA0+pRtnL56IbPxs6gp4CeRsBk4XS0=
github.com/consensys/gnark-crypto v0.20.0 h1:dJmv2sC9KWV/cNRjMjy2S0h7emfyyX8eSsJzwk0DQzw=
github.com/consensys/gnark-crypto v0.20.0/go.mod h1:RBWrSgy+IDbGR69RRV313th3M/aZU1ubk2om+qHuTSc=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
@@ -72,8 +72,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/emicklei/dot v1.6.2 h1:08GN+DD79cy/tzN6uLCT84+2Wk9u+wvqP+Hkx/dIR8A=
github.com/emicklei/dot v1.6.2/go.mod h1:DeV7GvQtIw4h2u73RKBkkFdvVAz0D9fzeJrgPW6gy/s=
github.com/ethereum/c-kzg-4844/v2 v2.1.6 h1:xQymkKCT5E2Jiaoqf3v4wsNgjZLY0lRSkZn27fRjSls=
github.com/ethereum/c-kzg-4844/v2 v2.1.6/go.mod h1:8HMkUZ5JRv4hpw/XUrYWSQNAUzhHMg2UDb/U+5m+XNw=
github.com/ethereum/c-kzg-4844/v2 v2.1.7 h1:aat3CuITdDbPC6pmEGRT0zJ5eOxzrZj8TJT5z7Xk//M=
github.com/ethereum/c-kzg-4844/v2 v2.1.7/go.mod h1:8HMkUZ5JRv4hpw/XUrYWSQNAUzhHMg2UDb/U+5m+XNw=
github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab h1:rvv6MJhy07IMfEKuARQ9TKojGqLVNxQajaXEp/BoqSk=
github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab/go.mod h1:IuLm4IsPipXKF7CW5Lzf68PIbZ5yl7FFd74l/E0o9A8=
github.com/ethereum/go-ethereum v1.17.1 h1:IjlQDjgxg2uL+GzPRkygGULPMLzcYWncEI7wbaizvho=
@@ -344,8 +344,8 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -355,8 +355,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c h1:xgCzyF2LFIO/0X2UAoVRiXKU5Xg6VjToG4i2/ecSswk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU=
google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=

View File

@@ -47,6 +47,6 @@ require (
golang.org/x/net v0.51.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
golang.org/x/text v0.35.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c // indirect
)

View File

@@ -199,8 +199,8 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
@@ -208,8 +208,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c h1:xgCzyF2LFIO/0X2UAoVRiXKU5Xg6VjToG4i2/ecSswk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU=
google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=

View File

@@ -25,6 +25,6 @@ require (
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/text v0.35.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)

View File

@@ -146,8 +146,8 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=

View File

@@ -50,6 +50,6 @@ require (
golang.org/x/net v0.51.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
golang.org/x/text v0.35.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c // indirect
)

View File

@@ -201,8 +201,8 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
@@ -210,8 +210,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c h1:xgCzyF2LFIO/0X2UAoVRiXKU5Xg6VjToG4i2/ecSswk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU=
google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=

View File

@@ -47,6 +47,6 @@ require (
golang.org/x/net v0.51.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
golang.org/x/text v0.35.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c // indirect
)

View File

@@ -199,8 +199,8 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
@@ -208,8 +208,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c h1:xgCzyF2LFIO/0X2UAoVRiXKU5Xg6VjToG4i2/ecSswk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU=
google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=

View File

@@ -36,12 +36,12 @@ require (
github.com/casbin/mongodb-adapter/v4 v4.3.0 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/consensys/gnark-crypto v0.19.2 // indirect
github.com/consensys/gnark-crypto v0.20.0 // indirect
github.com/crate-crypto/go-eth-kzg v1.5.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/deckarep/golang-set v1.8.0 // indirect
github.com/deckarep/golang-set/v2 v2.8.0 // indirect
github.com/ethereum/c-kzg-4844/v2 v2.1.6 // indirect
github.com/ethereum/c-kzg-4844/v2 v2.1.7 // indirect
github.com/fbsobreira/go-bip39 v1.2.0 // indirect
github.com/go-chi/chi/v5 v5.2.5 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
@@ -97,8 +97,8 @@ require (
golang.org/x/net v0.51.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/text v0.35.0 // indirect
golang.org/x/time v0.15.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260311181403-84a4fc48630c // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c // indirect
)

View File

@@ -42,8 +42,8 @@ github.com/cockroachdb/redact v1.1.5 h1:u1PMllDkdFfPWaNGMyLD1+so+aq3uUItthCFqzwP
github.com/cockroachdb/redact v1.1.5/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg=
github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 h1:zuQyyAKVxetITBuuhv3BI9cMrmStnpT18zmgmTxunpo=
github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06/go.mod h1:7nc4anLGjupUW/PeY5qiNYsdNXj7zopG+eqsS7To5IQ=
github.com/consensys/gnark-crypto v0.19.2 h1:qrEAIXq3T4egxqiliFFoNrepkIWVEeIYwt3UL0fvS80=
github.com/consensys/gnark-crypto v0.19.2/go.mod h1:rT23F0XSZqE0mUA0+pRtnL56IbPxs6gp4CeRsBk4XS0=
github.com/consensys/gnark-crypto v0.20.0 h1:dJmv2sC9KWV/cNRjMjy2S0h7emfyyX8eSsJzwk0DQzw=
github.com/consensys/gnark-crypto v0.20.0/go.mod h1:RBWrSgy+IDbGR69RRV313th3M/aZU1ubk2om+qHuTSc=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
@@ -76,8 +76,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/emicklei/dot v1.6.2 h1:08GN+DD79cy/tzN6uLCT84+2Wk9u+wvqP+Hkx/dIR8A=
github.com/emicklei/dot v1.6.2/go.mod h1:DeV7GvQtIw4h2u73RKBkkFdvVAz0D9fzeJrgPW6gy/s=
github.com/ethereum/c-kzg-4844/v2 v2.1.6 h1:xQymkKCT5E2Jiaoqf3v4wsNgjZLY0lRSkZn27fRjSls=
github.com/ethereum/c-kzg-4844/v2 v2.1.6/go.mod h1:8HMkUZ5JRv4hpw/XUrYWSQNAUzhHMg2UDb/U+5m+XNw=
github.com/ethereum/c-kzg-4844/v2 v2.1.7 h1:aat3CuITdDbPC6pmEGRT0zJ5eOxzrZj8TJT5z7Xk//M=
github.com/ethereum/c-kzg-4844/v2 v2.1.7/go.mod h1:8HMkUZ5JRv4hpw/XUrYWSQNAUzhHMg2UDb/U+5m+XNw=
github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab h1:rvv6MJhy07IMfEKuARQ9TKojGqLVNxQajaXEp/BoqSk=
github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab/go.mod h1:IuLm4IsPipXKF7CW5Lzf68PIbZ5yl7FFd74l/E0o9A8=
github.com/ethereum/go-ethereum v1.17.1 h1:IjlQDjgxg2uL+GzPRkygGULPMLzcYWncEI7wbaizvho=
@@ -360,8 +360,8 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -371,10 +371,10 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 h1:tu/dtnW1o3wfaxCOjSLn5IRX4YDcJrtlpzYkhHhGaC4=
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171/go.mod h1:M5krXqk4GhBKvB596udGL3UyjL4I1+cTbK0orROM9ng=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/genproto/googleapis/api v0.0.0-20260311181403-84a4fc48630c h1:OyQPd6I3pN/9gDxz6L13kYGJgqkpdrAohJRBeXyxlgI=
google.golang.org/genproto/googleapis/api v0.0.0-20260311181403-84a4fc48630c/go.mod h1:X2gu9Qwng7Nn009s/r3RUxqkzQNqOrAy79bluY7ojIg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c h1:xgCzyF2LFIO/0X2UAoVRiXKU5Xg6VjToG4i2/ecSswk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU=
google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=

View File

@@ -48,6 +48,6 @@ require (
golang.org/x/net v0.51.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
golang.org/x/text v0.35.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c // indirect
)

View File

@@ -201,8 +201,8 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
@@ -210,8 +210,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c h1:xgCzyF2LFIO/0X2UAoVRiXKU5Xg6VjToG4i2/ecSswk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU=
google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=

View File

@@ -14,7 +14,7 @@ require (
github.com/xhit/go-simple-mail/v2 v2.16.0
go.mongodb.org/mongo-driver/v2 v2.5.0
go.uber.org/zap v1.27.1
golang.org/x/text v0.34.0
golang.org/x/text v0.35.0
gopkg.in/yaml.v3 v3.0.1
)
@@ -50,7 +50,7 @@ require (
golang.org/x/net v0.51.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c // indirect
google.golang.org/grpc v1.79.2 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)

View File

@@ -216,8 +216,8 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
@@ -225,8 +225,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c h1:xgCzyF2LFIO/0X2UAoVRiXKU5Xg6VjToG4i2/ecSswk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU=
google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=

View File

@@ -48,6 +48,6 @@ require (
golang.org/x/net v0.51.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
golang.org/x/text v0.35.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c // indirect
)

View File

@@ -201,8 +201,8 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
@@ -210,8 +210,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c h1:xgCzyF2LFIO/0X2UAoVRiXKU5Xg6VjToG4i2/ecSswk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU=
google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=

View File

@@ -46,7 +46,7 @@ card_gateways:
# Batch optimizer settings:
# - default_mode disables aggregation for unmatched traffic
# - crypto rule enables destination aggregation for crypto rail
# - crypto rule enables aggregation by operation destination for crypto operations
optimizer:
aggregation:
default_mode: "no_optimization"
@@ -54,6 +54,7 @@ optimizer:
- id: "crypto_by_destination"
priority: 100
mode: "merge_by_destination"
group_by: "rail_target"
match:
rail: "CRYPTO"

View File

@@ -46,7 +46,7 @@ card_gateways:
# Batch optimizer settings:
# - default_mode disables aggregation for unmatched traffic
# - crypto rule enables destination aggregation for crypto rail
# - crypto rule enables aggregation by operation destination for crypto operations
optimizer:
aggregation:
default_mode: "no_optimization"
@@ -54,6 +54,7 @@ optimizer:
- id: "crypto_by_destination"
priority: 100
mode: "merge_by_destination"
group_by: "rail_target"
match:
rail: "CRYPTO"

View File

@@ -17,7 +17,6 @@ replace github.com/tech/sendico/ledger => ../../ledger
replace github.com/tech/sendico/payments/storage => ../storage
require (
github.com/google/uuid v1.6.0
github.com/shopspring/decimal v1.4.0
github.com/tech/sendico/fx/oracle v0.0.0-00010101000000-000000000000
github.com/tech/sendico/gateway/chain v0.0.0-00010101000000-000000000000
@@ -40,6 +39,7 @@ require (
github.com/casbin/mongodb-adapter/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/go-chi/chi/v5 v5.2.5 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/compress v1.18.4 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
@@ -63,6 +63,6 @@ require (
golang.org/x/net v0.51.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
golang.org/x/text v0.35.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c // indirect
)

View File

@@ -202,8 +202,8 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
@@ -211,8 +211,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c h1:xgCzyF2LFIO/0X2UAoVRiXKU5Xg6VjToG4i2/ecSswk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU=
google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=

View File

@@ -54,6 +54,7 @@ func buildBatchOptimizationPolicy(cfg optimizerConfig) psvc.BatchOptimizationPol
Enabled: ruleCfg.Enabled,
Priority: ruleCfg.Priority,
Mode: psvc.BatchOptimizationMode(strings.TrimSpace(ruleCfg.Mode)),
GroupBy: psvc.BatchOptimizationGrouping(strings.TrimSpace(ruleCfg.GroupBy)),
Match: psvc.BatchOptimizationMatch{
Rail: model.ParseRail(ruleCfg.Match.Rail),
Providers: cloneTrimmedSlice(ruleCfg.Match.Providers),

View File

@@ -33,6 +33,7 @@ type aggregationRuleConfig struct {
Enabled *bool `yaml:"enabled"`
Priority int `yaml:"priority"`
Mode string `yaml:"mode"`
GroupBy string `yaml:"group_by"`
Match aggregationMatchConfig `yaml:"match"`
}

View File

@@ -1,164 +0,0 @@
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

@@ -1,123 +0,0 @@
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"
sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/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 *sharedv1.PaymentQuote,
quoteFromSnapshot func(*model.PaymentQuoteSnapshot) *sharedv1.PaymentQuote,
) *sharedv1.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

@@ -1,123 +0,0 @@
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

@@ -1,220 +0,0 @@
package execution
import (
"fmt"
"github.com/tech/sendico/pkg/discovery"
"strings"
"github.com/google/uuid"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/model/account_role"
sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/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 *sharedv1.PaymentQuote,
quoteFromSnapshot func(*model.PaymentQuoteSnapshot) *sharedv1.PaymentQuote,
) *sharedv1.PaymentQuote {
if quote != nil {
return quote
}
if payment != nil && payment.LastQuote != nil && quoteFromSnapshot != nil {
return quoteFromSnapshot(payment.LastQuote)
}
return &sharedv1.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 != discovery.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 != discovery.RailLedger || step.Action != discovery.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 != discovery.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 discovery.RailLedger:
if step.Action == discovery.RailOperationFXConvert {
return model.PaymentFailureCodeFX
}
return model.PaymentFailureCodeLedger
case discovery.RailCrypto:
return model.PaymentFailureCodeChain
default:
return model.PaymentFailureCodePolicy
}
}

View File

@@ -1,227 +0,0 @@
package execution
import (
"github.com/tech/sendico/pkg/discovery"
"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 != discovery.RailCardPayout ||
step.Action != discovery.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
}

View File

@@ -44,33 +44,37 @@ const (
// StepShell defines one initial step telemetry item.
type StepShell struct {
StepRef string `bson:"stepRef" json:"stepRef"`
StepCode string `bson:"stepCode" json:"stepCode"`
Rail model.Rail `bson:"rail,omitempty" json:"rail,omitempty"`
Gateway string `bson:"gateway,omitempty" json:"gateway,omitempty"`
InstanceID string `bson:"instanceId,omitempty" json:"instanceId,omitempty"`
ReportVisibility model.ReportVisibility `bson:"reportVisibility,omitempty" json:"reportVisibility,omitempty"`
UserLabel string `bson:"userLabel,omitempty" json:"userLabel,omitempty"`
StepRef string `bson:"stepRef" json:"stepRef"`
StepCode string `bson:"stepCode" json:"stepCode"`
Rail model.Rail `bson:"rail,omitempty" json:"rail,omitempty"`
Gateway string `bson:"gateway,omitempty" json:"gateway,omitempty"`
InstanceID string `bson:"instanceId,omitempty" json:"instanceId,omitempty"`
ReportVisibility model.ReportVisibility `bson:"reportVisibility,omitempty" json:"reportVisibility,omitempty"`
UserLabel string `bson:"userLabel,omitempty" json:"userLabel,omitempty"`
PlannedMoney *paymenttypes.Money `bson:"plannedMoney,omitempty" json:"plannedMoney,omitempty"`
PlannedConvertedMoney *paymenttypes.Money `bson:"plannedConvertedMoney,omitempty" json:"plannedConvertedMoney,omitempty"`
}
// StepExecution is runtime telemetry for one step.
type StepExecution struct {
StepRef string `bson:"stepRef" json:"stepRef"`
StepCode string `bson:"stepCode" json:"stepCode"`
Rail model.Rail `bson:"rail,omitempty" json:"rail,omitempty"`
Gateway string `bson:"gateway,omitempty" json:"gateway,omitempty"`
InstanceID string `bson:"instanceId,omitempty" json:"instanceId,omitempty"`
ReportVisibility model.ReportVisibility `bson:"reportVisibility,omitempty" json:"reportVisibility,omitempty"`
UserLabel string `bson:"userLabel,omitempty" json:"userLabel,omitempty"`
State StepState `bson:"state" json:"state"`
Attempt uint32 `bson:"attempt" json:"attempt"`
StartedAt *time.Time `bson:"startedAt,omitempty" json:"startedAt,omitempty"`
CompletedAt *time.Time `bson:"completedAt,omitempty" json:"completedAt,omitempty"`
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"`
StepRef string `bson:"stepRef" json:"stepRef"`
StepCode string `bson:"stepCode" json:"stepCode"`
Rail model.Rail `bson:"rail,omitempty" json:"rail,omitempty"`
Gateway string `bson:"gateway,omitempty" json:"gateway,omitempty"`
InstanceID string `bson:"instanceId,omitempty" json:"instanceId,omitempty"`
ReportVisibility model.ReportVisibility `bson:"reportVisibility,omitempty" json:"reportVisibility,omitempty"`
UserLabel string `bson:"userLabel,omitempty" json:"userLabel,omitempty"`
State StepState `bson:"state" json:"state"`
Attempt uint32 `bson:"attempt" json:"attempt"`
StartedAt *time.Time `bson:"startedAt,omitempty" json:"startedAt,omitempty"`
CompletedAt *time.Time `bson:"completedAt,omitempty" json:"completedAt,omitempty"`
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"`
PlannedMoney *paymenttypes.Money `bson:"plannedMoney,omitempty" json:"plannedMoney,omitempty"`
PlannedConvertedMoney *paymenttypes.Money `bson:"plannedConvertedMoney,omitempty" json:"plannedConvertedMoney,omitempty"`
}
// ExternalRef links step execution to an external operation.

View File

@@ -4,6 +4,7 @@ import (
"strings"
"time"
svcshared "github.com/tech/sendico/payments/orchestrator/internal/service/shared"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/db/storable"
"github.com/tech/sendico/pkg/merrors"
@@ -148,15 +149,17 @@ func buildInitialStepTelemetry(shell []StepShell) ([]StepExecution, error) {
instanceID := strings.TrimSpace(shell[i].InstanceID)
out = append(out, StepExecution{
StepRef: stepRef,
StepCode: stepCode,
Rail: railValue,
Gateway: gatewayID,
InstanceID: instanceID,
ReportVisibility: visibility,
UserLabel: userLabel,
State: StepStatePending,
Attempt: 1,
StepRef: stepRef,
StepCode: stepCode,
Rail: railValue,
Gateway: gatewayID,
InstanceID: instanceID,
ReportVisibility: visibility,
UserLabel: userLabel,
State: StepStatePending,
Attempt: 1,
PlannedMoney: svcshared.CloneMoneyTrimNonEmpty(shell[i].PlannedMoney),
PlannedConvertedMoney: svcshared.CloneMoneyTrimNonEmpty(shell[i].PlannedConvertedMoney),
})
}
return out, nil

View File

@@ -5,6 +5,8 @@ import (
"time"
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/oshared"
svcshared "github.com/tech/sendico/payments/orchestrator/internal/service/shared"
"github.com/tech/sendico/pkg/merrors"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
)
@@ -123,9 +125,9 @@ func normalizeGatewayEvent(src GatewayEvent) (*normalizedEvent, error) {
ev := &normalizedEvent{
stepRef: strings.TrimSpace(src.StepRef),
targetState: target,
failureInfo: buildFailureInfo(failureCode, failureMsg, normalizeTimePtr(src.OccurredAt)),
failureInfo: buildFailureInfo(failureCode, failureMsg, oshared.CloneTimeUTC(src.OccurredAt)),
forceAggregate: buildForceAggregate(src.TerminalFailure, needsAttention),
executedMoney: normalizeEventMoney(src.ExecutedMoney),
executedMoney: svcshared.CloneMoneyTrimNonEmpty(src.ExecutedMoney),
}
ev.matchRefs = normalizeRefList([]agg.ExternalRef{
{
@@ -162,7 +164,7 @@ func normalizeLedgerEvent(src LedgerEvent) (*normalizedEvent, error) {
ev := &normalizedEvent{
stepRef: strings.TrimSpace(src.StepRef),
targetState: target,
failureInfo: buildFailureInfo(failureCode, failureMsg, normalizeTimePtr(src.OccurredAt)),
failureInfo: buildFailureInfo(failureCode, failureMsg, oshared.CloneTimeUTC(src.OccurredAt)),
forceAggregate: buildForceAggregate(src.TerminalFailure, needsAttention),
}
ev.matchRefs = normalizeRefList([]agg.ExternalRef{
@@ -194,7 +196,7 @@ func normalizeCardEvent(src CardEvent) (*normalizedEvent, error) {
ev := &normalizedEvent{
stepRef: strings.TrimSpace(src.StepRef),
targetState: target,
failureInfo: buildFailureInfo(failureCode, failureMsg, normalizeTimePtr(src.OccurredAt)),
failureInfo: buildFailureInfo(failureCode, failureMsg, oshared.CloneTimeUTC(src.OccurredAt)),
forceAggregate: buildForceAggregate(src.TerminalFailure, needsAttention),
}
ev.matchRefs = normalizeRefList([]agg.ExternalRef{
@@ -268,18 +270,7 @@ 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,
}
return svcshared.CloneMoneyTrimNonEmpty(money)
}
func mapFailureTarget(status any, retryable *bool) (agg.StepState, bool) {
@@ -302,14 +293,6 @@ func mapFailureTarget(status any, retryable *bool) (agg.StepState, bool) {
}
}
func normalizeTimePtr(ts *time.Time) *time.Time {
if ts == nil {
return nil
}
val := ts.UTC()
return &val
}
func normalizeRefList(refs []agg.ExternalRef) []agg.ExternalRef {
if len(refs) == 0 {
return nil

View File

@@ -197,3 +197,57 @@ func TestAggregate_UserBatchQuoteSampleCompactsToSingleRecipientOperation(t *tes
t.Fatalf("aggregated items mismatch: got=%q want=%q", got, want)
}
}
func TestAggregate_MergeByRecipient_IgnoresSourceAndQuoteRefs(t *testing.T) {
aggregator := New()
firstIntent := sampleIntent("intent-a", "card-1", "100")
secondIntent := sampleIntent("intent-b", "card-1", "125")
secondIntent.Source.ManagedWallet.ManagedWalletRef = "src-wallet-2"
firstQuote := sampleQuote("quote-a", "100", "9150", "1.8")
secondQuote := sampleQuote("quote-b", "125", "11437.5", "1.8")
secondQuote.Route.RouteRef = "route-b"
secondQuote.FXQuote.QuoteRef = "fx-b"
secondQuote.FXQuote.RateRef = "rate-b"
secondQuote.FXQuote.Price.Value = "92.7"
in := Input{
Items: []Item{
{
IntentSnapshot: firstIntent,
QuoteSnapshot: firstQuote,
MergeMode: MergeModeByRecipient,
},
{
IntentSnapshot: secondIntent,
QuoteSnapshot: secondQuote,
MergeMode: MergeModeByRecipient,
},
},
}
out, err := aggregator.Aggregate(in)
if err != nil {
t.Fatalf("Aggregate returned error: %v", err)
}
if out == nil {
t.Fatal("expected output")
}
if got, want := len(out.Groups), 1; got != want {
t.Fatalf("groups count mismatch: got=%d want=%d", got, want)
}
group := out.Groups[0]
if got, want := group.IntentSnapshot.Amount.Amount, "225"; got != want {
t.Fatalf("intent amount mismatch: got=%q want=%q", got, want)
}
if group.QuoteSnapshot == nil {
t.Fatal("expected quote snapshot")
}
if got, want := group.QuoteSnapshot.DebitAmount.Amount, "225"; got != want {
t.Fatalf("debit amount mismatch: got=%q want=%q", got, want)
}
if got, want := group.QuoteSnapshot.ExpectedSettlementAmount.Amount, "20587.5"; got != want {
t.Fatalf("settlement amount mismatch: got=%q want=%q", got, want)
}
}

View File

@@ -219,6 +219,33 @@ func cloneStringSlice(src []string) []string {
return out
}
func cloneGroupMembers(src []GroupMember) []GroupMember {
if len(src) == 0 {
return nil
}
out := make([]GroupMember, 0, len(src))
for i := range src {
member := src[i]
intentSnapshot, err := cloneIntentSnapshot(member.IntentSnapshot)
if err != nil {
continue
}
quoteSnapshot, err := cloneQuoteSnapshot(member.QuoteSnapshot)
if err != nil {
continue
}
out = append(out, GroupMember{
IntentRef: strings.TrimSpace(member.IntentRef),
IntentSnapshot: intentSnapshot,
QuoteSnapshot: quoteSnapshot,
})
}
if len(out) == 0 {
return nil
}
return out
}
func cloneIntentSnapshot(src model.PaymentIntent) (model.PaymentIntent, error) {
var dst model.PaymentIntent
if err := bsonClone(src, &dst); err != nil {

View File

@@ -95,8 +95,6 @@ func mergeQuoteSnapshot(dst *model.PaymentQuoteSnapshot, src *model.PaymentQuote
if strings.TrimSpace(dst.QuoteRef) == "" {
dst.QuoteRef = strings.TrimSpace(src.QuoteRef)
} else if srcRef := strings.TrimSpace(src.QuoteRef); srcRef != "" && dst.QuoteRef != srcRef {
return merrors.InvalidArgument("quote_snapshot.quote_ref mismatch")
}
return nil

View File

@@ -3,7 +3,6 @@ package opagg
import (
"strings"
"github.com/tech/sendico/pkg/merrors"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
)
@@ -14,9 +13,7 @@ func mergeRoute(dst, src *paymenttypes.QuoteRouteSpecification) (*paymenttypes.Q
if src == nil {
return dst, nil
}
if routeSignature(dst) != routeSignature(src) {
return nil, merrors.InvalidArgument("quote_snapshot.route mismatch")
}
// Aggregation is destination-driven; keep the first route as execution template.
return dst, nil
}
@@ -27,9 +24,6 @@ func mergeFXQuote(dst, src *paymenttypes.FXQuote) (*paymenttypes.FXQuote, error)
if src == nil {
return dst, nil
}
if fxQuoteSignature(dst) != fxQuoteSignature(src) {
return nil, merrors.InvalidArgument("quote_snapshot.fx_quote mismatch")
}
var err error
dst.BaseAmount, err = mergeMoney(dst.BaseAmount, src.BaseAmount, "quote_snapshot.fx_quote.base_amount")
if err != nil {

View File

@@ -31,15 +31,24 @@ type Item struct {
IntentSnapshot model.PaymentIntent
QuoteSnapshot *model.PaymentQuoteSnapshot
MergeMode MergeMode
MergeKey string
PolicyTag string
}
// GroupMember preserves one original batch member inside an aggregated group.
type GroupMember struct {
IntentRef string
IntentSnapshot model.PaymentIntent
QuoteSnapshot *model.PaymentQuoteSnapshot
}
// Group is one aggregated recipient operation group.
type Group struct {
RecipientKey string
IntentRefs []string
IntentSnapshot model.PaymentIntent
QuoteSnapshot *model.PaymentQuoteSnapshot
Members []GroupMember
}
// Output is the aggregation result.

View File

@@ -27,6 +27,7 @@ type groupAccumulator struct {
intentRefs []string
intent model.PaymentIntent
quote *model.PaymentQuoteSnapshot
members []GroupMember
}
func (s *svc) Aggregate(in Input) (out *Output, err error) {
@@ -90,12 +91,23 @@ func (s *svc) Aggregate(in Input) (out *Output, err error) {
if quoteSnapshot == nil {
return nil, merrors.InvalidArgument("items[" + itoa(i) + "].quote_snapshot is required")
}
member, memberErr := makeGroupMember(intentRef, item.IntentSnapshot, item.QuoteSnapshot)
if memberErr != nil {
return nil, memberErr
}
groupRecipientKey := recipientKey
if normalizeMergeMode(item.MergeMode) == MergeModeByRecipient {
if override := strings.TrimSpace(item.MergeKey); override != "" {
groupRecipientKey = override
}
}
groups[key] = &groupAccumulator{
recipientKey: recipientKey,
recipientKey: groupRecipientKey,
intentRefs: []string{intentRef},
intent: intentSnapshot,
quote: quoteSnapshot,
members: []GroupMember{member},
}
order = append(order, key)
continue
@@ -108,6 +120,11 @@ func (s *svc) Aggregate(in Input) (out *Output, err error) {
return nil, merrors.InvalidArgument("items[" + itoa(i) + "]: " + err.Error())
}
acc.intentRefs = append(acc.intentRefs, intentRef)
member, memberErr := makeGroupMember(intentRef, item.IntentSnapshot, item.QuoteSnapshot)
if memberErr != nil {
return nil, memberErr
}
acc.members = append(acc.members, member)
}
out = &Output{
@@ -139,6 +156,7 @@ func (s *svc) Aggregate(in Input) (out *Output, err error) {
IntentRefs: cloneStringSlice(acc.intentRefs),
IntentSnapshot: finalIntent,
QuoteSnapshot: finalQuote,
Members: cloneGroupMembers(acc.members),
})
}
@@ -165,15 +183,28 @@ func validateItem(item Item) error {
}
func groupingKey(item Item) (string, string, error) {
sourceKey, err := endpointKey(item.IntentSnapshot.Source)
if err != nil {
return "", "", merrors.InvalidArgument("intent_snapshot.source: " + err.Error())
}
recipientKey, err := endpointKey(item.IntentSnapshot.Destination)
if err != nil {
return "", "", merrors.InvalidArgument("intent_snapshot.destination: " + err.Error())
}
if normalizeMergeMode(item.MergeMode) == MergeModeByRecipient {
mergeKey := strings.TrimSpace(item.MergeKey)
if mergeKey == "" {
mergeKey = recipientKey
}
key := strings.Join([]string{
"kind=" + strings.ToLower(strings.TrimSpace(string(item.IntentSnapshot.Kind))),
"merge_key=" + mergeKey,
}, keySep)
return key, recipientKey, nil
}
sourceKey, err := endpointKey(item.IntentSnapshot.Source)
if err != nil {
return "", "", merrors.InvalidArgument("intent_snapshot.source: " + err.Error())
}
quote := item.QuoteSnapshot
key := strings.Join([]string{
"kind=" + strings.ToLower(strings.TrimSpace(string(item.IntentSnapshot.Kind))),
@@ -190,6 +221,22 @@ func groupingKey(item Item) (string, string, error) {
return key, recipientKey, nil
}
func makeGroupMember(intentRef string, intent model.PaymentIntent, quote *model.PaymentQuoteSnapshot) (GroupMember, error) {
intentSnapshot, err := cloneIntentSnapshot(intent)
if err != nil {
return GroupMember{}, err
}
quoteSnapshot, err := cloneQuoteSnapshot(quote)
if err != nil {
return GroupMember{}, err
}
return GroupMember{
IntentRef: strings.TrimSpace(intentRef),
IntentSnapshot: intentSnapshot,
QuoteSnapshot: quoteSnapshot,
}, nil
}
func isBatchingEligible(quote *model.PaymentQuoteSnapshot) bool {
if quote == nil || quote.ExecutionConditions == nil {
return true

View File

@@ -0,0 +1,31 @@
package oshared
import (
"strings"
"time"
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
)
func CloneExternalRefs(refs []agg.ExternalRef) []agg.ExternalRef {
if len(refs) == 0 {
return nil
}
out := make([]agg.ExternalRef, 0, len(refs))
for i := range refs {
ref := refs[i]
ref.GatewayInstanceID = strings.TrimSpace(ref.GatewayInstanceID)
ref.Kind = strings.TrimSpace(ref.Kind)
ref.Ref = strings.TrimSpace(ref.Ref)
out = append(out, ref)
}
return out
}
func CloneTimeUTC(ts *time.Time) *time.Time {
if ts == nil {
return nil
}
val := ts.UTC()
return &val
}

View File

@@ -2,14 +2,14 @@ package prepo
import (
"strings"
"time"
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/oshared"
svcshared "github.com/tech/sendico/payments/orchestrator/internal/service/shared"
"github.com/tech/sendico/payments/storage/model"
"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,50 +108,14 @@ func cloneStepExecutions(src []agg.StepExecution) []agg.StepExecution {
if step.Attempt == 0 {
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)
step.ExternalRefs = oshared.CloneExternalRefs(step.ExternalRefs)
step.ExecutedMoney = svcshared.CloneMoneyTrimNonEmpty(step.ExecutedMoney)
step.ConvertedMoney = svcshared.CloneMoneyTrimNonEmpty(step.ConvertedMoney)
step.PlannedMoney = svcshared.CloneMoneyTrimNonEmpty(step.PlannedMoney)
step.PlannedConvertedMoney = svcshared.CloneMoneyTrimNonEmpty(step.PlannedConvertedMoney)
step.StartedAt = oshared.CloneTimeUTC(step.StartedAt)
step.CompletedAt = oshared.CloneTimeUTC(step.CompletedAt)
out = append(out, step)
}
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
}
out := make([]agg.ExternalRef, 0, len(refs))
for i := range refs {
ref := refs[i]
ref.GatewayInstanceID = strings.TrimSpace(ref.GatewayInstanceID)
ref.Kind = strings.TrimSpace(ref.Kind)
ref.Ref = strings.TrimSpace(ref.Ref)
out = append(out, ref)
}
return out
}
func cloneTime(ts *time.Time) *time.Time {
if ts == nil {
return nil
}
val := ts.UTC()
return &val
}

View File

@@ -118,23 +118,26 @@ 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 steps[0].GetMoney() == nil {
t.Fatal("expected structured money envelope 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 steps[0].GetMoney().GetPlanned() == nil {
t.Fatal("expected structured planned money snapshot")
}
if got, want := steps[0].GetExecutedMoney().GetCurrency(), "USD"; got != want {
t.Fatalf("executed_money.currency mismatch: got=%q want=%q", got, want)
if got, want := steps[0].GetMoney().GetPlanned().GetAmount().GetAmount(), "96"; got != want {
t.Fatalf("money.planned.amount.amount 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].GetMoney().GetPlanned().GetConvertedAmount().GetAmount(), "91"; got != want {
t.Fatalf("money.planned.converted_amount.amount mismatch: got=%q want=%q", got, want)
}
if got, want := steps[0].GetConvertedMoney().GetAmount(), "90"; got != want {
t.Fatalf("converted_money.amount mismatch: got=%q want=%q", got, want)
if steps[0].GetMoney().GetExecuted() == nil {
t.Fatal("expected structured executed money snapshot")
}
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[0].GetMoney().GetExecuted().GetAmount().GetAmount(), "95"; got != want {
t.Fatalf("money.executed.amount.amount mismatch: got=%q want=%q", got, want)
}
if got, want := steps[0].GetMoney().GetExecuted().GetConvertedAmount().GetAmount(), "90"; got != want {
t.Fatalf("money.executed.converted_amount.amount 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())
@@ -389,6 +392,14 @@ func newPaymentFixture() *agg.Payment {
Amount: "90",
Currency: "EUR",
},
PlannedMoney: &paymenttypes.Money{
Amount: "96",
Currency: "USD",
},
PlannedConvertedMoney: &paymenttypes.Money{
Amount: "91",
Currency: "EUR",
},
StartedAt: &startedAt,
},
{

View File

@@ -6,6 +6,7 @@ import (
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
"github.com/tech/sendico/pkg/merrors"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2"
)
@@ -35,6 +36,12 @@ func mapStepExecution(step agg.StepExecution, index int) (*orchestrationv2.StepE
attempt = 1
}
plannedAmount := moneyToProto(step.PlannedMoney)
plannedConverted := moneyToProto(step.PlannedConvertedMoney)
executedAmount := moneyToProto(step.ExecutedMoney)
executedConverted := moneyToProto(step.ConvertedMoney)
moneyEnvelope := mapStepMoneyEnvelope(plannedAmount, plannedConverted, executedAmount, executedConverted)
return &orchestrationv2.StepExecution{
StepRef: strings.TrimSpace(step.StepRef),
StepCode: strings.TrimSpace(step.StepCode),
@@ -46,11 +53,37 @@ 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),
Money: moneyEnvelope,
}, nil
}
func mapStepMoneyEnvelope(
plannedAmount *moneyv1.Money,
plannedConverted *moneyv1.Money,
executedAmount *moneyv1.Money,
executedConverted *moneyv1.Money,
) *orchestrationv2.StepExecutionMoney {
planned := mapStepMoneySnapshot(plannedAmount, plannedConverted)
executed := mapStepMoneySnapshot(executedAmount, executedConverted)
if planned == nil && executed == nil {
return nil
}
return &orchestrationv2.StepExecutionMoney{
Planned: planned,
Executed: executed,
}
}
func mapStepMoneySnapshot(amount *moneyv1.Money, converted *moneyv1.Money) *orchestrationv2.StepExecutionMoneySnapshot {
if amount == nil && converted == nil {
return nil
}
return &orchestrationv2.StepExecutionMoneySnapshot{
Amount: amount,
ConvertedAmount: converted,
}
}
func mapStepFailure(step agg.StepExecution, state agg.StepState) *orchestrationv2.Failure {
if state != agg.StepStateFailed && state != agg.StepStateNeedsAttention {
return nil

View File

@@ -30,17 +30,24 @@ type BatchOptimizer interface {
Optimize(in BatchOptimizeInput) (*BatchOptimizeOutput, error)
}
// BatchOptimizationMergeKeyResolver allows external packages to resolve custom merge keys.
type BatchOptimizationMergeKeyResolver interface {
ResolveMergeKey(item qsnap.ResolvedItem, selection BatchOptimizationSelection) (string, bool)
}
// PolicyBatchOptimizerDependencies configures policy-based optimizer implementation.
type PolicyBatchOptimizerDependencies struct {
Logger mlogger.Logger
Aggregator opagg.Aggregator
Policy BatchOptimizationPolicy
Logger mlogger.Logger
Aggregator opagg.Aggregator
Policy BatchOptimizationPolicy
MergeKeyResolver BatchOptimizationMergeKeyResolver
}
type policyBatchOptimizer struct {
logger mlogger.Logger
aggregator opagg.Aggregator
policy BatchOptimizationPolicy
logger mlogger.Logger
aggregator opagg.Aggregator
policy BatchOptimizationPolicy
mergeKeyResolver BatchOptimizationMergeKeyResolver
}
// NewPolicyBatchOptimizer creates default rule-based optimizer implementation.
@@ -54,9 +61,10 @@ func NewPolicyBatchOptimizer(deps PolicyBatchOptimizerDependencies) BatchOptimiz
aggregator = opagg.New(opagg.Dependencies{Logger: logger})
}
return &policyBatchOptimizer{
logger: logger.Named("batch_optimizer"),
aggregator: aggregator,
policy: normalizeBatchOptimizationPolicy(deps.Policy),
logger: logger.Named("batch_optimizer"),
aggregator: aggregator,
policy: normalizeBatchOptimizationPolicy(deps.Policy),
mergeKeyResolver: deps.MergeKeyResolver,
}
}
@@ -68,13 +76,15 @@ func (o *policyBatchOptimizer) Optimize(in BatchOptimizeInput) (*BatchOptimizeOu
aggItems := make([]opagg.Item, 0, len(in.Items))
for i := range in.Items {
item := in.Items[i]
selection := o.policy.Select(batchOptimizationContextFromItem(item))
selection := o.policy.SelectMany(batchOptimizationContextsFromItem(item))
intentSnapshot := applyBatchOptimizationSelection(item.IntentSnapshot, selection)
mergeKey := batchOptimizationMergeKey(item, selection, o.mergeKeyResolver)
aggItems = append(aggItems, opagg.Item{
IntentRef: item.IntentRef,
IntentSnapshot: intentSnapshot,
QuoteSnapshot: item.QuoteSnapshot,
MergeMode: mergeModeFromBatchOptimization(selection.Mode),
MergeKey: mergeKey,
PolicyTag: batchOptimizationSelectionTag(selection),
})
}
@@ -89,13 +99,211 @@ func (o *policyBatchOptimizer) Optimize(in BatchOptimizeInput) (*BatchOptimizeOu
return &BatchOptimizeOutput{Groups: aggOutput.Groups}, nil
}
func batchOptimizationContextsFromItem(item qsnap.ResolvedItem) []BatchOptimizationContext {
out := make([]BatchOptimizationContext, 0, 6)
add := func(base BatchOptimizationContext) {
if base.Rail == discovery.RailUnspecified {
return
}
money := selectSettlementMoney(item)
if money != nil {
base.Amount = strings.TrimSpace(money.Amount)
base.Currency = strings.TrimSpace(money.Currency)
}
out = append(out, base)
}
add(batchOptimizationContextFromItem(item))
add(mergeBatchOptimizationContext(
endpointOptimizationContext(item.IntentSnapshot.Source),
sourceHopContext(item.QuoteSnapshot),
))
route := item.QuoteSnapshot
if route != nil && route.Route != nil {
if parsed := model.ParseRail(route.Route.Rail); parsed != discovery.RailUnspecified {
add(BatchOptimizationContext{
Rail: parsed,
Provider: strings.TrimSpace(route.Route.Provider),
Network: strings.TrimSpace(route.Route.Network),
})
}
for i := range route.Route.Hops {
hop := route.Route.Hops[i]
if hop == nil {
continue
}
parsed := model.ParseRail(hop.Rail)
if parsed == discovery.RailUnspecified {
continue
}
add(BatchOptimizationContext{
Rail: parsed,
Provider: strings.TrimSpace(hop.Gateway),
Network: strings.TrimSpace(hop.Network),
})
}
}
if len(out) == 0 {
add(batchOptimizationContextFromItem(item))
}
return out
}
func batchOptimizationMergeKey(
item qsnap.ResolvedItem,
sel BatchOptimizationSelection,
resolver BatchOptimizationMergeKeyResolver,
) string {
if mergeModeFromBatchOptimization(sel.Mode) != opagg.MergeModeByRecipient {
return ""
}
groupBy := normalizeBatchOptimizationGrouping(sel.GroupBy)
if groupBy == BatchOptimizationGroupingUnspecified {
groupBy = BatchOptimizationGroupingOperationDestination
}
switch groupBy {
case BatchOptimizationGroupingOperationSource:
return mergeEndpointKey(item.IntentSnapshot.Source)
case BatchOptimizationGroupingRailTarget:
if resolver != nil {
if key, ok := resolver.ResolveMergeKey(item, sel); ok {
return strings.TrimSpace(key)
}
}
return railTargetMergeKey(item, sel.MatchRail)
default:
return mergeEndpointKey(item.IntentSnapshot.Destination)
}
}
func railTargetMergeKey(item qsnap.ResolvedItem, rail model.Rail) string {
matchRail := model.ParseRail(string(rail))
if matchRail == discovery.RailUnspecified {
ctx := batchOptimizationContextFromItem(item)
matchRail = ctx.Rail
}
switch matchRail {
case discovery.RailCardPayout:
return mergeEndpointKey(item.IntentSnapshot.Destination)
case discovery.RailCrypto:
if endpointRail(item.IntentSnapshot.Destination) == discovery.RailCrypto {
return mergeEndpointKey(item.IntentSnapshot.Destination)
}
if endpointRail(item.IntentSnapshot.Source) == discovery.RailCrypto {
return mergeEndpointKey(item.IntentSnapshot.Source)
}
return routeRailTargetKey(item.QuoteSnapshot, matchRail)
default:
return routeRailTargetKey(item.QuoteSnapshot, matchRail)
}
}
func routeRailTargetKey(snapshot *model.PaymentQuoteSnapshot, rail model.Rail) string {
if snapshot == nil || snapshot.Route == nil {
return ""
}
route := snapshot.Route
target := ""
fallback := ""
for i := range route.Hops {
hop := route.Hops[i]
if hop == nil || model.ParseRail(hop.Rail) != rail {
continue
}
key := strings.Join([]string{
"rail=" + strings.ToUpper(strings.TrimSpace(hop.Rail)),
"gateway=" + strings.TrimSpace(hop.Gateway),
"instance=" + strings.TrimSpace(hop.InstanceID),
"network=" + strings.TrimSpace(hop.Network),
}, "|")
if fallback == "" {
fallback = key
}
if hop.Role == paymenttypes.QuoteRouteHopRoleDestination {
target = key
break
}
}
if target != "" {
return target
}
if fallback != "" {
return fallback
}
return ""
}
func mergeEndpointKey(ep model.PaymentEndpoint) string {
switch normalizeEndpointType(ep) {
case model.EndpointTypeLedger:
if ep.Ledger == nil {
return ""
}
return strings.Join([]string{
"type=ledger",
"account=" + strings.TrimSpace(ep.Ledger.LedgerAccountRef),
"contra=" + strings.TrimSpace(ep.Ledger.ContraLedgerAccountRef),
}, "|")
case model.EndpointTypeManagedWallet:
if ep.ManagedWallet == nil {
return ""
}
return strings.Join([]string{
"type=managed_wallet",
"wallet=" + strings.TrimSpace(ep.ManagedWallet.ManagedWalletRef),
"asset=" + mergeAssetKey(ep.ManagedWallet.Asset),
}, "|")
case model.EndpointTypeExternalChain:
if ep.ExternalChain == nil {
return ""
}
return strings.Join([]string{
"type=external_chain",
"address=" + strings.TrimSpace(ep.ExternalChain.Address),
"memo=" + strings.TrimSpace(ep.ExternalChain.Memo),
"asset=" + mergeAssetKey(ep.ExternalChain.Asset),
}, "|")
case model.EndpointTypeCard:
if ep.Card == nil {
return ""
}
return strings.Join([]string{
"type=card",
"token=" + strings.TrimSpace(ep.Card.Token),
"pan=" + strings.TrimSpace(ep.Card.Pan),
"masked=" + strings.TrimSpace(ep.Card.MaskedPan),
"country=" + strings.TrimSpace(ep.Card.Country),
"exp=" + strconv.FormatUint(uint64(ep.Card.ExpMonth), 10) + "-" + strconv.FormatUint(uint64(ep.Card.ExpYear), 10),
}, "|")
default:
return ""
}
}
func mergeAssetKey(asset *paymenttypes.Asset) string {
if asset == nil {
return ""
}
return strings.Join([]string{
strings.ToUpper(strings.TrimSpace(asset.Chain)),
strings.ToUpper(strings.TrimSpace(asset.TokenSymbol)),
strings.TrimSpace(asset.ContractAddress),
}, ":")
}
func batchOptimizationContextFromItem(item qsnap.ResolvedItem) BatchOptimizationContext {
rail, provider, network := destinationHopSignature(item.QuoteSnapshot)
// Match policy against operation target (intent destination), not route leg semantics.
target := mergeBatchOptimizationContext(
endpointOptimizationContext(item.IntentSnapshot.Destination),
destinationHopContext(item.QuoteSnapshot),
)
money := selectSettlementMoney(item)
ctx := BatchOptimizationContext{
Rail: rail,
Provider: provider,
Network: network,
Rail: target.Rail,
Provider: target.Provider,
Network: target.Network,
}
if money != nil {
ctx.Amount = strings.TrimSpace(money.Amount)
@@ -104,6 +312,70 @@ func batchOptimizationContextFromItem(item qsnap.ResolvedItem) BatchOptimization
return ctx
}
func mergeBatchOptimizationContext(primary, fallback BatchOptimizationContext) BatchOptimizationContext {
out := primary
if out.Rail == "" || out.Rail == discovery.RailUnspecified {
out.Rail = fallback.Rail
}
if strings.TrimSpace(out.Provider) == "" {
out.Provider = fallback.Provider
}
if strings.TrimSpace(out.Network) == "" {
out.Network = fallback.Network
}
return out
}
func endpointOptimizationContext(endpoint model.PaymentEndpoint) BatchOptimizationContext {
out := BatchOptimizationContext{
Rail: endpointRail(endpoint),
}
switch normalizeEndpointType(endpoint) {
case model.EndpointTypeManagedWallet:
if endpoint.ManagedWallet != nil && endpoint.ManagedWallet.Asset != nil {
out.Network = strings.TrimSpace(endpoint.ManagedWallet.Asset.Chain)
}
case model.EndpointTypeExternalChain:
if endpoint.ExternalChain != nil {
if endpoint.ExternalChain.Asset != nil {
out.Network = strings.TrimSpace(endpoint.ExternalChain.Asset.Chain)
}
}
}
return out
}
func endpointRail(endpoint model.PaymentEndpoint) model.Rail {
switch normalizeEndpointType(endpoint) {
case model.EndpointTypeManagedWallet, model.EndpointTypeExternalChain:
return discovery.RailCrypto
case model.EndpointTypeCard:
return discovery.RailCardPayout
case model.EndpointTypeLedger:
return discovery.RailLedger
default:
return discovery.RailUnspecified
}
}
func normalizeEndpointType(endpoint model.PaymentEndpoint) model.PaymentEndpointType {
if endpoint.Type != model.EndpointTypeUnspecified {
return endpoint.Type
}
switch {
case endpoint.Ledger != nil:
return model.EndpointTypeLedger
case endpoint.ManagedWallet != nil:
return model.EndpointTypeManagedWallet
case endpoint.ExternalChain != nil:
return model.EndpointTypeExternalChain
case endpoint.Card != nil:
return model.EndpointTypeCard
default:
return model.EndpointTypeUnspecified
}
}
func selectSettlementMoney(item qsnap.ResolvedItem) *paymenttypes.Money {
if item.QuoteSnapshot != nil && item.QuoteSnapshot.ExpectedSettlementAmount != nil {
return item.QuoteSnapshot.ExpectedSettlementAmount
@@ -111,14 +383,16 @@ func selectSettlementMoney(item qsnap.ResolvedItem) *paymenttypes.Money {
return item.IntentSnapshot.Amount
}
func destinationHopSignature(snapshot *model.PaymentQuoteSnapshot) (model.Rail, string, string) {
func destinationHopContext(snapshot *model.PaymentQuoteSnapshot) BatchOptimizationContext {
if snapshot == nil || snapshot.Route == nil {
return discovery.RailUnspecified, "", ""
return BatchOptimizationContext{}
}
route := snapshot.Route
rail := model.ParseRail(route.Rail)
provider := strings.TrimSpace(route.Provider)
network := strings.TrimSpace(route.Network)
ctx := BatchOptimizationContext{
Rail: model.ParseRail(route.Rail),
Provider: strings.TrimSpace(route.Provider),
Network: strings.TrimSpace(route.Network),
}
var destinationHop *paymenttypes.QuoteRouteHop
for i := range route.Hops {
@@ -133,16 +407,54 @@ func destinationHopSignature(snapshot *model.PaymentQuoteSnapshot) (model.Rail,
}
if destinationHop != nil {
if parsed := model.ParseRail(destinationHop.Rail); parsed != discovery.RailUnspecified {
rail = parsed
ctx.Rail = parsed
}
if provider == "" {
provider = strings.TrimSpace(destinationHop.Gateway)
if ctx.Provider == "" {
ctx.Provider = strings.TrimSpace(destinationHop.Gateway)
}
if network == "" {
network = strings.TrimSpace(destinationHop.Network)
if ctx.Network == "" {
ctx.Network = strings.TrimSpace(destinationHop.Network)
}
}
return rail, provider, network
return ctx
}
func sourceHopContext(snapshot *model.PaymentQuoteSnapshot) BatchOptimizationContext {
if snapshot == nil || snapshot.Route == nil {
return BatchOptimizationContext{}
}
route := snapshot.Route
ctx := BatchOptimizationContext{
Rail: model.ParseRail(route.Rail),
Provider: strings.TrimSpace(route.Provider),
Network: strings.TrimSpace(route.Network),
}
var sourceHop *paymenttypes.QuoteRouteHop
for i := range route.Hops {
hop := route.Hops[i]
if hop == nil {
continue
}
if sourceHop == nil {
sourceHop = hop
}
if hop.Role == paymenttypes.QuoteRouteHopRoleSource {
sourceHop = hop
break
}
}
if sourceHop != nil {
if parsed := model.ParseRail(sourceHop.Rail); parsed != discovery.RailUnspecified {
ctx.Rail = parsed
}
if ctx.Provider == "" {
ctx.Provider = strings.TrimSpace(sourceHop.Gateway)
}
if ctx.Network == "" {
ctx.Network = strings.TrimSpace(sourceHop.Network)
}
}
return ctx
}
func mergeModeFromBatchOptimization(mode BatchOptimizationMode) opagg.MergeMode {
@@ -174,6 +486,12 @@ func applyBatchOptimizationSelection(intent model.PaymentIntent, sel BatchOptimi
}
intent.Attributes[attrBatchOptimizerMode] = string(mode)
intent.Attributes[attrBatchOptimizerRuleMatch] = strconv.FormatBool(sel.Matched)
groupBy := normalizeBatchOptimizationGrouping(sel.GroupBy)
if groupBy == BatchOptimizationGroupingUnspecified {
delete(intent.Attributes, attrBatchOptimizerGrouping)
} else {
intent.Attributes[attrBatchOptimizerGrouping] = string(groupBy)
}
ruleID := strings.TrimSpace(sel.RuleID)
if ruleID == "" {
delete(intent.Attributes, attrBatchOptimizerRuleID)

View File

@@ -0,0 +1,48 @@
package psvc
import (
"testing"
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/qsnap"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/discovery"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
)
func TestBatchOptimizationContextFromItem_UsesOperationTargetContext(t *testing.T) {
item := qsnap.ResolvedItem{
IntentRef: "intent-1",
IntentSnapshot: model.PaymentIntent{
Ref: "intent-1",
Destination: model.PaymentEndpoint{
Type: model.EndpointTypeCard,
Card: &model.CardEndpoint{
Pan: "2200001100000001",
ExpMonth: 1,
ExpYear: 2030,
},
},
Amount: &paymenttypes.Money{Amount: "100", Currency: "RUB"},
SettlementCurrency: "RUB",
},
QuoteSnapshot: &model.PaymentQuoteSnapshot{
Route: &paymenttypes.QuoteRouteSpecification{
Hops: []*paymenttypes.QuoteRouteHop{
{Index: 1, Rail: "CRYPTO", Gateway: "gw-crypto", Network: "TRON_NILE", Role: paymenttypes.QuoteRouteHopRoleSource},
{Index: 2, Rail: "CARD", Gateway: "mcards", Network: "TRON_NILE", Role: paymenttypes.QuoteRouteHopRoleDestination},
},
},
},
}
ctx := batchOptimizationContextFromItem(item)
if got, want := ctx.Rail, model.ParseRail(string(discovery.RailCardPayout)); got != want {
t.Fatalf("operation rail mismatch: got=%q want=%q", got, want)
}
if got, want := ctx.Provider, "mcards"; got != want {
t.Fatalf("provider mismatch: got=%q want=%q", got, want)
}
if got, want := ctx.Network, "TRON_NILE"; got != want {
t.Fatalf("network mismatch: got=%q want=%q", got, want)
}
}

View File

@@ -7,6 +7,7 @@ import (
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/sexec"
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan"
svcshared "github.com/tech/sendico/payments/orchestrator/internal/service/shared"
"github.com/tech/sendico/pkg/mlogger"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
)
@@ -201,7 +202,7 @@ func inheritedExternalRefs(payment *agg.Payment, step xplan.Step, current agg.St
}
func inheritedExecutedMoney(payment *agg.Payment, step xplan.Step, current agg.StepExecution) *paymenttypes.Money {
if money := cloneStepMoney(current.ExecutedMoney); money != nil {
if money := svcshared.CloneMoneyTrimNonEmpty(current.ExecutedMoney); money != nil {
return money
}
if payment == nil || len(step.DependsOn) == 0 {
@@ -213,7 +214,7 @@ func inheritedExecutedMoney(payment *agg.Payment, step xplan.Step, current agg.S
if !ok || idx < 0 || idx >= len(payment.StepExecutions) {
continue
}
if money := cloneStepMoney(payment.StepExecutions[idx].ExecutedMoney); money != nil {
if money := svcshared.CloneMoneyTrimNonEmpty(payment.StepExecutions[idx].ExecutedMoney); money != nil {
return money
}
}
@@ -221,7 +222,7 @@ func inheritedExecutedMoney(payment *agg.Payment, step xplan.Step, current agg.S
}
func inheritedConvertedMoney(payment *agg.Payment, step xplan.Step, current agg.StepExecution) *paymenttypes.Money {
if money := cloneStepMoney(current.ConvertedMoney); money != nil {
if money := svcshared.CloneMoneyTrimNonEmpty(current.ConvertedMoney); money != nil {
return money
}
if payment == nil || len(step.DependsOn) == 0 {
@@ -233,7 +234,7 @@ func inheritedConvertedMoney(payment *agg.Payment, step xplan.Step, current agg.
if !ok || idx < 0 || idx >= len(payment.StepExecutions) {
continue
}
if money := cloneStepMoney(payment.StepExecutions[idx].ConvertedMoney); money != nil {
if money := svcshared.CloneMoneyTrimNonEmpty(payment.StepExecutions[idx].ConvertedMoney); money != nil {
return money
}
}

View File

@@ -14,6 +14,7 @@ import (
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/qsnap"
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/reqval"
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/merrors"
orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2"
"go.uber.org/zap"
@@ -168,7 +169,7 @@ func (s *svc) createNewPayment(ctx context.Context, requestCtx *reqval.Ctx) (*ag
ClientPaymentRef: requestCtx.ClientPaymentRef,
IntentSnapshot: resolved.IntentSnapshot,
QuoteSnapshot: resolved.QuoteSnapshot,
Steps: toStepShells(graph),
Steps: toStepShells(graph, resolved.IntentSnapshot, resolved.QuoteSnapshot),
})
if err != nil {
return nil, err
@@ -212,20 +213,23 @@ func (s *svc) resolveAndPlan(ctx context.Context, requestCtx *reqval.Ctx) (*qsna
return resolved, graph, nil
}
func toStepShells(graph *xplan.Graph) []agg.StepShell {
func toStepShells(graph *xplan.Graph, intent model.PaymentIntent, quote *model.PaymentQuoteSnapshot) []agg.StepShell {
if graph == nil || len(graph.Steps) == 0 {
return nil
}
out := make([]agg.StepShell, 0, len(graph.Steps))
for i := range graph.Steps {
plannedMoney, plannedConverted := plannedMoneyForStep(graph.Steps[i], intent, quote)
out = append(out, agg.StepShell{
StepRef: graph.Steps[i].StepRef,
StepCode: graph.Steps[i].StepCode,
Rail: graph.Steps[i].Rail,
Gateway: graph.Steps[i].Gateway,
InstanceID: graph.Steps[i].InstanceID,
ReportVisibility: graph.Steps[i].Visibility,
UserLabel: graph.Steps[i].UserLabel,
StepRef: graph.Steps[i].StepRef,
StepCode: graph.Steps[i].StepCode,
Rail: graph.Steps[i].Rail,
Gateway: graph.Steps[i].Gateway,
InstanceID: graph.Steps[i].InstanceID,
ReportVisibility: graph.Steps[i].Visibility,
UserLabel: graph.Steps[i].UserLabel,
PlannedMoney: plannedMoney,
PlannedConvertedMoney: plannedConverted,
})
}
return out

View File

@@ -31,6 +31,7 @@ const (
attrAggregatedByRecipient = "orchestrator.v2.aggregated_by_recipient"
attrAggregatedItems = "orchestrator.v2.aggregated_items"
attrBatchOptimizerMode = "orchestrator.v2.batch_optimizer_mode"
attrBatchOptimizerGrouping = "orchestrator.v2.batch_optimizer_group_by"
attrBatchOptimizerRuleID = "orchestrator.v2.batch_optimizer_rule_id"
attrBatchOptimizerRuleMatch = "orchestrator.v2.batch_optimizer_rule_matched"
)
@@ -198,7 +199,7 @@ func (s *svc) createGroupPayment(
ClientPaymentRef: requestCtx.ClientPaymentRef,
IntentSnapshot: intentSnapshot,
QuoteSnapshot: group.QuoteSnapshot,
Steps: toStepShells(graph),
Steps: toStepShells(graph, intentSnapshot, group.QuoteSnapshot),
})
if err != nil {
return nil, err
@@ -291,7 +292,7 @@ func (s *svc) buildBatchOperationGroups(groups []opagg.Group) ([]opagg.Group, er
}
group.IntentSnapshot.Attributes[attrAggregatedItems] = strconv.Itoa(len(group.IntentRefs))
targets := buildBatchPayoutTargets([]opagg.Group{group})
targets := buildBatchPayoutTargets(group)
if routeContainsCardPayout(group.QuoteSnapshot) && len(targets) > 0 {
raw, err := batchmeta.EncodePayoutTargets(targets)
if err != nil {
@@ -311,32 +312,49 @@ func (s *svc) buildBatchOperationGroups(groups []opagg.Group) ([]opagg.Group, er
return out, nil
}
func buildBatchPayoutTargets(groups []opagg.Group) []batchmeta.PayoutTarget {
if len(groups) == 0 {
func buildBatchPayoutTargets(group opagg.Group) []batchmeta.PayoutTarget {
if len(group.Members) > 0 {
targets := make([]batchmeta.PayoutTarget, 0, len(group.Members))
for i := range group.Members {
member := group.Members[i]
target := batchmeta.PayoutTarget{
TargetRef: firstNonEmpty(strings.TrimSpace(member.IntentRef), "recipient-"+strconv.Itoa(i+1)),
IntentRef: normalizeIntentRefs([]string{member.IntentRef}),
Amount: batchPayoutAmountFromMember(member),
}
if member.IntentSnapshot.Destination.Type == model.EndpointTypeCard && member.IntentSnapshot.Destination.Card != nil {
card := *member.IntentSnapshot.Destination.Card
target.Card = &card
}
if member.IntentSnapshot.Customer != nil {
customer := *member.IntentSnapshot.Customer
target.Customer = &customer
}
targets = append(targets, target)
}
if len(targets) == 0 {
return nil
}
return targets
}
if isEmptyIntentSnapshot(group.IntentSnapshot) {
return nil
}
out := make([]batchmeta.PayoutTarget, 0, len(groups))
for i := range groups {
group := groups[i]
target := batchmeta.PayoutTarget{
TargetRef: firstNonEmpty(strings.TrimSpace(group.RecipientKey), "recipient-"+strconv.Itoa(i+1)),
IntentRef: normalizeIntentRefs(group.IntentRefs),
Amount: batchPayoutAmount(group),
}
if group.IntentSnapshot.Destination.Type == model.EndpointTypeCard && group.IntentSnapshot.Destination.Card != nil {
card := *group.IntentSnapshot.Destination.Card
target.Card = &card
}
if group.IntentSnapshot.Customer != nil {
customer := *group.IntentSnapshot.Customer
target.Customer = &customer
}
out = append(out, target)
target := batchmeta.PayoutTarget{
TargetRef: firstNonEmpty(strings.TrimSpace(group.RecipientKey), "recipient-1"),
IntentRef: normalizeIntentRefs(group.IntentRefs),
Amount: batchPayoutAmount(group),
}
if len(out) == 0 {
return nil
if group.IntentSnapshot.Destination.Type == model.EndpointTypeCard && group.IntentSnapshot.Destination.Card != nil {
card := *group.IntentSnapshot.Destination.Card
target.Card = &card
}
return out
if group.IntentSnapshot.Customer != nil {
customer := *group.IntentSnapshot.Customer
target.Customer = &customer
}
return []batchmeta.PayoutTarget{target}
}
func batchPayoutAmount(group opagg.Group) *paymenttypes.Money {
@@ -355,6 +373,26 @@ func batchPayoutAmount(group opagg.Group) *paymenttypes.Money {
}
}
func batchPayoutAmountFromMember(member opagg.GroupMember) *paymenttypes.Money {
if member.QuoteSnapshot != nil && member.QuoteSnapshot.ExpectedSettlementAmount != nil {
return &paymenttypes.Money{
Amount: strings.TrimSpace(member.QuoteSnapshot.ExpectedSettlementAmount.Amount),
Currency: strings.TrimSpace(member.QuoteSnapshot.ExpectedSettlementAmount.Currency),
}
}
if member.IntentSnapshot.Amount == nil {
return nil
}
return &paymenttypes.Money{
Amount: strings.TrimSpace(member.IntentSnapshot.Amount.Amount),
Currency: strings.TrimSpace(member.IntentSnapshot.Amount.Currency),
}
}
func isEmptyIntentSnapshot(intent model.PaymentIntent) bool {
return intent.Amount == nil && (intent.Kind == "" || intent.Kind == model.PaymentKindUnspecified)
}
func routeContainsCardPayout(snapshot *model.PaymentQuoteSnapshot) bool {
if snapshot == nil || snapshot.Route == nil {
return false

View File

@@ -162,6 +162,47 @@ func TestExecuteBatchPayment_CryptoPolicyMergesByDestination(t *testing.T) {
}
}
func TestExecuteBatchPayment_CryptoPolicyMergesSameRecipientEvenWithDifferentSourceAndQuoteRefs(t *testing.T) {
env := newTestEnvWithPolicy(t, BatchOptimizationPolicy{
DefaultMode: BatchOptimizationModeNoOptimization,
Rules: []BatchOptimizationRule{
{
ID: "crypto-merge",
Priority: 100,
Mode: BatchOptimizationModeMergeByDestination,
Match: BatchOptimizationMatch{
Rail: discovery.RailCrypto,
},
},
},
}, func(_ string, req sexec.StepRequest) (*sexec.ExecuteOutput, error) {
step := req.StepExecution
step.State = agg.StepStateCompleted
return &sexec.ExecuteOutput{StepExecution: step}, nil
})
quote := newExecutableBatchCryptoQuoteSameDestination(env.orgID, "quote-batch-crypto-merge-mixed")
if len(quote.Items) != 2 {
t.Fatalf("expected 2 quote items, got=%d", len(quote.Items))
}
quote.Items[0].Quote.Route.RouteRef = "route-a"
quote.Items[1].Intent.Source = testLedgerEndpoint("ledger-src-2")
quote.Items[1].Quote.Route.RouteRef = "route-b"
env.quotes.Put(quote)
resp, err := env.svc.ExecuteBatchPayment(context.Background(), &orchestrationv2.ExecuteBatchPaymentRequest{
Meta: testMeta(env.orgID, "idem-batch-crypto-merge-mixed"),
QuotationRef: "quote-batch-crypto-merge-mixed",
ClientPaymentRef: "client-batch-crypto-merge-mixed",
})
if err != nil {
t.Fatalf("ExecuteBatchPayment returned error: %v", err)
}
if got, want := len(resp.GetPayments()), 1; got != want {
t.Fatalf("expected %d merged payment for crypto policy, got=%d", want, got)
}
}
func TestExecuteBatchPayment_IdempotentRetry(t *testing.T) {
env := newTestEnv(t, func(_ string, req sexec.StepRequest) (*sexec.ExecuteOutput, error) {
step := req.StepExecution

View File

@@ -17,11 +17,21 @@ const (
BatchOptimizationModeNoOptimization BatchOptimizationMode = "no_optimization"
)
// BatchOptimizationGrouping defines which target key is used for merge grouping.
type BatchOptimizationGrouping string
const (
BatchOptimizationGroupingUnspecified BatchOptimizationGrouping = ""
BatchOptimizationGroupingOperationDestination BatchOptimizationGrouping = "operation_destination"
BatchOptimizationGroupingOperationSource BatchOptimizationGrouping = "operation_source"
BatchOptimizationGroupingRailTarget BatchOptimizationGrouping = "rail_target"
)
// BatchOptimizationPolicy configures batch operation compaction behavior.
//
// Rules are evaluated by "best match":
// 1. all specified match fields must match the item context;
// 2. winner is highest specificity (number of optional selectors present);
// 2. winner is highest specificity (number of optional match fields present);
// 3. ties are resolved by larger Priority;
// 4. remaining ties keep the first rule order from config.
//
@@ -37,6 +47,7 @@ type BatchOptimizationRule struct {
Enabled *bool
Priority int
Mode BatchOptimizationMode
GroupBy BatchOptimizationGrouping
Match BatchOptimizationMatch
}
@@ -67,9 +78,11 @@ type BatchOptimizationContext struct {
// BatchOptimizationSelection is the selected optimization decision.
type BatchOptimizationSelection struct {
Mode BatchOptimizationMode
RuleID string
Matched bool
Mode BatchOptimizationMode
RuleID string
Matched bool
GroupBy BatchOptimizationGrouping
MatchRail model.Rail
}
func defaultBatchOptimizationPolicy() BatchOptimizationPolicy {
@@ -107,10 +120,14 @@ func normalizeBatchOptimizationRule(in BatchOptimizationRule) (BatchOptimization
Enabled: in.Enabled,
Priority: in.Priority,
Mode: normalizeBatchOptimizationMode(in.Mode),
GroupBy: normalizeBatchOptimizationGrouping(in.GroupBy),
}
if out.Mode == BatchOptimizationModeUnspecified {
return BatchOptimizationRule{}, false
}
if out.GroupBy == BatchOptimizationGroupingUnspecified {
out.GroupBy = BatchOptimizationGroupingOperationDestination
}
match, ok := normalizeBatchOptimizationMatch(in.Match)
if !ok {
@@ -174,41 +191,78 @@ func normalizeBatchOptimizationAmountRange(in BatchOptimizationAmountRange) (Bat
}
func (p BatchOptimizationPolicy) Select(in BatchOptimizationContext) BatchOptimizationSelection {
return p.SelectMany([]BatchOptimizationContext{in})
}
func (p BatchOptimizationPolicy) SelectMany(inputs []BatchOptimizationContext) BatchOptimizationSelection {
policy := normalizeBatchOptimizationPolicy(p)
ctx := normalizeBatchOptimizationContext(in)
contexts := make([]BatchOptimizationContext, 0, len(inputs))
for i := range inputs {
ctx := normalizeBatchOptimizationContext(inputs[i])
if ctx.Rail == discovery.RailUnspecified {
continue
}
contexts = append(contexts, ctx)
}
selected := BatchOptimizationSelection{
Mode: policy.DefaultMode,
Mode: policy.DefaultMode,
GroupBy: BatchOptimizationGroupingOperationDestination,
MatchRail: discovery.RailUnspecified,
}
if len(contexts) == 0 {
return selected
}
bestSpecificity := -1
bestPriority := 0
bestOrder := len(policy.Rules) + 1
for i := range policy.Rules {
rule := policy.Rules[i]
if rule.Enabled != nil && !*rule.Enabled {
continue
}
specificity, ok := rule.matches(ctx)
if !ok {
continue
}
if specificity > bestSpecificity ||
(specificity == bestSpecificity && rule.Priority > bestPriority) ||
(specificity == bestSpecificity && rule.Priority == bestPriority && i < bestOrder) {
selected = BatchOptimizationSelection{
Mode: rule.Mode,
RuleID: rule.ID,
Matched: true,
bestContext := len(contexts) + 1
for cIdx := range contexts {
ctx := contexts[cIdx]
for rIdx := range policy.Rules {
rule := policy.Rules[rIdx]
if rule.Enabled != nil && !*rule.Enabled {
continue
}
specificity, ok := rule.matches(ctx)
if !ok {
continue
}
if specificity > bestSpecificity ||
(specificity == bestSpecificity && rule.Priority > bestPriority) ||
(specificity == bestSpecificity && rule.Priority == bestPriority && rIdx < bestOrder) ||
(specificity == bestSpecificity && rule.Priority == bestPriority && rIdx == bestOrder && cIdx < bestContext) {
selected = BatchOptimizationSelection{
Mode: rule.Mode,
RuleID: rule.ID,
Matched: true,
GroupBy: rule.GroupBy,
MatchRail: rule.Match.Rail,
}
bestSpecificity = specificity
bestPriority = rule.Priority
bestOrder = rIdx
bestContext = cIdx
}
bestSpecificity = specificity
bestPriority = rule.Priority
bestOrder = i
}
}
return selected
}
func normalizeBatchOptimizationGrouping(raw BatchOptimizationGrouping) BatchOptimizationGrouping {
switch strings.ToLower(strings.TrimSpace(string(raw))) {
case "operation_destination", "operation-destination", "destination", "intent_destination", "intent-destination":
return BatchOptimizationGroupingOperationDestination
case "operation_source", "operation-source", "source", "intent_source", "intent-source":
return BatchOptimizationGroupingOperationSource
case "rail_target", "rail-target", "by_rail_target", "by-rail-target":
return BatchOptimizationGroupingRailTarget
default:
return BatchOptimizationGroupingUnspecified
}
}
func (r BatchOptimizationRule) matches(ctx BatchOptimizationContext) (int, bool) {
match := r.Match
if match.Rail == discovery.RailUnspecified || ctx.Rail == discovery.RailUnspecified {

View File

@@ -53,6 +53,9 @@ func TestNormalizeBatchOptimizationPolicy_DropsInvalidRules(t *testing.T) {
if got.Rules[0].ID != "valid" {
t.Fatalf("rule id mismatch: got=%q want=%q", got.Rules[0].ID, "valid")
}
if got, want := got.Rules[0].GroupBy, BatchOptimizationGroupingOperationDestination; got != want {
t.Fatalf("group_by mismatch: got=%q want=%q", got, want)
}
}
func TestBatchOptimizationPolicy_Select_UsesBestSpecificityThenPriority(t *testing.T) {
@@ -101,6 +104,12 @@ func TestBatchOptimizationPolicy_Select_UsesBestSpecificityThenPriority(t *testi
if out.Mode != BatchOptimizationModeMergeByDestination {
t.Fatalf("mode mismatch: got=%q want=%q", out.Mode, BatchOptimizationModeMergeByDestination)
}
if got, want := out.GroupBy, BatchOptimizationGroupingOperationDestination; got != want {
t.Fatalf("group_by mismatch: got=%q want=%q", got, want)
}
if got, want := string(out.MatchRail), string(discovery.RailCrypto); got != want {
t.Fatalf("match rail mismatch: got=%q want=%q", got, want)
}
}
func TestBatchOptimizationPolicy_Select_FallsBackToDefault(t *testing.T) {
@@ -124,6 +133,9 @@ func TestBatchOptimizationPolicy_Select_FallsBackToDefault(t *testing.T) {
if out.Mode != BatchOptimizationModeNoOptimization {
t.Fatalf("mode mismatch: got=%q want=%q", out.Mode, BatchOptimizationModeNoOptimization)
}
if got, want := out.GroupBy, BatchOptimizationGroupingOperationDestination; got != want {
t.Fatalf("default group_by mismatch: got=%q want=%q", got, want)
}
}
func TestBatchOptimizationPolicy_Select_MatchesAmountRange(t *testing.T) {
@@ -165,3 +177,26 @@ func TestBatchOptimizationPolicy_Select_MatchesAmountRange(t *testing.T) {
t.Fatalf("default mode mismatch: got=%q want=%q", notMatched.Mode, BatchOptimizationModeNoOptimization)
}
}
func TestBatchOptimizationPolicy_Select_ReturnsConfiguredGroupBy(t *testing.T) {
policy := BatchOptimizationPolicy{
DefaultMode: BatchOptimizationModeNoOptimization,
Rules: []BatchOptimizationRule{
{
ID: "crypto-by-rail-target",
Mode: BatchOptimizationModeMergeByDestination,
GroupBy: BatchOptimizationGroupingRailTarget,
Match: BatchOptimizationMatch{
Rail: discovery.RailCrypto,
},
},
},
}
out := policy.Select(BatchOptimizationContext{Rail: discovery.RailCrypto})
if !out.Matched {
t.Fatal("expected matched rule")
}
if got, want := out.GroupBy, BatchOptimizationGroupingRailTarget; got != want {
t.Fatalf("group_by mismatch: got=%q want=%q", got, want)
}
}

View File

@@ -0,0 +1,234 @@
package psvc
import (
"fmt"
"strings"
"github.com/shopspring/decimal"
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/batchmeta"
svcshared "github.com/tech/sendico/payments/orchestrator/internal/service/shared"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/discovery"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan"
)
func plannedMoneyForStep(
step xplan.Step,
intent model.PaymentIntent,
quote *model.PaymentQuoteSnapshot,
) (*paymenttypes.Money, *paymenttypes.Money) {
action := model.ParseRailOperation(string(step.Action))
switch step.Rail {
case discovery.RailLedger:
return plannedLedgerMoney(step, intent, quote, action), nil
case discovery.RailCrypto:
switch action {
case discovery.RailOperationSend:
return plannedSourceMoney(intent, quote), nil
case discovery.RailOperationFee:
return plannedFeeMoney(intent, quote), nil
}
case discovery.RailProviderSettlement:
if action == discovery.RailOperationFXConvert {
return plannedSourceMoney(intent, quote), plannedSettlementMoney(intent, quote)
}
case discovery.RailCardPayout:
if action == discovery.RailOperationSend {
return plannedCardPayoutMoney(step, intent, quote), nil
}
}
if override, ok := batchmeta.AmountFromMetadata(step.Metadata); ok {
return svcshared.CloneMoneyTrimNonEmpty(override), nil
}
return nil, nil
}
func plannedLedgerMoney(
step xplan.Step,
intent model.PaymentIntent,
quote *model.PaymentQuoteSnapshot,
action model.RailOperation,
) *paymenttypes.Money {
if override, ok := batchmeta.AmountFromMetadata(step.Metadata); ok {
return svcshared.CloneMoneyTrimNonEmpty(override)
}
sourceMoney := plannedSourceMoney(intent, quote)
settlementMoney := plannedSettlementMoney(intent, quote)
payoutMoney := settlementMoney
if fromRail, toRail, ok := plannedLedgerBoundaryRails(step.StepCode, quote); ok {
switch {
case isLedgerExternalRail(fromRail) && isLedgerExternalRail(toRail):
return sourceMoney
case isLedgerExternalRail(fromRail) && isLedgerInternalRail(toRail):
return settlementMoney
case isLedgerInternalRail(fromRail) && isLedgerExternalRail(toRail):
if toRail == discovery.RailCardPayout {
return payoutMoney
}
return settlementMoney
case isLedgerInternalRail(fromRail) && isLedgerInternalRail(toRail):
return settlementMoney
}
}
switch action {
case discovery.RailOperationCredit, discovery.RailOperationExternalCredit:
return settlementMoney
case discovery.RailOperationDebit, discovery.RailOperationExternalDebit:
if sourceMoney != nil {
return sourceMoney
}
return settlementMoney
case discovery.RailOperationMove, discovery.RailOperationBlock, discovery.RailOperationRelease:
if settlementMoney != nil {
return settlementMoney
}
return sourceMoney
default:
return nil
}
}
func plannedSourceMoney(intent model.PaymentIntent, quote *model.PaymentQuoteSnapshot) *paymenttypes.Money {
if quote != nil && quote.DebitAmount != nil {
return svcshared.CloneMoneyTrimNonEmpty(quote.DebitAmount)
}
return svcshared.CloneMoneyTrimNonEmpty(intent.Amount)
}
func plannedSettlementMoney(intent model.PaymentIntent, quote *model.PaymentQuoteSnapshot) *paymenttypes.Money {
if quote != nil && quote.ExpectedSettlementAmount != nil {
return svcshared.CloneMoneyTrimNonEmpty(quote.ExpectedSettlementAmount)
}
return plannedSourceMoney(intent, quote)
}
func plannedCardPayoutMoney(step xplan.Step, intent model.PaymentIntent, quote *model.PaymentQuoteSnapshot) *paymenttypes.Money {
if override, ok := batchmeta.AmountFromMetadata(step.Metadata); ok {
return svcshared.CloneMoneyTrimNonEmpty(override)
}
if quote != nil && quote.ExpectedSettlementAmount != nil {
return svcshared.CloneMoneyTrimNonEmpty(quote.ExpectedSettlementAmount)
}
return svcshared.CloneMoneyTrimNonEmpty(intent.Amount)
}
func plannedFeeMoney(intent model.PaymentIntent, quote *model.PaymentQuoteSnapshot) *paymenttypes.Money {
if quote == nil || len(quote.FeeLines) == 0 {
return nil
}
sourceCurrency := ""
if source := plannedSourceMoney(intent, quote); source != nil {
sourceCurrency = strings.TrimSpace(source.Currency)
}
total := decimal.Zero
currency := ""
for i := range quote.FeeLines {
line := quote.FeeLines[i]
if line == nil || line.GetMoney() == nil {
continue
}
lineCurrency := strings.TrimSpace(line.GetMoney().GetCurrency())
if lineCurrency == "" {
continue
}
if sourceCurrency != "" && !strings.EqualFold(sourceCurrency, lineCurrency) {
continue
}
if currency == "" {
currency = strings.ToUpper(lineCurrency)
} else if !strings.EqualFold(currency, lineCurrency) {
return nil
}
amountRaw := strings.TrimSpace(line.GetMoney().GetAmount())
amount, err := decimal.NewFromString(amountRaw)
if err != nil {
continue
}
amount = amount.Abs()
if amount.IsZero() {
continue
}
if line.GetSide() == paymenttypes.EntrySideCredit {
total = total.Sub(amount)
} else {
total = total.Add(amount)
}
}
if total.Sign() <= 0 || strings.TrimSpace(currency) == "" {
return nil
}
return &paymenttypes.Money{
Amount: total.String(),
Currency: strings.ToUpper(strings.TrimSpace(currency)),
}
}
func plannedLedgerBoundaryRails(stepCode string, quote *model.PaymentQuoteSnapshot) (model.Rail, model.Rail, bool) {
fromIndex, toIndex, ok := parseLedgerEdgeStepCode(stepCode)
if !ok || quote == nil || quote.Route == nil {
return discovery.RailUnspecified, discovery.RailUnspecified, false
}
var fromRail model.Rail = discovery.RailUnspecified
var toRail model.Rail = discovery.RailUnspecified
for i := range quote.Route.Hops {
hop := quote.Route.Hops[i]
if hop == nil {
continue
}
if hop.Index == fromIndex {
fromRail = model.ParseRail(hop.Rail)
}
if hop.Index == toIndex {
toRail = model.ParseRail(hop.Rail)
}
}
if fromRail == discovery.RailUnspecified || toRail == discovery.RailUnspecified {
return discovery.RailUnspecified, discovery.RailUnspecified, false
}
return fromRail, toRail, true
}
func parseLedgerEdgeStepCode(stepCode string) (uint32, uint32, bool) {
code := strings.ToLower(strings.TrimSpace(stepCode))
if !strings.HasPrefix(code, "edge.") || !strings.Contains(code, ".ledger.") {
return 0, 0, false
}
var (
from uint32
to uint32
op string
)
if _, err := fmt.Sscanf(code, "edge.%d_%d.ledger.%s", &from, &to, &op); err != nil {
return 0, 0, false
}
if strings.TrimSpace(op) == "" {
return 0, 0, false
}
return from, to, true
}
func isLedgerInternalRail(rail model.Rail) bool {
return rail == discovery.RailLedger
}
func isLedgerExternalRail(rail model.Rail) bool {
switch rail {
case discovery.RailCrypto, discovery.RailProviderSettlement, discovery.RailCardPayout, discovery.RailFiatOnRamp:
return true
default:
return false
}
}

View File

@@ -13,6 +13,7 @@ import (
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/sexec"
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/ssched"
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan"
svcshared "github.com/tech/sendico/payments/orchestrator/internal/service/shared"
"github.com/tech/sendico/pkg/merrors"
"go.uber.org/zap"
)
@@ -325,8 +326,10 @@ 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.ExecutedMoney = svcshared.CloneMoneyTrimNonEmpty(out.StepExecution.ExecutedMoney)
next.ConvertedMoney = svcshared.CloneMoneyTrimNonEmpty(out.StepExecution.ConvertedMoney)
next.PlannedMoney = svcshared.CloneMoneyTrimNonEmpty(out.StepExecution.PlannedMoney)
next.PlannedConvertedMoney = svcshared.CloneMoneyTrimNonEmpty(out.StepExecution.PlannedConvertedMoney)
next.FailureCode = strings.TrimSpace(out.StepExecution.FailureCode)
next.FailureMsg = strings.TrimSpace(out.StepExecution.FailureMsg)
@@ -445,6 +448,12 @@ func stepExecutionEqual(left, right agg.StepExecution) bool {
if !stepMoneyEqual(left.ConvertedMoney, right.ConvertedMoney) {
return false
}
if !stepMoneyEqual(left.PlannedMoney, right.PlannedMoney) {
return false
}
if !stepMoneyEqual(left.PlannedConvertedMoney, right.PlannedConvertedMoney) {
return false
}
return true
}

View File

@@ -1,29 +1,13 @@
package psvc
import (
"strings"
svcshared "github.com/tech/sendico/payments/orchestrator/internal/service/shared"
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)
left = svcshared.CloneMoneyTrimNonEmpty(left)
right = svcshared.CloneMoneyTrimNonEmpty(right)
switch {
case left == nil && right == nil:
return true

View File

@@ -207,6 +207,19 @@ func TestExecute_UnsupportedProviderSettlementSend(t *testing.T) {
}
}
func TestExecute_UnsupportedLedgerFee(t *testing.T) {
registry := New(Dependencies{})
_, err := registry.Execute(context.Background(), ExecuteInput{
Payment: &agg.Payment{PaymentRef: "p1"},
Step: xplan.Step{StepRef: "s1", StepCode: "ledger.fee", Action: discovery.RailOperationFee, Rail: discovery.RailLedger},
StepExecution: agg.StepExecution{StepRef: "s1", StepCode: "ledger.fee", Attempt: 1},
})
if !errors.Is(err, ErrUnsupportedStep) {
t.Fatalf("expected ErrUnsupportedStep, got %v", err)
}
}
func TestExecute_MissingExecutor(t *testing.T) {
registry := New(Dependencies{})

View File

@@ -4,11 +4,12 @@ import (
"strings"
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/oshared"
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan"
svcshared "github.com/tech/sendico/payments/orchestrator/internal/service/shared"
"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,9 +175,11 @@ func (s *svc) normalizeStepExecution(exec agg.StepExecution, index int) (agg.Ste
exec.InstanceID = strings.TrimSpace(exec.InstanceID)
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)
exec.ExternalRefs = oshared.CloneExternalRefs(exec.ExternalRefs)
exec.ExecutedMoney = svcshared.CloneMoneyTrimNonEmpty(exec.ExecutedMoney)
exec.ConvertedMoney = svcshared.CloneMoneyTrimNonEmpty(exec.ConvertedMoney)
exec.PlannedMoney = svcshared.CloneMoneyTrimNonEmpty(exec.PlannedMoney)
exec.PlannedConvertedMoney = svcshared.CloneMoneyTrimNonEmpty(exec.PlannedConvertedMoney)
if exec.StepRef == "" {
return agg.StepExecution{}, merrors.InvalidArgument("stepExecutions[" + itoa(index) + "].step_ref is required")
}
@@ -212,15 +215,17 @@ func seedMissingExecutions(
maxAttemptsByRef[stepRef] = 1
}
executionsByRef[stepRef] = &agg.StepExecution{
StepRef: step.StepRef,
StepCode: step.StepCode,
Rail: step.Rail,
Gateway: strings.TrimSpace(step.Gateway),
InstanceID: strings.TrimSpace(step.InstanceID),
ReportVisibility: effectiveStepVisibility(model.ReportVisibilityUnspecified, step.Visibility),
UserLabel: strings.TrimSpace(step.UserLabel),
State: agg.StepStatePending,
Attempt: attempt,
StepRef: step.StepRef,
StepCode: step.StepCode,
Rail: step.Rail,
Gateway: strings.TrimSpace(step.Gateway),
InstanceID: strings.TrimSpace(step.InstanceID),
ReportVisibility: effectiveStepVisibility(model.ReportVisibilityUnspecified, step.Visibility),
UserLabel: strings.TrimSpace(step.UserLabel),
State: agg.StepStatePending,
Attempt: attempt,
PlannedMoney: nil,
PlannedConvertedMoney: nil,
}
}
}
@@ -259,39 +264,11 @@ 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
}
out := make([]agg.ExternalRef, 0, len(refs))
for i := range refs {
ref := refs[i]
ref.GatewayInstanceID = strings.TrimSpace(ref.GatewayInstanceID)
ref.Kind = strings.TrimSpace(ref.Kind)
ref.Ref = strings.TrimSpace(ref.Ref)
out = append(out, ref)
}
out.ExternalRefs = oshared.CloneExternalRefs(exec.ExternalRefs)
out.ExecutedMoney = svcshared.CloneMoneyTrimNonEmpty(exec.ExecutedMoney)
out.ConvertedMoney = svcshared.CloneMoneyTrimNonEmpty(exec.ConvertedMoney)
out.PlannedMoney = svcshared.CloneMoneyTrimNonEmpty(exec.PlannedMoney)
out.PlannedConvertedMoney = svcshared.CloneMoneyTrimNonEmpty(exec.PlannedConvertedMoney)
return out
}

View File

@@ -170,7 +170,7 @@ func TestCompile_ExternalToExternal_WithWalletFee_InsertsFeeStep(t *testing.T) {
}
}
func TestCompile_ExternalToExternal_IgnoresNonWalletFeeLines(t *testing.T) {
func TestCompile_ExternalToExternal_IncludesNonWalletFeeLines(t *testing.T) {
compiler := New()
graph, err := compiler.Compile(Input{
@@ -195,14 +195,18 @@ func TestCompile_ExternalToExternal_IgnoresNonWalletFeeLines(t *testing.T) {
if err != nil {
t.Fatalf("Compile returned error: %v", err)
}
if got, want := len(graph.Steps), 9; got != want {
t.Fatalf("expected 9 steps, got %d", got)
if got, want := len(graph.Steps), 10; got != want {
t.Fatalf("expected 10 steps, got %d", got)
}
feeCount := 0
for i := range graph.Steps {
if got := graph.Steps[i].Action; got == discovery.RailOperationFee {
t.Fatalf("unexpected fee step at index %d: %+v", i, graph.Steps[i])
if graph.Steps[i].Action == discovery.RailOperationFee {
feeCount++
}
}
if got, want := feeCount, 1; got != want {
t.Fatalf("fee steps mismatch: got=%d want=%d", got, want)
}
}
func TestCompile_InternalToExternal_UsesHoldAndSettlementBranches(t *testing.T) {
@@ -239,6 +243,51 @@ func TestCompile_InternalToExternal_UsesHoldAndSettlementBranches(t *testing.T)
}
}
func TestCompile_InternalToExternal_WithWalletFee_InsertsFeeStep(t *testing.T) {
compiler := New()
graph, err := compiler.Compile(Input{
IntentSnapshot: testIntent(model.PaymentKindPayout),
QuoteSnapshot: &model.PaymentQuoteSnapshot{
DebitAmount: &paymenttypes.Money{Amount: "100", Currency: "USDT"},
FeeLines: []*paymenttypes.FeeLine{
{
Money: &paymenttypes.Money{Amount: "1", Currency: "USDT"},
Side: paymenttypes.EntrySideDebit,
Meta: map[string]string{"fee_target": "wallet"},
},
},
Route: &paymenttypes.QuoteRouteSpecification{
Hops: []*paymenttypes.QuoteRouteHop{
{Index: 10, Rail: "LEDGER", Role: paymenttypes.QuoteRouteHopRoleSource},
{Index: 20, Rail: "CARD", Gateway: "gw-card", Role: paymenttypes.QuoteRouteHopRoleDestination},
},
},
},
})
if err != nil {
t.Fatalf("Compile returned error: %v", err)
}
if len(graph.Steps) != 7 {
t.Fatalf("expected 7 steps, got %d", len(graph.Steps))
}
assertStep(t, graph.Steps[0], "edge.10_20.ledger.block", discovery.RailOperationBlock, discovery.RailLedger, model.ReportVisibilityHidden)
assertStep(t, graph.Steps[1], "hop.10.ledger.fee", discovery.RailOperationFee, discovery.RailLedger, model.ReportVisibilityHidden)
assertStep(t, graph.Steps[2], "hop.20.card_payout.send", discovery.RailOperationSend, discovery.RailCardPayout, model.ReportVisibilityUser)
assertStep(t, graph.Steps[3], "hop.20.card_payout.observe", discovery.RailOperationObserveConfirm, discovery.RailCardPayout, model.ReportVisibilityUser)
assertStep(t, graph.Steps[4], "edge.10_20.ledger.debit", discovery.RailOperationExternalDebit, discovery.RailLedger, model.ReportVisibilityHidden)
assertStep(t, graph.Steps[5], "edge.10_20.ledger.release", discovery.RailOperationRelease, discovery.RailLedger, model.ReportVisibilityHidden)
assertStep(t, graph.Steps[6], "edge.10_20.ledger.release", discovery.RailOperationRelease, discovery.RailLedger, model.ReportVisibilityHidden)
if got, want := graph.Steps[1].DependsOn, []string{graph.Steps[0].StepRef}; !equalStringSlice(got, want) {
t.Fatalf("fee deps mismatch: got=%v want=%v", got, want)
}
if got, want := graph.Steps[2].DependsOn, []string{graph.Steps[1].StepRef}; !equalStringSlice(got, want) {
t.Fatalf("send deps mismatch: got=%v want=%v", got, want)
}
}
func TestCompile_ExternalToInternal_UsesCreditAfterObserve(t *testing.T) {
compiler := New()

View File

@@ -51,28 +51,28 @@ func (e *expansion) nextRef(base string) string {
return token + "_" + itoa(count+1)
}
func (e *expansion) needsWalletFeeStep(hop normalizedHop) bool {
if e == nil || len(e.walletFeeHops) == 0 {
func (e *expansion) needsFeeStep(hop normalizedHop) bool {
if e == nil || len(e.feeHops) == 0 {
return false
}
key := observedKey(hop)
if _, ok := e.walletFeeHops[key]; !ok {
if _, ok := e.feeHops[key]; !ok {
return false
}
if _, emitted := e.walletFeeEmitted[key]; emitted {
if _, emitted := e.feeEmitted[key]; emitted {
return false
}
return true
}
func (e *expansion) markWalletFeeEmitted(hop normalizedHop) {
func (e *expansion) markFeeEmitted(hop normalizedHop) {
if e == nil {
return
}
if e.walletFeeEmitted == nil {
e.walletFeeEmitted = map[string]struct{}{}
if e.feeEmitted == nil {
e.feeEmitted = map[string]struct{}{}
}
e.walletFeeEmitted[observedKey(hop)] = struct{}{}
e.feeEmitted[observedKey(hop)] = struct{}{}
}
func normalizeStep(step Step) Step {

View File

@@ -5,27 +5,26 @@ import (
"strings"
"github.com/shopspring/decimal"
"github.com/tech/sendico/pkg/discovery"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/merrors"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
)
func planWalletFeeHops(
func planFeeHops(
hops []normalizedHop,
intent model.PaymentIntent,
quote *model.PaymentQuoteSnapshot,
) (map[string]struct{}, error) {
_, hasWalletFee, err := walletFeeAmountFromSnapshots(intent, quote)
_, hasFee, err := feeAmountFromSnapshots(intent, quote)
if err != nil {
return nil, err
}
if !hasWalletFee {
if !hasFee {
return nil, nil
}
sourceHop, ok := sourceCryptoHop(hops)
sourceHop, ok := sourceFeeHop(hops)
if !ok {
return nil, nil
}
@@ -34,19 +33,19 @@ func planWalletFeeHops(
}, nil
}
func sourceCryptoHop(hops []normalizedHop) (normalizedHop, bool) {
func sourceFeeHop(hops []normalizedHop) (normalizedHop, bool) {
for i := range hops {
if hops[i].rail == discovery.RailCrypto && hops[i].role == paymenttypes.QuoteRouteHopRoleSource {
if hops[i].role == paymenttypes.QuoteRouteHopRoleSource {
return hops[i], true
}
}
if len(hops) > 0 && hops[0].rail == discovery.RailCrypto {
if len(hops) > 0 {
return hops[0], true
}
return normalizedHop{}, false
}
func walletFeeAmountFromSnapshots(
func feeAmountFromSnapshots(
intent model.PaymentIntent,
quote *model.PaymentQuoteSnapshot,
) (*paymenttypes.Money, bool, error) {
@@ -62,10 +61,7 @@ func walletFeeAmountFromSnapshots(
total := decimal.Zero
currency := ""
for i, line := range quote.FeeLines {
if !isWalletDebitFeeLine(line) {
continue
}
money := line.GetMoney()
money := feeLineMoney(line)
if money == nil {
continue
}
@@ -80,7 +76,7 @@ func walletFeeAmountFromSnapshots(
if currency == "" {
currency = lineCurrency
} else if !strings.EqualFold(currency, lineCurrency) {
return nil, false, merrors.InvalidArgument("quote_snapshot.fee_lines wallet fee currency mismatch")
return nil, false, merrors.InvalidArgument("quote_snapshot.fee_lines fee currency mismatch")
}
amountRaw := strings.TrimSpace(money.GetAmount())
@@ -88,10 +84,12 @@ func walletFeeAmountFromSnapshots(
if err != nil {
return nil, false, merrors.InvalidArgument(fmt.Sprintf("quote_snapshot.fee_lines[%d].money.amount is invalid", i))
}
if amount.Sign() < 0 {
amount = amount.Neg()
amount = amount.Abs()
if amount.IsZero() {
continue
}
if amount.Sign() == 0 {
if line.GetSide() == paymenttypes.EntrySideCredit {
total = total.Sub(amount)
continue
}
total = total.Add(amount)
@@ -113,16 +111,9 @@ func feePlanningSourceAmount(intent model.PaymentIntent, quote *model.PaymentQuo
return intent.Amount
}
func isWalletDebitFeeLine(line *paymenttypes.FeeLine) bool {
func feeLineMoney(line *paymenttypes.FeeLine) *paymenttypes.Money {
if line == nil {
return false
return nil
}
if line.GetSide() != paymenttypes.EntrySideDebit {
return false
}
meta := line.Meta
if len(meta) == 0 {
return false
}
return strings.EqualFold(strings.TrimSpace(meta["fee_target"]), "wallet")
return line.GetMoney()
}

View File

@@ -30,16 +30,16 @@ type expansion struct {
lastMainRef string
refSeq map[string]int
externalObserved map[string]string
walletFeeHops map[string]struct{}
walletFeeEmitted map[string]struct{}
feeHops map[string]struct{}
feeEmitted map[string]struct{}
}
func newExpansion(walletFeeHops map[string]struct{}) *expansion {
func newExpansion(feeHops map[string]struct{}) *expansion {
return &expansion{
refSeq: map[string]int{},
externalObserved: map[string]string{},
walletFeeHops: walletFeeHops,
walletFeeEmitted: map[string]struct{}{},
feeHops: feeHops,
feeEmitted: map[string]struct{}{},
}
}
@@ -90,12 +90,12 @@ func (s *svc) Compile(in Input) (graph *Graph, err error) {
return nil, err
}
walletFeeHops, err := planWalletFeeHops(hops, in.IntentSnapshot, in.QuoteSnapshot)
feeHops, err := planFeeHops(hops, in.IntentSnapshot, in.QuoteSnapshot)
if err != nil {
return nil, err
}
ex := newExpansion(walletFeeHops)
ex := newExpansion(feeHops)
appendGuards(ex, conditions)
if len(hops) == 1 {

View File

@@ -18,7 +18,7 @@ func (s *svc) expandSingleHop(ex *expansion, hop normalizedHop, intent model.Pay
switch hop.role {
case paymenttypes.QuoteRouteHopRoleSource:
ex.appendMain(Step{
appendMainWithOptionalFee(ex, Step{
StepCode: singleHopCode(hop, "debit"),
Kind: StepKindFundsDebit,
Action: discovery.RailOperationDebit,
@@ -26,7 +26,7 @@ func (s *svc) expandSingleHop(ex *expansion, hop normalizedHop, intent model.Pay
HopIndex: hop.index,
HopRole: hop.role,
Visibility: model.ReportVisibilityHidden,
})
}, hop)
case paymenttypes.QuoteRouteHopRoleDestination:
ex.appendMain(Step{
StepCode: singleHopCode(hop, "credit"),
@@ -74,7 +74,7 @@ func (s *svc) applyDefaultBoundary(
if to.rail == discovery.RailCardPayout && len(targets) > 0 {
return s.applyBatchCardPayoutBoundary(ex, from, to, internalRail, intent, targets)
}
ex.appendMain(makeFundsBlockStep(from, to, internalRail))
appendMainWithOptionalFee(ex, makeFundsBlockStep(from, to, internalRail), from)
observeRef, err := s.ensureExternalObserved(ex, to, intent)
if err != nil {
return err
@@ -97,7 +97,7 @@ func (s *svc) applyDefaultBoundary(
return nil
case isInternalRail(from.rail) && isInternalRail(to.rail):
ex.appendMain(makeFundsMoveStep(from, to, internalRailForBoundary(from, to)))
appendMainWithOptionalFee(ex, makeFundsMoveStep(from, to, internalRailForBoundary(from, to)), from)
return nil
default:
@@ -113,7 +113,7 @@ func (s *svc) applyBatchCardPayoutBoundary(
intent model.PaymentIntent,
targets []batchmeta.PayoutTarget,
) error {
blockRef := ex.appendMain(makeFundsBlockStep(from, to, internalRail))
blockRef := appendMainWithOptionalFee(ex, makeFundsBlockStep(from, to, internalRail), from)
for i := range targets {
target := targets[i]
if target.Amount == nil {
@@ -148,13 +148,13 @@ func (s *svc) ensureExternalObserved(ex *expansion, hop normalizedHop, intent mo
sendStep := makeRailSendStep(hop, intent)
sendRef := ex.appendMain(sendStep)
observeDependency := sendRef
if ex.needsWalletFeeStep(hop) {
if ex.needsFeeStep(hop) {
feeStep := makeRailFeeStep(hop)
if observeDependency != "" {
feeStep.DependsOn = []string{observeDependency}
}
observeDependency = ex.appendMain(feeStep)
ex.markWalletFeeEmitted(hop)
ex.markFeeEmitted(hop)
}
observeStep := makeRailObserveStep(hop, intent)
@@ -167,6 +167,20 @@ func (s *svc) ensureExternalObserved(ex *expansion, hop normalizedHop, intent mo
return observeRef, nil
}
func appendMainWithOptionalFee(ex *expansion, step Step, hop normalizedHop) string {
mainRef := ex.appendMain(step)
if !ex.needsFeeStep(hop) {
return mainRef
}
feeStep := makeRailFeeStep(hop)
if strings.TrimSpace(mainRef) != "" {
feeStep.DependsOn = []string{mainRef}
}
feeRef := ex.appendMain(feeStep)
ex.markFeeEmitted(hop)
return feeRef
}
func makeRailFeeStep(hop normalizedHop) Step {
return Step{
StepCode: singleHopCode(hop, "fee"),

View File

@@ -0,0 +1,119 @@
package orchestrator
import (
"strings"
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/psvc"
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/qsnap"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/discovery"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
)
type orchestratorBatchMergeKeyResolver struct {
cardGatewayRoutes map[string]CardGatewayRoute
}
func newOrchestratorBatchMergeKeyResolver(routes map[string]CardGatewayRoute) psvc.BatchOptimizationMergeKeyResolver {
return &orchestratorBatchMergeKeyResolver{
cardGatewayRoutes: cloneCardGatewayRoutes(routes),
}
}
func (r *orchestratorBatchMergeKeyResolver) ResolveMergeKey(
item qsnap.ResolvedItem,
selection psvc.BatchOptimizationSelection,
) (string, bool) {
if psvc.BatchOptimizationGrouping(strings.TrimSpace(string(selection.GroupBy))) != psvc.BatchOptimizationGroupingRailTarget {
return "", false
}
if model.ParseRail(string(selection.MatchRail)) != discovery.RailCrypto {
return "", false
}
return r.cryptoRailTargetKey(item)
}
func (r *orchestratorBatchMergeKeyResolver) cryptoRailTargetKey(item qsnap.ResolvedItem) (string, bool) {
destination := item.IntentSnapshot.Destination
if destination.Type == model.EndpointTypeExternalChain && destination.ExternalChain != nil {
return strings.Join([]string{
"type=external_chain",
"address=" + strings.TrimSpace(destination.ExternalChain.Address),
"memo=" + strings.TrimSpace(destination.ExternalChain.Memo),
}, "|"), true
}
if destination.Type == model.EndpointTypeManagedWallet && destination.ManagedWallet != nil {
return strings.Join([]string{
"type=managed_wallet",
"wallet=" + strings.TrimSpace(destination.ManagedWallet.ManagedWalletRef),
}, "|"), true
}
if destination.Type == model.EndpointTypeCard {
address := r.resolveCardFundingAddress(item.QuoteSnapshot)
if strings.TrimSpace(address) == "" {
return "", false
}
return strings.Join([]string{
"type=external_chain",
"address=" + strings.TrimSpace(address),
"network=" + cryptoNetwork(item.QuoteSnapshot),
}, "|"), true
}
return "", false
}
func (r *orchestratorBatchMergeKeyResolver) resolveCardFundingAddress(snapshot *model.PaymentQuoteSnapshot) string {
gatewayKey := destinationCardGatewayKeyFromQuote(snapshot)
if gatewayKey == "" {
return ""
}
route, ok := lookupCardGatewayRoute(r.cardGatewayRoutes, gatewayKey)
if !ok {
return ""
}
return strings.TrimSpace(route.FundingAddress)
}
func destinationCardGatewayKeyFromQuote(snapshot *model.PaymentQuoteSnapshot) string {
if snapshot == nil || snapshot.Route == nil {
return ""
}
hops := snapshot.Route.Hops
fallback := ""
for i := range hops {
hop := hops[i]
if hop == nil || model.ParseRail(hop.Rail) != discovery.RailCardPayout {
continue
}
key := firstNonEmpty(strings.TrimSpace(hop.Gateway), strings.TrimSpace(hop.InstanceID))
if key == "" {
continue
}
if hop.Role != paymenttypes.QuoteRouteHopRoleDestination {
fallback = key
continue
}
return key
}
return fallback
}
func cryptoNetwork(snapshot *model.PaymentQuoteSnapshot) string {
if snapshot == nil || snapshot.Route == nil {
return ""
}
for i := range snapshot.Route.Hops {
hop := snapshot.Route.Hops[i]
if hop == nil || model.ParseRail(hop.Rail) != discovery.RailCrypto {
continue
}
network := strings.TrimSpace(hop.Network)
if network != "" {
return strings.ToUpper(network)
}
}
if strings.TrimSpace(snapshot.Route.Network) != "" {
return strings.ToUpper(strings.TrimSpace(snapshot.Route.Network))
}
return ""
}

View File

@@ -67,7 +67,7 @@ func (e *gatewayCardPayoutExecutor) ExecuteCardPayout(ctx context.Context, req s
if err != nil {
return nil, err
}
amountMinor, currency, err := cardPayoutAmountMinor(req.Payment, req.Step.Metadata)
amountMinor, currency, err := cardPayoutAmountMinor(req.Payment, req.StepExecution, req.Step.Metadata)
if err != nil {
return nil, err
}
@@ -310,7 +310,10 @@ func payoutDestinationCard(payment *agg.Payment, metadata map[string]string) (*m
return destination.Card, nil
}
func cardPayoutMoney(payment *agg.Payment, metadata map[string]string) *paymenttypes.Money {
func cardPayoutMoney(payment *agg.Payment, stepExecution agg.StepExecution, metadata map[string]string) *paymenttypes.Money {
if planned := plannedStepMoney(stepExecution); planned != nil {
return planned
}
if override, ok := batchmeta.AmountFromMetadata(metadata); ok && override != nil {
return override
}
@@ -323,8 +326,8 @@ func cardPayoutMoney(payment *agg.Payment, metadata map[string]string) *paymentt
return payment.IntentSnapshot.Amount
}
func cardPayoutAmountMinor(payment *agg.Payment, metadata map[string]string) (int64, string, error) {
money := cardPayoutMoney(payment, metadata)
func cardPayoutAmountMinor(payment *agg.Payment, stepExecution agg.StepExecution, metadata map[string]string) (int64, string, error) {
money := cardPayoutMoney(payment, stepExecution, metadata)
if money == nil {
return 0, "", merrors.InvalidArgument("card payout send: payout amount is required")
}

View File

@@ -176,6 +176,86 @@ func TestGatewayCardPayoutExecutor_ExecuteCardPayout_SubmitsCardPayout(t *testin
}
}
func TestGatewayCardPayoutExecutor_ExecuteCardPayout_UsesPlannedMoney(t *testing.T) {
orgID := bson.NewObjectID()
var payoutReq *mntxv1.CardPayoutRequest
executor := &gatewayCardPayoutExecutor{
gatewayRegistry: &fakeGatewayRegistry{
items: []*model.GatewayInstanceDescriptor{
{
ID: paymenttypes.DefaultCardsGatewayID,
InstanceID: paymenttypes.DefaultCardsGatewayID,
Rail: discovery.RailCardPayout,
InvokeURI: "grpc://mntx-gateway:50051",
IsEnabled: true,
},
},
},
dialClient: func(_ context.Context, _ string) (mntxclient.Client, error) {
return &mntxclient.Fake{
CreateCardPayoutFn: func(_ context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error) {
payoutReq = req
return &mntxv1.CardPayoutResponse{
Payout: &mntxv1.CardPayoutState{
PayoutId: "payout-planned",
},
}, nil
},
}, nil
},
}
_, err := executor.ExecuteCardPayout(context.Background(), sexec.StepRequest{
Payment: &agg.Payment{
OrganizationBoundBase: pm.OrganizationBoundBase{OrganizationRef: orgID},
PaymentRef: "payment-planned",
IdempotencyKey: "idem-planned",
IntentSnapshot: model.PaymentIntent{
Ref: "intent-planned",
Destination: model.PaymentEndpoint{
Type: model.EndpointTypeCard,
Card: &model.CardEndpoint{
Pan: "2200700142860161",
ExpMonth: 3,
ExpYear: 2030,
},
},
Amount: &paymenttypes.Money{Amount: "1.000000", Currency: "USDT"},
},
QuoteSnapshot: &model.PaymentQuoteSnapshot{
ExpectedSettlementAmount: &paymenttypes.Money{Amount: "76.50", Currency: "RUB"},
},
},
Step: xplan.Step{
StepRef: "hop_4_card_payout_send",
StepCode: "hop.4.card_payout.send",
Action: discovery.RailOperationSend,
Rail: discovery.RailCardPayout,
Gateway: paymenttypes.DefaultCardsGatewayID,
InstanceID: paymenttypes.DefaultCardsGatewayID,
},
StepExecution: agg.StepExecution{
StepRef: "hop_4_card_payout_send",
StepCode: "hop.4.card_payout.send",
Attempt: 1,
PlannedMoney: &paymenttypes.Money{Amount: "100.00", Currency: "RUB"},
},
})
if err != nil {
t.Fatalf("ExecuteCardPayout returned error: %v", err)
}
if payoutReq == nil {
t.Fatal("expected payout request to be submitted")
}
if got, want := payoutReq.GetAmountMinor(), int64(10000); got != want {
t.Fatalf("amount_minor mismatch: got=%d want=%d", got, want)
}
if got, want := payoutReq.GetCurrency(), "RUB"; got != want {
t.Fatalf("currency mismatch: got=%q want=%q", got, want)
}
}
func TestGatewayCardPayoutExecutor_ExecuteCardPayout_UsesStepMetadataOverrides(t *testing.T) {
orgID := bson.NewObjectID()

View File

@@ -51,7 +51,7 @@ func (e *gatewayCryptoExecutor) ExecuteCrypto(ctx context.Context, req sexec.Ste
if err != nil {
return nil, err
}
amount, err := sourceAmount(req.Payment, action)
amount, err := sourceAmount(req.Payment, req.StepExecution, action)
if err != nil {
return nil, err
}
@@ -170,19 +170,26 @@ func sourceManagedWalletRef(payment *agg.Payment) (string, error) {
return ref, nil
}
func sourceAmount(payment *agg.Payment, action model.RailOperation) (*moneyv1.Money, error) {
func sourceAmount(payment *agg.Payment, stepExecution agg.StepExecution, action model.RailOperation) (*moneyv1.Money, error) {
if planned := plannedStepMoney(stepExecution); planned != nil {
proto := paymentMoneyToProto(planned)
if proto == nil {
return nil, merrors.InvalidArgument("crypto send: planned amount is invalid")
}
return proto, nil
}
if payment == nil {
return nil, merrors.InvalidArgument("crypto send: payment is required")
}
var money *paymenttypes.Money
switch action {
case discovery.RailOperationFee:
resolved, ok, err := walletFeeAmount(payment)
resolved, ok, err := feeAmount(payment)
if err != nil {
return nil, err
}
if !ok {
return nil, merrors.InvalidArgument("crypto send: wallet fee amount is required")
return nil, merrors.InvalidArgument("crypto send: fee amount is required")
}
money = resolved
default:
@@ -212,7 +219,7 @@ func effectiveSourceAmount(payment *agg.Payment) *paymenttypes.Money {
return payment.IntentSnapshot.Amount
}
func walletFeeAmount(payment *agg.Payment) (*paymenttypes.Money, bool, error) {
func feeAmount(payment *agg.Payment) (*paymenttypes.Money, bool, error) {
if payment == nil || payment.QuoteSnapshot == nil || len(payment.QuoteSnapshot.FeeLines) == 0 {
return nil, false, nil
}
@@ -225,9 +232,6 @@ func walletFeeAmount(payment *agg.Payment) (*paymenttypes.Money, bool, error) {
total := decimal.Zero
currency := ""
for i, line := range payment.QuoteSnapshot.FeeLines {
if !isWalletDebitFeeLine(line) {
continue
}
money := line.GetMoney()
if money == nil {
continue
@@ -243,7 +247,7 @@ func walletFeeAmount(payment *agg.Payment) (*paymenttypes.Money, bool, error) {
if currency == "" {
currency = lineCurrency
} else if !strings.EqualFold(currency, lineCurrency) {
return nil, false, merrors.InvalidArgument("crypto send: wallet fee currency mismatch")
return nil, false, merrors.InvalidArgument("crypto send: fee currency mismatch")
}
amountRaw := strings.TrimSpace(money.GetAmount())
@@ -251,10 +255,12 @@ func walletFeeAmount(payment *agg.Payment) (*paymenttypes.Money, bool, error) {
if err != nil {
return nil, false, merrors.InvalidArgument(fmt.Sprintf("crypto send: fee_lines[%d].money.amount is invalid", i))
}
if amount.Sign() < 0 {
amount = amount.Neg()
amount = amount.Abs()
if amount.IsZero() {
continue
}
if amount.Sign() == 0 {
if line.GetSide() == paymenttypes.EntrySideCredit {
total = total.Sub(amount)
continue
}
total = total.Add(amount)
@@ -269,20 +275,6 @@ func walletFeeAmount(payment *agg.Payment) (*paymenttypes.Money, bool, error) {
}, true, nil
}
func isWalletDebitFeeLine(line *paymenttypes.FeeLine) bool {
if line == nil {
return false
}
if line.GetSide() != paymenttypes.EntrySideDebit {
return false
}
meta := line.Meta
if len(meta) == 0 {
return false
}
return strings.EqualFold(strings.TrimSpace(meta["fee_target"]), "wallet")
}
func (e *gatewayCryptoExecutor) resolveDestination(
ctx context.Context,
client chainclient.Client,

View File

@@ -138,6 +138,97 @@ func TestGatewayCryptoExecutor_ExecuteCrypto_SubmitsTransfer(t *testing.T) {
}
}
func TestGatewayCryptoExecutor_ExecuteCrypto_UsesPlannedMoney(t *testing.T) {
orgID := bson.NewObjectID()
var submitReq *chainv1.SubmitTransferRequest
client := &chainclient.Fake{
SubmitTransferFn: func(_ context.Context, req *chainv1.SubmitTransferRequest) (*chainv1.SubmitTransferResponse, error) {
submitReq = req
return &chainv1.SubmitTransferResponse{
Transfer: &chainv1.Transfer{
TransferRef: "trf-planned",
OperationRef: "op-planned",
},
}, nil
},
}
executor := &gatewayCryptoExecutor{
gatewayInvokeResolver: &fakeGatewayInvokeResolver{client: client},
gatewayRegistry: &fakeGatewayRegistry{
items: []*model.GatewayInstanceDescriptor{
{
ID: "crypto_rail_gateway_arbitrum_sepolia",
InstanceID: "crypto_rail_gateway_arbitrum_sepolia",
Rail: discovery.RailCrypto,
InvokeURI: "grpc://crypto-gateway",
IsEnabled: true,
},
},
},
cardGatewayRoutes: map[string]CardGatewayRoute{
paymenttypes.DefaultCardsGatewayID: {FundingAddress: "TUA_DEST"},
},
}
_, err := executor.ExecuteCrypto(context.Background(), sexec.StepRequest{
Payment: &agg.Payment{
OrganizationBoundBase: pm.OrganizationBoundBase{OrganizationRef: orgID},
PaymentRef: "payment-planned",
IdempotencyKey: "idem-planned",
IntentSnapshot: model.PaymentIntent{
Ref: "intent-planned",
Source: model.PaymentEndpoint{
Type: model.EndpointTypeManagedWallet,
ManagedWallet: &model.ManagedWalletEndpoint{
ManagedWalletRef: "wallet-src",
},
},
Destination: model.PaymentEndpoint{
Type: model.EndpointTypeCard,
Card: &model.CardEndpoint{Pan: "4111111111111111"},
},
Amount: &paymenttypes.Money{Amount: "1", Currency: "USDT"},
},
QuoteSnapshot: &model.PaymentQuoteSnapshot{
DebitAmount: &paymenttypes.Money{Amount: "1.000000", Currency: "USDT"},
Route: &paymenttypes.QuoteRouteSpecification{
Hops: []*paymenttypes.QuoteRouteHop{
{Index: 1, Rail: "CRYPTO", Gateway: "crypto_rail_gateway_arbitrum_sepolia", InstanceID: "crypto_rail_gateway_arbitrum_sepolia", Role: paymenttypes.QuoteRouteHopRoleSource},
{Index: 4, Rail: "CARD", Gateway: paymenttypes.DefaultCardsGatewayID, InstanceID: paymenttypes.DefaultCardsGatewayID, Role: paymenttypes.QuoteRouteHopRoleDestination},
},
},
},
},
Step: xplan.Step{
StepRef: "hop_1_crypto_send",
StepCode: "hop.1.crypto.send",
Action: discovery.RailOperationSend,
Rail: discovery.RailCrypto,
Gateway: "crypto_rail_gateway_arbitrum_sepolia",
InstanceID: "crypto_rail_gateway_arbitrum_sepolia",
},
StepExecution: agg.StepExecution{
StepRef: "hop_1_crypto_send",
StepCode: "hop.1.crypto.send",
Attempt: 1,
PlannedMoney: &paymenttypes.Money{Amount: "5.50", Currency: "USDT"},
},
})
if err != nil {
t.Fatalf("ExecuteCrypto returned error: %v", err)
}
if submitReq == nil {
t.Fatal("expected transfer submission request")
}
if got, want := submitReq.GetAmount().GetAmount(), "5.50"; got != want {
t.Fatalf("amount mismatch: got=%q want=%q", got, want)
}
if got, want := submitReq.GetAmount().GetCurrency(), "USDT"; got != want {
t.Fatalf("currency mismatch: got=%q want=%q", got, want)
}
}
func TestGatewayCryptoExecutor_ExecuteCrypto_MissingCardRoute(t *testing.T) {
orgID := bson.NewObjectID()
executor := &gatewayCryptoExecutor{
@@ -430,7 +521,7 @@ func TestGatewayCryptoExecutor_ExecuteCrypto_FeeActionResolvesFeeAddressFromFeeW
}
}
func TestGatewayCryptoExecutor_ExecuteCrypto_FeeActionUsesWalletFeeAmount(t *testing.T) {
func TestGatewayCryptoExecutor_ExecuteCrypto_FeeActionUsesAllFeeLines(t *testing.T) {
orgID := bson.NewObjectID()
var submitReq *chainv1.SubmitTransferRequest
@@ -493,6 +584,18 @@ func TestGatewayCryptoExecutor_ExecuteCrypto_FeeActionUsesWalletFeeAmount(t *tes
Side: paymenttypes.EntrySideDebit,
Meta: map[string]string{"fee_target": "wallet"},
},
{
Money: &paymenttypes.Money{Amount: "0.30", Currency: "USDT"},
LineType: paymenttypes.PostingLineTypeTax,
Side: paymenttypes.EntrySideDebit,
Meta: map[string]string{"fee_target": "ledger"},
},
{
Money: &paymenttypes.Money{Amount: "0.10", Currency: "USDT"},
LineType: paymenttypes.PostingLineTypeReversal,
Side: paymenttypes.EntrySideCredit,
Meta: map[string]string{"fee_target": "rebate"},
},
},
Route: &paymenttypes.QuoteRouteSpecification{
Hops: []*paymenttypes.QuoteRouteHop{
@@ -524,7 +627,7 @@ func TestGatewayCryptoExecutor_ExecuteCrypto_FeeActionUsesWalletFeeAmount(t *tes
if submitReq == nil {
t.Fatal("expected transfer submission")
}
if got, want := submitReq.GetAmount().GetAmount(), "0.7"; got != want {
if got, want := submitReq.GetAmount().GetAmount(), "0.9"; got != want {
t.Fatalf("fee amount mismatch: got=%q want=%q", got, want)
}
if got, want := submitReq.GetDestination().GetExternalAddress(), "TUA_FEE"; got != want {

View File

@@ -13,6 +13,7 @@ import (
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/erecon"
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/prepo"
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/psvc"
svcshared "github.com/tech/sendico/payments/orchestrator/internal/service/shared"
"github.com/tech/sendico/payments/storage/model"
cons "github.com/tech/sendico/pkg/messaging/consumer"
paymentgatewaynotifications "github.com/tech/sendico/pkg/messaging/notifications/paymentgateway"
@@ -159,7 +160,7 @@ func buildGatewayExecutionEvent(payment *agg.Payment, msg *pmodel.PaymentGateway
TransferRef: transferRef,
GatewayInstanceID: gatewayInstanceID,
Status: status,
ExecutedMoney: clonePaymentMoney(msg.ExecutedMoney),
ExecutedMoney: svcshared.CloneMoneyTrimNonEmpty(msg.ExecutedMoney),
}
switch status {

View File

@@ -56,7 +56,7 @@ func (e *gatewayLedgerExecutor) ExecuteLedger(ctx context.Context, req sexec.Ste
return nil, merrors.InvalidArgument("ledger step: unsupported action")
}
amount, err := ledgerAmountForStep(req.Payment, req.Step, action)
amount, err := ledgerAmountForStep(req.Payment, req.Step, req.StepExecution, action)
if err != nil {
return nil, err
}
@@ -132,8 +132,12 @@ func (e *gatewayLedgerExecutor) ExecuteLedger(ctx context.Context, req sexec.Ste
func ledgerAmountForStep(
payment *agg.Payment,
step xplan.Step,
stepExecution agg.StepExecution,
action model.RailOperation,
) (*moneyv1.Money, error) {
if planned := plannedStepMoney(stepExecution); planned != nil {
return protoMoneyRequired(planned, "ledger step: planned amount is invalid")
}
if override, ok := batchmeta.AmountFromMetadata(step.Metadata); ok && override != nil {
return protoMoneyRequired(override, "ledger step: override amount is invalid")
}

View File

@@ -95,6 +95,49 @@ func TestGatewayLedgerExecutor_ExecuteLedger_CreditUsesSourceAmountAndDefaultRol
}
}
func TestGatewayLedgerExecutor_ExecuteLedger_UsesPlannedMoney(t *testing.T) {
orgID := bson.NewObjectID()
payment := testLedgerExecutorPayment(orgID)
var transferReq *ledgerv1.TransferRequest
executor := &gatewayLedgerExecutor{
ledgerClient: &ledgerclient.Fake{
TransferInternalFn: func(_ context.Context, req *ledgerv1.TransferRequest) (*ledgerv1.PostResponse, error) {
transferReq = req
return &ledgerv1.PostResponse{JournalEntryRef: "entry-planned"}, nil
},
},
}
_, err := executor.ExecuteLedger(context.Background(), sexec.StepRequest{
Payment: payment,
Step: xplan.Step{
StepRef: "edge_1_2_ledger_credit",
StepCode: "edge.1_2.ledger.credit",
Action: discovery.RailOperationCredit,
Rail: discovery.RailLedger,
},
StepExecution: agg.StepExecution{
StepRef: "edge_1_2_ledger_credit",
StepCode: "edge.1_2.ledger.credit",
Attempt: 1,
PlannedMoney: &paymenttypes.Money{Amount: "12.34", Currency: "EUR"},
},
})
if err != nil {
t.Fatalf("ExecuteLedger returned error: %v", err)
}
if transferReq == nil {
t.Fatal("expected ledger transfer request")
}
if got, want := transferReq.GetMoney().GetAmount(), "12.34"; got != want {
t.Fatalf("money.amount mismatch: got=%q want=%q", got, want)
}
if got, want := transferReq.GetMoney().GetCurrency(), "EUR"; got != want {
t.Fatalf("money.currency mismatch: got=%q want=%q", got, want)
}
}
func TestGatewayLedgerExecutor_ExecuteLedger_ExternalCreditUsesPostCreditWithCharges(t *testing.T) {
orgID := bson.NewObjectID()
payment := testLedgerExecutorPayment(orgID)

View File

@@ -71,12 +71,17 @@ func newOrchestrationV2Service(logger mlogger.Logger, repo storage.Repository, r
executors := buildOrchestrationV2Executors(logger, runtimeDeps)
svc, err := psvc.New(psvc.Dependencies{
Logger: logger.Named("v2"),
QuoteStore: repo.Quotes(),
Repository: paymentRepo,
Query: query,
Observer: observer,
Executors: executors,
Logger: logger.Named("v2"),
QuoteStore: repo.Quotes(),
Repository: paymentRepo,
Query: query,
Observer: observer,
Executors: executors,
BatchOptimizer: psvc.NewPolicyBatchOptimizer(psvc.PolicyBatchOptimizerDependencies{
Logger: logger.Named("v2"),
Policy: runtimeDeps.BatchOptimizationPolicy,
MergeKeyResolver: newOrchestratorBatchMergeKeyResolver(runtimeDeps.CardGatewayRoutes),
}),
BatchOptimizationPolicy: runtimeDeps.BatchOptimizationPolicy,
Producer: runtimeDeps.Producer,
})

View File

@@ -2,13 +2,14 @@ package orchestrator
import (
"context"
"github.com/tech/sendico/pkg/discovery"
"strings"
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/sexec"
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan"
svcshared "github.com/tech/sendico/payments/orchestrator/internal/service/shared"
"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"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
@@ -52,11 +53,11 @@ func (e *gatewayProviderSettlementExecutor) ExecuteProviderSettlement(ctx contex
},
}
amount, err := settlementAmount(req.Payment)
amount, err := settlementAmount(req.Payment, req.StepExecution)
if err != nil {
return nil, err
}
convertedAmount := settlementConvertedMoney(req.Payment)
convertedAmount := settlementConvertedMoney(req.Payment, req.StepExecution)
if convertedAmount == nil {
return nil, merrors.InvalidArgument("settlement fx_convert: converted amount is required")
}
@@ -106,11 +107,14 @@ func (e *gatewayProviderSettlementExecutor) ExecuteProviderSettlement(ctx contex
return &sexec.ExecuteOutput{StepExecution: step}, nil
}
func settlementConvertedMoney(payment *agg.Payment) *paymenttypes.Money {
func settlementConvertedMoney(payment *agg.Payment, stepExecution agg.StepExecution) *paymenttypes.Money {
if planned := plannedStepConvertedMoney(stepExecution); planned != nil {
return planned
}
if payment == nil || payment.QuoteSnapshot == nil {
return nil
}
return clonePaymentMoney(payment.QuoteSnapshot.ExpectedSettlementAmount)
return svcshared.CloneMoneyTrimNonEmpty(payment.QuoteSnapshot.ExpectedSettlementAmount)
}
func (e *gatewayProviderSettlementExecutor) resolveGateway(ctx context.Context, step xplan.Step) (*model.GatewayInstanceDescriptor, error) {
@@ -165,7 +169,14 @@ func (e *gatewayProviderSettlementExecutor) resolveGateway(ctx context.Context,
}
}
func settlementAmount(payment *agg.Payment) (*moneyv1.Money, error) {
func settlementAmount(payment *agg.Payment, stepExecution agg.StepExecution) (*moneyv1.Money, error) {
if planned := plannedStepMoney(stepExecution); planned != nil {
amount := paymentMoneyToProto(planned)
if amount == nil {
return nil, merrors.InvalidArgument("settlement fx_convert: planned amount is invalid")
}
return amount, nil
}
if payment == nil {
return nil, merrors.InvalidArgument("settlement fx_convert: payment is required")
}

View File

@@ -151,6 +151,96 @@ func TestGatewayProviderSettlementExecutor_ExecuteProviderSettlement_SubmitsTran
}
}
func TestGatewayProviderSettlementExecutor_ExecuteProviderSettlement_UsesPlannedMoney(t *testing.T) {
orgID := bson.NewObjectID()
var submitReq *chainv1.SubmitTransferRequest
executor := &gatewayProviderSettlementExecutor{
gatewayInvokeResolver: &fakeGatewayInvokeResolver{
client: &chainclient.Fake{
SubmitTransferFn: func(_ context.Context, req *chainv1.SubmitTransferRequest) (*chainv1.SubmitTransferResponse, error) {
submitReq = req
return &chainv1.SubmitTransferResponse{
Transfer: &chainv1.Transfer{
TransferRef: "trf-planned",
OperationRef: "op-planned",
},
}, nil
},
},
},
gatewayRegistry: &fakeGatewayRegistry{
items: []*model.GatewayInstanceDescriptor{
{
ID: "payment_gateway_settlement",
InstanceID: "payment_gateway_settlement",
Rail: discovery.RailProviderSettlement,
InvokeURI: "grpc://tgsettle-gateway",
IsEnabled: true,
},
},
},
}
out, err := executor.ExecuteProviderSettlement(context.Background(), sexec.StepRequest{
Payment: &agg.Payment{
OrganizationBoundBase: pm.OrganizationBoundBase{OrganizationRef: orgID},
PaymentRef: "payment-planned",
IdempotencyKey: "idem-planned",
IntentSnapshot: model.PaymentIntent{
Ref: "intent-planned",
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"},
ExpectedSettlementAmount: &paymenttypes.Money{Amount: "76.63", Currency: "RUB"},
},
},
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,
PlannedMoney: &paymenttypes.Money{Amount: "2.00", Currency: "USDT"},
PlannedConvertedMoney: &paymenttypes.Money{Amount: "150.00", Currency: "RUB"},
},
})
if err != nil {
t.Fatalf("ExecuteProviderSettlement returned error: %v", err)
}
if submitReq == nil {
t.Fatal("expected transfer submission request")
}
if got, want := submitReq.GetAmount().GetAmount(), "2.00"; got != want {
t.Fatalf("amount mismatch: got=%q want=%q", got, want)
}
if got, want := submitReq.GetAmount().GetCurrency(), "USDT"; got != want {
t.Fatalf("currency mismatch: got=%q want=%q", got, want)
}
if out == nil || out.StepExecution.ConvertedMoney == nil {
t.Fatal("expected converted money in executor output")
}
if got, want := out.StepExecution.ConvertedMoney.Amount, "150.00"; got != want {
t.Fatalf("converted amount mismatch: got=%q want=%q", got, want)
}
if got, want := out.StepExecution.ConvertedMoney.Currency, "RUB"; got != want {
t.Fatalf("converted currency mismatch: got=%q want=%q", got, want)
}
}
func TestGatewayProviderSettlementExecutor_ExecuteProviderSettlement_MissingSettlementAmount(t *testing.T) {
orgID := bson.NewObjectID()

View File

@@ -4,6 +4,8 @@ import (
"strings"
"github.com/shopspring/decimal"
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
svcshared "github.com/tech/sendico/payments/orchestrator/internal/service/shared"
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"
@@ -11,21 +13,6 @@ import (
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
@@ -38,6 +25,26 @@ func protoMoneyToPaymentMoney(src *moneyv1.Money) *paymenttypes.Money {
return &paymenttypes.Money{Amount: amount, Currency: currency}
}
func paymentMoneyToProto(src *paymenttypes.Money) *moneyv1.Money {
if src == nil {
return nil
}
amount := strings.TrimSpace(src.GetAmount())
currency := strings.TrimSpace(src.GetCurrency())
if amount == "" || currency == "" {
return nil
}
return &moneyv1.Money{Amount: amount, Currency: currency}
}
func plannedStepMoney(step agg.StepExecution) *paymenttypes.Money {
return svcshared.CloneMoneyTrimNonEmpty(step.PlannedMoney)
}
func plannedStepConvertedMoney(step agg.StepExecution) *paymenttypes.Money {
return svcshared.CloneMoneyTrimNonEmpty(step.PlannedConvertedMoney)
}
func transferExecutedMoney(transfer *chainv1.Transfer) *paymenttypes.Money {
if transfer == nil {
return nil

View File

@@ -0,0 +1,24 @@
package shared
import (
"strings"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
)
// CloneMoneyTrimNonEmpty clones a money value after trimming fields.
// It returns nil when amount or currency is missing.
func CloneMoneyTrimNonEmpty(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,
}
}

View File

@@ -62,6 +62,6 @@ require (
golang.org/x/net v0.51.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
golang.org/x/text v0.35.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c // indirect
)

View File

@@ -202,8 +202,8 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
@@ -211,8 +211,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c h1:xgCzyF2LFIO/0X2UAoVRiXKU5Xg6VjToG4i2/ecSswk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU=
google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=

View File

@@ -5,6 +5,7 @@ import (
"time"
"github.com/shopspring/decimal"
"github.com/tech/sendico/payments/quotation/internal/shared"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/merrors"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
@@ -23,10 +24,7 @@ type moneyGetter interface {
}
func cloneMoney(input *paymenttypes.Money) *paymenttypes.Money {
if input == nil {
return nil
}
return &paymenttypes.Money{Currency: input.GetCurrency(), Amount: input.GetAmount()}
return shared.CloneModelMoneyRaw(input)
}
func cloneStringList(values []string) []string {
@@ -100,10 +98,7 @@ func protoMoney(m *paymenttypes.Money) *moneyv1.Money {
}
func cloneProtoMoney(input *moneyv1.Money) *moneyv1.Money {
if input == nil {
return nil
}
return &moneyv1.Money{Currency: input.GetCurrency(), Amount: input.GetAmount()}
return shared.CloneProtoMoneyRaw(input)
}
func decimalFromProto(value *moneyv1.Decimal) *paymenttypes.Decimal {

View File

@@ -67,13 +67,7 @@ func BuildFundingGateFromProfile(
}
func cloneProtoMoney(src *moneyv1.Money) *moneyv1.Money {
if src == nil {
return nil
}
return &moneyv1.Money{
Amount: strings.TrimSpace(src.GetAmount()),
Currency: strings.TrimSpace(src.GetCurrency()),
}
return shared.CloneProtoMoneyTrim(src)
}
func clonePaymentEndpoint(src *model.PaymentEndpoint) *model.PaymentEndpoint {

View File

@@ -6,6 +6,7 @@ import (
"github.com/shopspring/decimal"
oracleclient "github.com/tech/sendico/fx/oracle/client"
qshared "github.com/tech/sendico/payments/quotation/internal/shared"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
"google.golang.org/protobuf/proto"
@@ -31,13 +32,7 @@ const (
)
func cloneProtoMoney(input *moneyv1.Money) *moneyv1.Money {
if input == nil {
return nil
}
return &moneyv1.Money{
Currency: input.GetCurrency(),
Amount: input.GetAmount(),
}
return qshared.CloneProtoMoneyRaw(input)
}
func cloneMetadata(input map[string]string) map[string]string {

View File

@@ -4,6 +4,7 @@ import (
"strings"
"time"
qshared "github.com/tech/sendico/payments/quotation/internal/shared"
"github.com/tech/sendico/payments/storage/model"
payecon "github.com/tech/sendico/pkg/payments/economics"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
@@ -38,13 +39,7 @@ func firstNonEmpty(values ...string) string {
}
func cloneProtoMoney(src *moneyv1.Money) *moneyv1.Money {
if src == nil {
return nil
}
return &moneyv1.Money{
Amount: strings.TrimSpace(src.GetAmount()),
Currency: strings.ToUpper(strings.TrimSpace(src.GetCurrency())),
}
return qshared.CloneProtoMoneyTrimUpperCurrency(src)
}
func resolvedSettlementModeFromModel(mode model.SettlementMode) paymentv1.SettlementMode {

View File

@@ -3,19 +3,14 @@ package quote_computation_service
import (
"strings"
qshared "github.com/tech/sendico/payments/quotation/internal/shared"
"github.com/tech/sendico/payments/storage/model"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
)
func cloneProtoMoney(src *moneyv1.Money) *moneyv1.Money {
if src == nil {
return nil
}
return &moneyv1.Money{
Amount: strings.TrimSpace(src.GetAmount()),
Currency: strings.TrimSpace(src.GetCurrency()),
}
return qshared.CloneProtoMoneyTrim(src)
}
func protoMoneyFromModel(src *paymenttypes.Money) *moneyv1.Money {
@@ -58,13 +53,7 @@ func cloneAsset(src *paymenttypes.Asset) *paymenttypes.Asset {
}
func cloneModelMoney(src *paymenttypes.Money) *paymenttypes.Money {
if src == nil {
return nil
}
return &paymenttypes.Money{
Amount: strings.TrimSpace(src.GetAmount()),
Currency: strings.ToUpper(strings.TrimSpace(src.GetCurrency())),
}
return qshared.CloneModelMoneyTrimUpperCurrency(src)
}
func clonePaymentIntent(src model.PaymentIntent) model.PaymentIntent {

Some files were not shown because too many files have changed in this diff Show More