payment quotation v2 + payment orchestration v2 draft
This commit is contained in:
6
Makefile
6
Makefile
@@ -85,7 +85,7 @@ init:
|
|||||||
@echo "$(GREEN)Verifying .env.dev...$(NC)"
|
@echo "$(GREEN)Verifying .env.dev...$(NC)"
|
||||||
@cat .env.dev | grep -q "MONGO_USER=" || (echo "$(YELLOW)Error: .env.dev is incomplete$(NC)" && exit 1)
|
@cat .env.dev | grep -q "MONGO_USER=" || (echo "$(YELLOW)Error: .env.dev is incomplete$(NC)" && exit 1)
|
||||||
@echo "$(GREEN)Running proto generation...$(NC)"
|
@echo "$(GREEN)Running proto generation...$(NC)"
|
||||||
@./generate_protos.sh
|
@./ci/scripts/proto/generate.sh
|
||||||
@echo "$(GREEN)Building Docker images...$(NC)"
|
@echo "$(GREEN)Building Docker images...$(NC)"
|
||||||
@$(COMPOSE) build
|
@$(COMPOSE) build
|
||||||
@echo "$(GREEN)✅ Initialization complete!$(NC)"
|
@echo "$(GREEN)✅ Initialization complete!$(NC)"
|
||||||
@@ -97,7 +97,7 @@ init:
|
|||||||
# Build all images
|
# Build all images
|
||||||
build:
|
build:
|
||||||
@echo "$(GREEN)Building all service images...$(NC)"
|
@echo "$(GREEN)Building all service images...$(NC)"
|
||||||
@./generate_protos.sh
|
@./ci/scripts/proto/generate.sh
|
||||||
@$(COMPOSE) build
|
@$(COMPOSE) build
|
||||||
|
|
||||||
# Start all services
|
# Start all services
|
||||||
@@ -154,7 +154,7 @@ generate: generate-api generate-frontend
|
|||||||
# Generate protobuf code
|
# Generate protobuf code
|
||||||
generate-api:
|
generate-api:
|
||||||
@echo "$(GREEN)Generating protobuf code...$(NC)"
|
@echo "$(GREEN)Generating protobuf code...$(NC)"
|
||||||
@./generate_protos.sh
|
@./ci/scripts/proto/generate.sh
|
||||||
@echo "$(GREEN)✅ Protobuf generation complete$(NC)"
|
@echo "$(GREEN)✅ Protobuf generation complete$(NC)"
|
||||||
|
|
||||||
# Generate Flutter code (json_serializable, etc.)
|
# Generate Flutter code (json_serializable, etc.)
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ go 1.25.7
|
|||||||
replace github.com/tech/sendico/pkg => ../../pkg
|
replace github.com/tech/sendico/pkg => ../../pkg
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/aws/aws-sdk-go-v2 v1.41.1
|
github.com/aws/aws-sdk-go-v2 v1.41.2
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.32.9
|
github.com/aws/aws-sdk-go-v2/config v1.32.10
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.9
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.10
|
||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.1
|
||||||
github.com/jung-kurt/gofpdf v1.16.2
|
github.com/jung-kurt/gofpdf v1.16.2
|
||||||
github.com/prometheus/client_golang v1.23.2
|
github.com/prometheus/client_golang v1.23.2
|
||||||
github.com/shopspring/decimal v1.4.0
|
github.com/shopspring/decimal v1.4.0
|
||||||
@@ -20,21 +20,21 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 // indirect
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.18 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.5 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 // indirect
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.9 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 // indirect
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.18 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 // indirect
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.18 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 // indirect
|
github.com/aws/aws-sdk-go-v2/service/signin v1.0.6 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.10 // indirect
|
github.com/aws/aws-sdk-go-v2/service/sso v1.30.11 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14 // indirect
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect
|
github.com/aws/aws-sdk-go-v2/service/sts v1.41.7 // indirect
|
||||||
github.com/aws/smithy-go v1.24.0 // indirect
|
github.com/aws/smithy-go v1.24.1 // indirect
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect
|
github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect
|
||||||
github.com/casbin/casbin/v2 v2.135.0 // indirect
|
github.com/casbin/casbin/v2 v2.135.0 // indirect
|
||||||
@@ -48,7 +48,7 @@ require (
|
|||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/nats-io/nats.go v1.48.0 // indirect
|
github.com/nats-io/nats.go v1.49.0 // indirect
|
||||||
github.com/nats-io/nkeys v0.4.15 // indirect
|
github.com/nats-io/nkeys v0.4.15 // indirect
|
||||||
github.com/nats-io/nuid v1.0.1 // indirect
|
github.com/nats-io/nuid v1.0.1 // indirect
|
||||||
github.com/prometheus/client_model v0.6.2 // indirect
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
@@ -65,6 +65,6 @@ require (
|
|||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.41.0 // indirect
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
golang.org/x/text v0.34.0 // indirect
|
golang.org/x/text v0.34.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc // indirect
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,44 +4,44 @@ github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25
|
|||||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||||
github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU=
|
github.com/aws/aws-sdk-go-v2 v1.41.2 h1:LuT2rzqNQsauaGkPK/7813XxcZ3o3yePY0Iy891T2ls=
|
||||||
github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
|
github.com/aws/aws-sdk-go-v2 v1.41.2/go.mod h1:IvvlAZQXvTXznUPfRVfryiG1fbzE2NGK6m9u39YQ+S4=
|
||||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU=
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 h1:zWFmPmgw4sveAYi1mRqG+E/g0461cJ5M4bJ8/nc6d3Q=
|
||||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4=
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5/go.mod h1:nVUlMLVV8ycXSb7mSkcNu9e3v/1TJq2RTlrPwhYWr5c=
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.32.9 h1:ktda/mtAydeObvJXlHzyGpK1xcsLaP16zfUPDGoW90A=
|
github.com/aws/aws-sdk-go-v2/config v1.32.10 h1:9DMthfO6XWZYLfzZglAgW5Fyou2nRI5CuV44sTedKBI=
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.32.9/go.mod h1:U+fCQ+9QKsLW786BCfEjYRj34VVTbPdsLP3CHSYXMOI=
|
github.com/aws/aws-sdk-go-v2/config v1.32.10/go.mod h1:2rUIOnA2JaiqYmSKYmRJlcMWy6qTj1vuRFscppSBMcw=
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.9 h1:sWvTKsyrMlJGEuj/WgrwilpoJ6Xa1+KhIpGdzw7mMU8=
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.10 h1:EEhmEUFCE1Yhl7vDhNOI5OCL/iKMdkkYFTRpZXNw7m8=
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.9/go.mod h1:+J44MBhmfVY/lETFiKI+klz0Vym2aCmIjqgClMmW82w=
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.10/go.mod h1:RnnlFCAlxQCkN2Q379B67USkBMu1PipEEiibzYN5UTE=
|
||||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 h1:I0GyV8wiYrP8XpA70g1HBcQO1JlQxCMTW9npl5UbDHY=
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18 h1:Ii4s+Sq3yDfaMLpjrJsqD6SmG/Wq/P5L/hw2qa78UAY=
|
||||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17/go.mod h1:tyw7BOl5bBe/oqvoIeECFJjMdzXoa/dfVz3QQ5lgHGA=
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18/go.mod h1:6x81qnY++ovptLE6nWQeWrpXxbnlIex+4H4eYYGcqfc=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U=
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18 h1:F43zk1vemYIqPAwhjTjYIz0irU2EY7sOb/F5eJ3HuyM=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17/go.mod h1:5M5CI3D12dNOtH3/mk6minaRwI2/37ifCURZISxA/IQ=
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18/go.mod h1:w1jdlZXrGKaJcNoL+Nnrj+k5wlpGXqnNrKoP22HvAug=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 h1:WWLqlh79iO48yLkj1v3ISRNiv+3KdQoZ6JWyfcsyQik=
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18 h1:xCeWVjj0ki0l3nruoyP2slHsGArMxeiiaoPN5QZH6YQ=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17/go.mod h1:EhG22vHRrvF8oXSTYStZhJc1aUgKtnJe+aOiFEV90cM=
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18/go.mod h1:r/eLGuGCBw6l36ZRWiw6PaZwPXb6YOj+i/7MizNl5/k=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 h1:JqcdRG//czea7Ppjb+g/n4o8i/R50aTBHkA7vu0lK+k=
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.18 h1:eZioDaZGJ0tMM4gzmkNIO2aAoQd+je7Ug7TkvAzlmkU=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17/go.mod h1:CO+WeGmIdj/MlPel2KwID9Gt7CNq4M65HUfBW97liM0=
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.18/go.mod h1:CCXwUKAJdoWr6/NcxZ+zsiPr6oH/Q5aTooRGYieAyj4=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E=
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.5 h1:CeY9LUdur+Dxoeldqoun6y4WtJ3RQtzk0JMP2gfUay0=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow=
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.5/go.mod h1:AZLZf2fMaahW5s/wMRciu1sYbdsikT/UHwbUjOdEVTc=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 h1:Z5EiPIzXKewUQK0QTMkutjiaPVeVYXX7KIqhXu/0fXs=
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.9 h1:IJRzQTvdpjHRPItx9gzNcz7Y1F+xqAR+xiy9rr5ZYl8=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8/go.mod h1:FsTpJtvC4U1fyDXk7c71XoDv3HlRm8V3NiYLeYLh5YE=
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.9/go.mod h1:Kzm5e6OmNH8VMkgK9t+ry5jEih4Y8whqs+1hrkxim1I=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 h1:RuNSMoozM8oXlgLG/n6WLaFGoea7/CddrCfIiSA+xdY=
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.18 h1:LTRCYFlnnKFlKsyIQxKhJuDuA3ZkrDQMRYm6rXiHlLY=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17/go.mod h1:F2xxQ9TZz5gDWsclCtPQscGpP0VUOc8RqgFM3vDENmU=
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.18/go.mod h1:XhwkgGG6bHSd00nO/mexWTcTjgd6PjuvWQMqSn2UaEk=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 h1:bGeHBsGZx0Dvu/eJC0Lh9adJa3M1xREcndxLNZlve2U=
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.18 h1:/A/xDuZAVD2BpsS2fftFRo/NoEKQJ8YTnJDEHBy2Gtg=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17/go.mod h1:dcW24lbU0CzHusTE8LLHhRLI42ejmINN8Lcr22bwh/g=
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.18/go.mod h1:hWe9b4f+djUQGmyiGEeOnZv69dtMSgpDRIvNMvuvzvY=
|
||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0 h1:oeu8VPlOre74lBA/PMhxa5vewaMIMmILM+RraSyB8KA=
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.1 h1:giB30dEeoar5bgDnkE0q+z7cFjcHaCjulpmPVmuKR84=
|
||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0/go.mod h1:5jggDlZ2CLQhwJBiZJb4vfk4f0GxWdEDruWKEJ1xOdo=
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.1/go.mod h1:071TH4M3botFLWDbzQLfBR7tXYi7Fs2RsXSiH7nlUlY=
|
||||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 h1:VrhDvQib/i0lxvr3zqlUwLwJP4fpmpyD9wYG1vfSu+Y=
|
github.com/aws/aws-sdk-go-v2/service/signin v1.0.6 h1:MzORe+J94I+hYu2a6XmV5yC9huoTv8NRcCrUNedDypQ=
|
||||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5/go.mod h1:k029+U8SY30/3/ras4G/Fnv/b88N4mAfliNn08Dem4M=
|
github.com/aws/aws-sdk-go-v2/service/signin v1.0.6/go.mod h1:hXzcHLARD7GeWnifd8j9RWqtfIgxj4/cAtIVIK7hg8g=
|
||||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.10 h1:+VTRawC4iVY58pS/lzpo0lnoa/SYNGF4/B/3/U5ro8Y=
|
github.com/aws/aws-sdk-go-v2/service/sso v1.30.11 h1:7oGD8KPfBOJGXiCoRKrrrQkbvCp8N++u36hrLMPey6o=
|
||||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.10/go.mod h1:yifAsgBxgJWn3ggx70A3urX2AN49Y5sJTD1UQFlfqBw=
|
github.com/aws/aws-sdk-go-v2/service/sso v1.30.11/go.mod h1:0DO9B5EUJQlIDif+XJRWCljZRKsAFKh3gpFz7UnDtOo=
|
||||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14 h1:0jbJeuEHlwKJ9PfXtpSFc4MF+WIWORdhN1n30ITZGFM=
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15 h1:edCcNp9eGIUDUCrzoCu1jWAXLGFIizeqkdkKgRlJwWc=
|
||||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14/go.mod h1:sTGThjphYE4Ohw8vJiRStAcu3rbjtXRsdNB0TvZ5wwo=
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15/go.mod h1:lyRQKED9xWfgkYC/wmmYfv7iVIM68Z5OQ88ZdcV1QbU=
|
||||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/pJ1jOWYlFDJTjRQ=
|
github.com/aws/aws-sdk-go-v2/service/sts v1.41.7 h1:NITQpgo9A5NrDZ57uOWj+abvXSb83BbyggcUBVksN7c=
|
||||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ=
|
github.com/aws/aws-sdk-go-v2/service/sts v1.41.7/go.mod h1:sks5UWBhEuWYDPdwlnRFn1w7xWdH29Jcpe+/PJQefEs=
|
||||||
github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
|
github.com/aws/smithy-go v1.24.1 h1:VbyeNfmYkWoxMVpGUAbQumkODcYmfMRfZ8yQiH30SK0=
|
||||||
github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
|
github.com/aws/smithy-go v1.24.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
|
||||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||||
@@ -134,8 +134,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
|||||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
|
github.com/nats-io/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE=
|
||||||
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
github.com/nats-io/nats.go v1.49.0/go.mod h1:fDCn3mN5cY8HooHwE2ukiLb4p4G4ImmzvXyJt+tGwdw=
|
||||||
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
|
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
|
||||||
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
|
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
|
||||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||||
@@ -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=
|
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 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc h1:51Wupg8spF+5FC6D+iMKbOddFjMckETnNnEiZ+HX37s=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||||
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
||||||
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ require (
|
|||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/nats-io/nats.go v1.48.0 // indirect
|
github.com/nats-io/nats.go v1.49.0 // indirect
|
||||||
github.com/nats-io/nkeys v0.4.15 // indirect
|
github.com/nats-io/nkeys v0.4.15 // indirect
|
||||||
github.com/nats-io/nuid v1.0.1 // indirect
|
github.com/nats-io/nuid v1.0.1 // indirect
|
||||||
github.com/prometheus/client_golang v1.23.2
|
github.com/prometheus/client_golang v1.23.2
|
||||||
@@ -50,6 +50,6 @@ require (
|
|||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.41.0 // indirect
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
golang.org/x/text v0.34.0 // indirect
|
golang.org/x/text v0.34.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc // indirect
|
||||||
google.golang.org/protobuf v1.36.11
|
google.golang.org/protobuf v1.36.11
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -91,8 +91,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
|||||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
|
github.com/nats-io/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE=
|
||||||
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
github.com/nats-io/nats.go v1.49.0/go.mod h1:fDCn3mN5cY8HooHwE2ukiLb4p4G4ImmzvXyJt+tGwdw=
|
||||||
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
|
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
|
||||||
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
|
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
|
||||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||||
@@ -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=
|
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 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc h1:51Wupg8spF+5FC6D+iMKbOddFjMckETnNnEiZ+HX37s=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||||
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
||||||
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"github.com/tech/sendico/billing/fees/storage"
|
"github.com/tech/sendico/billing/fees/storage"
|
||||||
"github.com/tech/sendico/billing/fees/storage/model"
|
"github.com/tech/sendico/billing/fees/storage/model"
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
"github.com/tech/sendico/pkg/mutil/mzap"
|
"github.com/tech/sendico/pkg/mutil/mzap"
|
||||||
"go.mongodb.org/mongo-driver/v2/bson"
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@@ -22,10 +23,10 @@ type planFinder interface {
|
|||||||
type feeResolver struct {
|
type feeResolver struct {
|
||||||
plans storage.PlansStore
|
plans storage.PlansStore
|
||||||
finder planFinder
|
finder planFinder
|
||||||
logger *zap.Logger
|
logger mlogger.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(plans storage.PlansStore, logger *zap.Logger) *feeResolver {
|
func New(plans storage.PlansStore, logger mlogger.Logger) *feeResolver {
|
||||||
var finder planFinder
|
var finder planFinder
|
||||||
if pf, ok := plans.(planFinder); ok {
|
if pf, ok := plans.(planFinder); ok {
|
||||||
finder = pf
|
finder = pf
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ require (
|
|||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/nats-io/nats.go v1.48.0 // indirect
|
github.com/nats-io/nats.go v1.49.0 // indirect
|
||||||
github.com/nats-io/nkeys v0.4.15 // indirect
|
github.com/nats-io/nkeys v0.4.15 // indirect
|
||||||
github.com/nats-io/nuid v1.0.1 // indirect
|
github.com/nats-io/nuid v1.0.1 // indirect
|
||||||
github.com/prometheus/client_model v0.6.2 // indirect
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
@@ -43,7 +43,7 @@ require (
|
|||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.41.0 // indirect
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
golang.org/x/text v0.34.0 // indirect
|
golang.org/x/text v0.34.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc // indirect
|
||||||
google.golang.org/grpc v1.79.1 // indirect
|
google.golang.org/grpc v1.79.1 // indirect
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -91,8 +91,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
|||||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
|
github.com/nats-io/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE=
|
||||||
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
github.com/nats-io/nats.go v1.49.0/go.mod h1:fDCn3mN5cY8HooHwE2ukiLb4p4G4ImmzvXyJt+tGwdw=
|
||||||
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
|
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
|
||||||
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
|
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
|
||||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||||
@@ -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=
|
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 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc h1:51Wupg8spF+5FC6D+iMKbOddFjMckETnNnEiZ+HX37s=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||||
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
||||||
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ require (
|
|||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/nats-io/nats.go v1.48.0 // indirect
|
github.com/nats-io/nats.go v1.49.0 // indirect
|
||||||
github.com/nats-io/nkeys v0.4.15 // indirect
|
github.com/nats-io/nkeys v0.4.15 // indirect
|
||||||
github.com/nats-io/nuid v1.0.1 // indirect
|
github.com/nats-io/nuid v1.0.1 // indirect
|
||||||
github.com/prometheus/client_model v0.6.2 // indirect
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
@@ -47,7 +47,7 @@ require (
|
|||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.41.0 // indirect
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
golang.org/x/text v0.34.0 // indirect
|
golang.org/x/text v0.34.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc // indirect
|
||||||
google.golang.org/grpc v1.79.1 // indirect
|
google.golang.org/grpc v1.79.1 // indirect
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -91,8 +91,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
|||||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
|
github.com/nats-io/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE=
|
||||||
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
github.com/nats-io/nats.go v1.49.0/go.mod h1:fDCn3mN5cY8HooHwE2ukiLb4p4G4ImmzvXyJt+tGwdw=
|
||||||
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
|
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
|
||||||
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
|
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
|
||||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||||
@@ -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=
|
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 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc h1:51Wupg8spF+5FC6D+iMKbOddFjMckETnNnEiZ+HX37s=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||||
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
||||||
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
|||||||
@@ -415,7 +415,7 @@ type valuteMapping struct {
|
|||||||
byID map[string]valuteInfo
|
byID map[string]valuteInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildValuteMapping(logger *zap.Logger, items []valuteItem) (*valuteMapping, error) { //nolint:cyclop,gocognit,nestif
|
func buildValuteMapping(logger mlogger.Logger, items []valuteItem) (*valuteMapping, error) { //nolint:cyclop,gocognit,nestif
|
||||||
byISO := make(map[string]valuteInfo, len(items))
|
byISO := make(map[string]valuteInfo, len(items))
|
||||||
byID := make(map[string]valuteInfo, len(items))
|
byID := make(map[string]valuteInfo, len(items))
|
||||||
byNum := make(map[string]string, len(items))
|
byNum := make(map[string]string, len(items))
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,7 +18,7 @@ const (
|
|||||||
type httpClient struct {
|
type httpClient struct {
|
||||||
client *http.Client
|
client *http.Client
|
||||||
headers http.Header
|
headers http.Header
|
||||||
logger *zap.Logger
|
logger mlogger.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
type httpClientOptions struct {
|
type httpClientOptions struct {
|
||||||
@@ -26,7 +27,7 @@ type httpClientOptions struct {
|
|||||||
referer string
|
referer string
|
||||||
}
|
}
|
||||||
|
|
||||||
func newHTTPClient(logger *zap.Logger, client *http.Client, opts httpClientOptions) *httpClient {
|
func newHTTPClient(logger mlogger.Logger, client *http.Client, opts httpClientOptions) *httpClient {
|
||||||
userAgent := opts.userAgent
|
userAgent := opts.userAgent
|
||||||
if strings.TrimSpace(userAgent) == "" {
|
if strings.TrimSpace(userAgent) == "" {
|
||||||
userAgent = defaultUserAgent
|
userAgent = defaultUserAgent
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ require (
|
|||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/nats-io/nats.go v1.48.0 // indirect
|
github.com/nats-io/nats.go v1.49.0 // indirect
|
||||||
github.com/nats-io/nkeys v0.4.15 // indirect
|
github.com/nats-io/nkeys v0.4.15 // indirect
|
||||||
github.com/nats-io/nuid v1.0.1 // indirect
|
github.com/nats-io/nuid v1.0.1 // indirect
|
||||||
github.com/prometheus/client_model v0.6.2 // indirect
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
@@ -48,5 +48,5 @@ require (
|
|||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.41.0 // indirect
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
golang.org/x/text v0.34.0 // indirect
|
golang.org/x/text v0.34.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -91,8 +91,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
|||||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
|
github.com/nats-io/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE=
|
||||||
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
github.com/nats-io/nats.go v1.49.0/go.mod h1:fDCn3mN5cY8HooHwE2ukiLb4p4G4ImmzvXyJt+tGwdw=
|
||||||
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
|
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
|
||||||
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
|
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
|
||||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||||
@@ -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=
|
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 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc h1:51Wupg8spF+5FC6D+iMKbOddFjMckETnNnEiZ+HX37s=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||||
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
||||||
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ replace github.com/tech/sendico/pkg => ../../pkg
|
|||||||
replace github.com/tech/sendico/gateway/common => ../common
|
replace github.com/tech/sendico/gateway/common => ../common
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1
|
||||||
github.com/ethereum/go-ethereum v1.17.0
|
github.com/ethereum/go-ethereum v1.17.0
|
||||||
github.com/hashicorp/vault/api v1.22.0
|
github.com/hashicorp/vault/api v1.22.0
|
||||||
github.com/mitchellh/mapstructure v1.5.0
|
github.com/mitchellh/mapstructure v1.5.0
|
||||||
@@ -61,7 +61,7 @@ require (
|
|||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/nats-io/nats.go v1.48.0 // indirect
|
github.com/nats-io/nats.go v1.49.0 // indirect
|
||||||
github.com/nats-io/nkeys v0.4.15 // indirect
|
github.com/nats-io/nkeys v0.4.15 // indirect
|
||||||
github.com/nats-io/nuid v1.0.1 // indirect
|
github.com/nats-io/nuid v1.0.1 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
@@ -91,5 +91,5 @@ require (
|
|||||||
golang.org/x/sys v0.41.0 // indirect
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
golang.org/x/text v0.34.0 // indirect
|
golang.org/x/text v0.34.0 // indirect
|
||||||
golang.org/x/time v0.14.0 // indirect
|
golang.org/x/time v0.14.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -60,8 +60,8 @@ github.com/deckarep/golang-set/v2 v2.8.0 h1:swm0rlPCmdWn9mESxKOjWk8hXSqoxOp+Zlfu
|
|||||||
github.com/deckarep/golang-set/v2 v2.8.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4=
|
github.com/deckarep/golang-set/v2 v2.8.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4=
|
||||||
github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8=
|
github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8=
|
||||||
github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
|
github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
|
||||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo=
|
||||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
||||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||||
github.com/docker/docker v27.3.1+incompatible h1:KttF0XoteNTicmUtBO0L2tP+J7FGRFTjaEF4k6WdhfI=
|
github.com/docker/docker v27.3.1+incompatible h1:KttF0XoteNTicmUtBO0L2tP+J7FGRFTjaEF4k6WdhfI=
|
||||||
@@ -204,8 +204,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
|||||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
|
github.com/nats-io/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE=
|
||||||
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
github.com/nats-io/nats.go v1.49.0/go.mod h1:fDCn3mN5cY8HooHwE2ukiLb4p4G4ImmzvXyJt+tGwdw=
|
||||||
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
|
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
|
||||||
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
|
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
|
||||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||||
@@ -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=
|
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 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc h1:51Wupg8spF+5FC6D+iMKbOddFjMckETnNnEiZ+HX37s=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||||
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
||||||
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
|||||||
@@ -224,8 +224,8 @@ func (s *Service) startDiscoveryAnnouncers() {
|
|||||||
announce := discovery.Announcement{
|
announce := discovery.Announcement{
|
||||||
ID: discovery.StableCryptoRailGatewayID(string(network.Name)),
|
ID: discovery.StableCryptoRailGatewayID(string(network.Name)),
|
||||||
Service: "CRYPTO_RAIL_GATEWAY",
|
Service: "CRYPTO_RAIL_GATEWAY",
|
||||||
Rail: "CRYPTO",
|
Rail: discovery.RailCrypto,
|
||||||
Operations: []string{"balance.read", "payin.crypto", "payout.crypto", "fee.send", "observe.confirm"},
|
Operations: discovery.CryptoRailGatewayOperations(),
|
||||||
Currencies: currencies,
|
Currencies: currencies,
|
||||||
InvokeURI: s.invokeURI,
|
InvokeURI: s.invokeURI,
|
||||||
Version: version,
|
Version: version,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ require (
|
|||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/klauspost/compress v1.18.4 // indirect
|
github.com/klauspost/compress v1.18.4 // indirect
|
||||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||||
github.com/nats-io/nats.go v1.48.0 // indirect
|
github.com/nats-io/nats.go v1.49.0 // indirect
|
||||||
github.com/nats-io/nkeys v0.4.15 // indirect
|
github.com/nats-io/nkeys v0.4.15 // indirect
|
||||||
github.com/nats-io/nuid v1.0.1 // indirect
|
github.com/nats-io/nuid v1.0.1 // indirect
|
||||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||||
|
|||||||
@@ -60,8 +60,8 @@ github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
|||||||
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
|
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
|
||||||
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||||
github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
|
github.com/nats-io/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE=
|
||||||
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
github.com/nats-io/nats.go v1.49.0/go.mod h1:fDCn3mN5cY8HooHwE2ukiLb4p4G4ImmzvXyJt+tGwdw=
|
||||||
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
|
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
|
||||||
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
|
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
|
||||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
|
|
||||||
"github.com/shopspring/decimal"
|
"github.com/shopspring/decimal"
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
"github.com/tech/sendico/pkg/model/account_role"
|
"github.com/tech/sendico/pkg/model/account_role"
|
||||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
|
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
|
||||||
@@ -35,7 +36,7 @@ type gatewayClient struct {
|
|||||||
conn *grpc.ClientConn
|
conn *grpc.ClientConn
|
||||||
client grpcConnectorClient
|
client grpcConnectorClient
|
||||||
cfg Config
|
cfg Config
|
||||||
logger *zap.Logger
|
logger mlogger.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// New dials the Monetix gateway.
|
// New dials the Monetix gateway.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package client
|
|||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -11,7 +12,7 @@ type Config struct {
|
|||||||
Address string
|
Address string
|
||||||
DialTimeout time.Duration
|
DialTimeout time.Duration
|
||||||
CallTimeout time.Duration
|
CallTimeout time.Duration
|
||||||
Logger *zap.Logger
|
Logger mlogger.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) setDefaults() {
|
func (c *Config) setDefaults() {
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ require (
|
|||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/nats-io/nats.go v1.48.0 // indirect
|
github.com/nats-io/nats.go v1.49.0 // indirect
|
||||||
github.com/nats-io/nkeys v0.4.15 // indirect
|
github.com/nats-io/nkeys v0.4.15 // indirect
|
||||||
github.com/nats-io/nuid v1.0.1 // indirect
|
github.com/nats-io/nuid v1.0.1 // indirect
|
||||||
github.com/prometheus/client_model v0.6.2 // indirect
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
@@ -51,5 +51,5 @@ require (
|
|||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.41.0 // indirect
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
golang.org/x/text v0.34.0 // indirect
|
golang.org/x/text v0.34.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -91,8 +91,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
|||||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
|
github.com/nats-io/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE=
|
||||||
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
github.com/nats-io/nats.go v1.49.0/go.mod h1:fDCn3mN5cY8HooHwE2ukiLb4p4G4ImmzvXyJt+tGwdw=
|
||||||
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
|
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
|
||||||
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
|
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
|
||||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||||
@@ -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=
|
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 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc h1:51Wupg8spF+5FC6D+iMKbOddFjMckETnNnEiZ+HX37s=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||||
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
||||||
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
|||||||
@@ -152,8 +152,8 @@ func (s *Service) startDiscoveryAnnouncer() {
|
|||||||
}
|
}
|
||||||
announce := discovery.Announcement{
|
announce := discovery.Announcement{
|
||||||
Service: "CARD_PAYOUT_RAIL_GATEWAY",
|
Service: "CARD_PAYOUT_RAIL_GATEWAY",
|
||||||
Rail: "CARD_PAYOUT",
|
Rail: discovery.RailCardPayout,
|
||||||
Operations: []string{"payout.card", "observe.confirm"},
|
Operations: discovery.CardPayoutRailGatewayOperations(),
|
||||||
InvokeURI: s.invokeURI,
|
InvokeURI: s.invokeURI,
|
||||||
Version: appversion.Create().Short(),
|
Version: appversion.Create().Short(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -312,10 +313,7 @@ func clearSignature(req any) (func(string), error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func logRequestDeadline(logger *zap.Logger, ctx context.Context, url string) {
|
func logRequestDeadline(logger mlogger.Logger, ctx context.Context, url string) {
|
||||||
if logger == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if ctx == nil {
|
if ctx == nil {
|
||||||
logger.Info("Monetix request context is nil", zap.String("url", url))
|
logger.Info("Monetix request context is nil", zap.String("url", url))
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ require (
|
|||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/nats-io/nats.go v1.48.0 // indirect
|
github.com/nats-io/nats.go v1.49.0 // indirect
|
||||||
github.com/nats-io/nkeys v0.4.15 // indirect
|
github.com/nats-io/nkeys v0.4.15 // indirect
|
||||||
github.com/nats-io/nuid v1.0.1 // indirect
|
github.com/nats-io/nuid v1.0.1 // indirect
|
||||||
github.com/prometheus/client_golang v1.23.2 // indirect
|
github.com/prometheus/client_golang v1.23.2 // indirect
|
||||||
@@ -48,5 +48,5 @@ require (
|
|||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.41.0 // indirect
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
golang.org/x/text v0.34.0 // indirect
|
golang.org/x/text v0.34.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -91,8 +91,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
|||||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
|
github.com/nats-io/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE=
|
||||||
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
github.com/nats-io/nats.go v1.49.0/go.mod h1:fDCn3mN5cY8HooHwE2ukiLb4p4G4ImmzvXyJt+tGwdw=
|
||||||
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
|
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
|
||||||
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
|
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
|
||||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||||
@@ -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=
|
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 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc h1:51Wupg8spF+5FC6D+iMKbOddFjMckETnNnEiZ+HX37s=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||||
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
||||||
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
|||||||
@@ -526,14 +526,11 @@ func (s *Service) startAnnouncer() {
|
|||||||
if s == nil || s.producer == nil {
|
if s == nil || s.producer == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
caps := []string{"telegram_confirmation", "money_persistence", "observe.confirm", "payout.fiat"}
|
caps := discovery.CardPayoutRailGatewayOperations()
|
||||||
if s.rail != "" {
|
|
||||||
caps = append(caps, "confirmations."+strings.ToLower(string(mservice.PaymentGateway))+"."+strings.ToLower(s.rail))
|
|
||||||
}
|
|
||||||
announce := discovery.Announcement{
|
announce := discovery.Announcement{
|
||||||
ID: discovery.StablePaymentGatewayID(s.rail),
|
ID: discovery.StablePaymentGatewayID(discovery.NormalizeRail(s.rail)),
|
||||||
Service: string(mservice.PaymentGateway),
|
Service: string(mservice.PaymentGateway),
|
||||||
Rail: s.rail,
|
Rail: discovery.NormalizeRail(s.rail),
|
||||||
Operations: caps,
|
Operations: caps,
|
||||||
InvokeURI: s.invokeURI,
|
InvokeURI: s.invokeURI,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ replace github.com/tech/sendico/pkg => ../../pkg
|
|||||||
replace github.com/tech/sendico/gateway/common => ../common
|
replace github.com/tech/sendico/gateway/common => ../common
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1
|
||||||
github.com/ethereum/go-ethereum v1.17.0
|
github.com/ethereum/go-ethereum v1.17.0
|
||||||
github.com/fbsobreira/gotron-sdk v0.24.1
|
github.com/fbsobreira/gotron-sdk v0.24.1
|
||||||
github.com/hashicorp/vault/api v1.22.0
|
github.com/hashicorp/vault/api v1.22.0
|
||||||
@@ -65,7 +65,7 @@ require (
|
|||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/nats-io/nats.go v1.48.0 // indirect
|
github.com/nats-io/nats.go v1.49.0 // indirect
|
||||||
github.com/nats-io/nkeys v0.4.15 // indirect
|
github.com/nats-io/nkeys v0.4.15 // indirect
|
||||||
github.com/nats-io/nuid v1.0.1 // indirect
|
github.com/nats-io/nuid v1.0.1 // indirect
|
||||||
github.com/pborman/uuid v1.2.1 // indirect
|
github.com/pborman/uuid v1.2.1 // indirect
|
||||||
@@ -99,6 +99,6 @@ require (
|
|||||||
golang.org/x/sys v0.41.0 // indirect
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
golang.org/x/text v0.34.0 // indirect
|
golang.org/x/text v0.34.0 // indirect
|
||||||
golang.org/x/time v0.14.0 // indirect
|
golang.org/x/time v0.14.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d // indirect
|
google.golang.org/genproto/googleapis/api v0.0.0-20260223185530-2f722ef697dc // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -64,8 +64,8 @@ github.com/deckarep/golang-set/v2 v2.8.0 h1:swm0rlPCmdWn9mESxKOjWk8hXSqoxOp+Zlfu
|
|||||||
github.com/deckarep/golang-set/v2 v2.8.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4=
|
github.com/deckarep/golang-set/v2 v2.8.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4=
|
||||||
github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8=
|
github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8=
|
||||||
github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
|
github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
|
||||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo=
|
||||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
||||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||||
github.com/docker/docker v27.3.1+incompatible h1:KttF0XoteNTicmUtBO0L2tP+J7FGRFTjaEF4k6WdhfI=
|
github.com/docker/docker v27.3.1+incompatible h1:KttF0XoteNTicmUtBO0L2tP+J7FGRFTjaEF4k6WdhfI=
|
||||||
@@ -211,8 +211,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
|||||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
|
github.com/nats-io/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE=
|
||||||
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
github.com/nats-io/nats.go v1.49.0/go.mod h1:fDCn3mN5cY8HooHwE2ukiLb4p4G4ImmzvXyJt+tGwdw=
|
||||||
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
|
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
|
||||||
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
|
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
|
||||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||||
@@ -374,10 +374,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=
|
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 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d h1:EocjzKLywydp5uZ5tJ79iP6Q0UjDnyiHkGRWxuPBP8s=
|
google.golang.org/genproto/googleapis/api v0.0.0-20260223185530-2f722ef697dc h1:ULD+ToGXUIU6Pkzr1ARxdyvwfHbelw+agoFDRbLg4TU=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:48U2I+QQUYhsFrg2SY6r+nJzeOtjey7j//WBESw+qyQ=
|
google.golang.org/genproto/googleapis/api v0.0.0-20260223185530-2f722ef697dc/go.mod h1:M5krXqk4GhBKvB596udGL3UyjL4I1+cTbK0orROM9ng=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc h1:51Wupg8spF+5FC6D+iMKbOddFjMckETnNnEiZ+HX37s=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||||
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
||||||
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
|||||||
@@ -229,8 +229,8 @@ func (s *Service) startDiscoveryAnnouncers() {
|
|||||||
announce := discovery.Announcement{
|
announce := discovery.Announcement{
|
||||||
ID: discovery.StableCryptoRailGatewayID(network.Name.String()),
|
ID: discovery.StableCryptoRailGatewayID(network.Name.String()),
|
||||||
Service: "CRYPTO_RAIL_GATEWAY",
|
Service: "CRYPTO_RAIL_GATEWAY",
|
||||||
Rail: "CRYPTO",
|
Rail: discovery.RailCrypto,
|
||||||
Operations: []string{"balance.read", "payin.crypto", "payout.crypto", "fee.send", "observe.confirm"},
|
Operations: discovery.CryptoRailGatewayOperations(),
|
||||||
Currencies: currencies,
|
Currencies: currencies,
|
||||||
InvokeURI: s.invokeURI,
|
InvokeURI: s.invokeURI,
|
||||||
Version: version,
|
Version: version,
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ require (
|
|||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/nats-io/nats.go v1.48.0 // indirect
|
github.com/nats-io/nats.go v1.49.0 // indirect
|
||||||
github.com/nats-io/nkeys v0.4.15 // indirect
|
github.com/nats-io/nkeys v0.4.15 // indirect
|
||||||
github.com/nats-io/nuid v1.0.1 // indirect
|
github.com/nats-io/nuid v1.0.1 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
@@ -49,5 +49,5 @@ require (
|
|||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.41.0 // indirect
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
golang.org/x/text v0.34.0 // indirect
|
golang.org/x/text v0.34.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -91,8 +91,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
|||||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
|
github.com/nats-io/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE=
|
||||||
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
github.com/nats-io/nats.go v1.49.0/go.mod h1:fDCn3mN5cY8HooHwE2ukiLb4p4G4ImmzvXyJt+tGwdw=
|
||||||
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
|
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
|
||||||
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
|
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
|
||||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||||
@@ -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=
|
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 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc h1:51Wupg8spF+5FC6D+iMKbOddFjMckETnNnEiZ+HX37s=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||||
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
||||||
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
|||||||
@@ -365,7 +365,7 @@ func (s *Service) GetJournalEntry(ctx context.Context, req *ledgerv1.GetEntryReq
|
|||||||
return responder(ctx)
|
return responder(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) logLedgerOperation(op string, logger *zap.Logger, resp *ledgerv1.PostResponse, err error) {
|
func (s *Service) logLedgerOperation(op string, logger mlogger.Logger, resp *ledgerv1.PostResponse, err error) {
|
||||||
if logger == nil {
|
if logger == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ require (
|
|||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/nats-io/nats.go v1.48.0 // indirect
|
github.com/nats-io/nats.go v1.49.0 // indirect
|
||||||
github.com/nats-io/nkeys v0.4.15 // indirect
|
github.com/nats-io/nkeys v0.4.15 // indirect
|
||||||
github.com/nats-io/nuid v1.0.1 // indirect
|
github.com/nats-io/nuid v1.0.1 // indirect
|
||||||
github.com/prometheus/client_golang v1.23.2 // indirect
|
github.com/prometheus/client_golang v1.23.2 // indirect
|
||||||
@@ -50,7 +50,7 @@ require (
|
|||||||
golang.org/x/net v0.50.0 // indirect
|
golang.org/x/net v0.50.0 // indirect
|
||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.41.0 // indirect
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc // indirect
|
||||||
google.golang.org/grpc v1.79.1 // indirect
|
google.golang.org/grpc v1.79.1 // indirect
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -95,8 +95,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
|||||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
|
github.com/nats-io/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE=
|
||||||
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
github.com/nats-io/nats.go v1.49.0/go.mod h1:fDCn3mN5cY8HooHwE2ukiLb4p4G4ImmzvXyJt+tGwdw=
|
||||||
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
|
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
|
||||||
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
|
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
|
||||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||||
@@ -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=
|
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 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc h1:51Wupg8spF+5FC6D+iMKbOddFjMckETnNnEiZ+HX37s=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||||
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
||||||
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
|||||||
@@ -30,13 +30,14 @@ require (
|
|||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/nats-io/nats.go v1.48.0 // indirect
|
github.com/nats-io/nats.go v1.49.0 // indirect
|
||||||
github.com/nats-io/nkeys v0.4.15 // indirect
|
github.com/nats-io/nkeys v0.4.15 // indirect
|
||||||
github.com/nats-io/nuid v1.0.1 // indirect
|
github.com/nats-io/nuid v1.0.1 // indirect
|
||||||
github.com/prometheus/client_golang v1.23.2 // indirect
|
github.com/prometheus/client_golang v1.23.2 // indirect
|
||||||
github.com/prometheus/client_model v0.6.2 // indirect
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
github.com/prometheus/common v0.67.5 // indirect
|
github.com/prometheus/common v0.67.5 // indirect
|
||||||
github.com/prometheus/procfs v0.19.2 // indirect
|
github.com/prometheus/procfs v0.19.2 // indirect
|
||||||
|
github.com/shopspring/decimal v1.4.0 // indirect
|
||||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||||
github.com/xdg-go/scram v1.2.0 // indirect
|
github.com/xdg-go/scram v1.2.0 // indirect
|
||||||
github.com/xdg-go/stringprep v1.0.4 // indirect
|
github.com/xdg-go/stringprep v1.0.4 // indirect
|
||||||
@@ -48,5 +49,5 @@ require (
|
|||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.41.0 // indirect
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
golang.org/x/text v0.34.0 // indirect
|
golang.org/x/text v0.34.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -91,8 +91,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
|||||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
|
github.com/nats-io/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE=
|
||||||
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
github.com/nats-io/nats.go v1.49.0/go.mod h1:fDCn3mN5cY8HooHwE2ukiLb4p4G4ImmzvXyJt+tGwdw=
|
||||||
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
|
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
|
||||||
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
|
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
|
||||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||||
@@ -121,6 +121,8 @@ github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/i
|
|||||||
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
|
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
|
||||||
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
|
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
|
||||||
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
|
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
|
||||||
|
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||||
|
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||||
@@ -208,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=
|
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 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc h1:51Wupg8spF+5FC6D+iMKbOddFjMckETnNnEiZ+HX37s=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||||
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
||||||
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
orchestrationv1 "github.com/tech/sendico/pkg/proto/payments/orchestration/v1"
|
orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2"
|
||||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestration/v1"
|
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
"google.golang.org/grpc/credentials"
|
"google.golang.org/grpc/credentials"
|
||||||
"google.golang.org/grpc/credentials/insecure"
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
@@ -17,33 +16,22 @@ import (
|
|||||||
|
|
||||||
// Client exposes typed helpers around the payment orchestration and quotation gRPC APIs.
|
// Client exposes typed helpers around the payment orchestration and quotation gRPC APIs.
|
||||||
type Client interface {
|
type Client interface {
|
||||||
InitiatePayments(ctx context.Context, req *orchestratorv1.InitiatePaymentsRequest) (*orchestratorv1.InitiatePaymentsResponse, error)
|
ExecutePayment(ctx context.Context, req *orchestrationv2.ExecutePaymentRequest) (*orchestrationv2.ExecutePaymentResponse, error)
|
||||||
InitiatePayment(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error)
|
GetPayment(ctx context.Context, req *orchestrationv2.GetPaymentRequest) (*orchestrationv2.GetPaymentResponse, error)
|
||||||
CancelPayment(ctx context.Context, req *orchestratorv1.CancelPaymentRequest) (*orchestratorv1.CancelPaymentResponse, error)
|
ListPayments(ctx context.Context, req *orchestrationv2.ListPaymentsRequest) (*orchestrationv2.ListPaymentsResponse, error)
|
||||||
GetPayment(ctx context.Context, req *orchestratorv1.GetPaymentRequest) (*orchestratorv1.GetPaymentResponse, error)
|
|
||||||
ListPayments(ctx context.Context, req *orchestratorv1.ListPaymentsRequest) (*orchestratorv1.ListPaymentsResponse, error)
|
|
||||||
InitiateConversion(ctx context.Context, req *orchestratorv1.InitiateConversionRequest) (*orchestratorv1.InitiateConversionResponse, error)
|
|
||||||
ProcessTransferUpdate(ctx context.Context, req *orchestratorv1.ProcessTransferUpdateRequest) (*orchestratorv1.ProcessTransferUpdateResponse, error)
|
|
||||||
ProcessDepositObserved(ctx context.Context, req *orchestratorv1.ProcessDepositObservedRequest) (*orchestratorv1.ProcessDepositObservedResponse, error)
|
|
||||||
Close() error
|
Close() error
|
||||||
}
|
}
|
||||||
|
|
||||||
type grpcOrchestratorClient interface {
|
type grpcOrchestratorClient interface {
|
||||||
InitiatePayments(ctx context.Context, in *orchestratorv1.InitiatePaymentsRequest, opts ...grpc.CallOption) (*orchestratorv1.InitiatePaymentsResponse, error)
|
ExecutePayment(ctx context.Context, in *orchestrationv2.ExecutePaymentRequest, opts ...grpc.CallOption) (*orchestrationv2.ExecutePaymentResponse, error)
|
||||||
InitiatePayment(ctx context.Context, in *orchestratorv1.InitiatePaymentRequest, opts ...grpc.CallOption) (*orchestratorv1.InitiatePaymentResponse, error)
|
GetPayment(ctx context.Context, in *orchestrationv2.GetPaymentRequest, opts ...grpc.CallOption) (*orchestrationv2.GetPaymentResponse, error)
|
||||||
CancelPayment(ctx context.Context, in *orchestratorv1.CancelPaymentRequest, opts ...grpc.CallOption) (*orchestratorv1.CancelPaymentResponse, error)
|
ListPayments(ctx context.Context, in *orchestrationv2.ListPaymentsRequest, opts ...grpc.CallOption) (*orchestrationv2.ListPaymentsResponse, error)
|
||||||
GetPayment(ctx context.Context, in *orchestratorv1.GetPaymentRequest, opts ...grpc.CallOption) (*orchestratorv1.GetPaymentResponse, error)
|
|
||||||
ListPayments(ctx context.Context, in *orchestratorv1.ListPaymentsRequest, opts ...grpc.CallOption) (*orchestratorv1.ListPaymentsResponse, error)
|
|
||||||
InitiateConversion(ctx context.Context, in *orchestratorv1.InitiateConversionRequest, opts ...grpc.CallOption) (*orchestratorv1.InitiateConversionResponse, error)
|
|
||||||
ProcessTransferUpdate(ctx context.Context, in *orchestratorv1.ProcessTransferUpdateRequest, opts ...grpc.CallOption) (*orchestratorv1.ProcessTransferUpdateResponse, error)
|
|
||||||
ProcessDepositObserved(ctx context.Context, in *orchestratorv1.ProcessDepositObservedRequest, opts ...grpc.CallOption) (*orchestratorv1.ProcessDepositObservedResponse, error)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type orchestratorClient struct {
|
type orchestratorClient struct {
|
||||||
cfg Config
|
cfg Config
|
||||||
conn *grpc.ClientConn
|
conn *grpc.ClientConn
|
||||||
quoteConn *grpc.ClientConn
|
client grpcOrchestratorClient
|
||||||
client grpcOrchestratorClient
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// New dials the payment orchestrator endpoint and returns a ready client.
|
// New dials the payment orchestrator endpoint and returns a ready client.
|
||||||
@@ -52,29 +40,16 @@ func New(ctx context.Context, cfg Config, opts ...grpc.DialOption) (Client, erro
|
|||||||
if strings.TrimSpace(cfg.Address) == "" {
|
if strings.TrimSpace(cfg.Address) == "" {
|
||||||
return nil, merrors.InvalidArgument("payment-orchestrator: address is required")
|
return nil, merrors.InvalidArgument("payment-orchestrator: address is required")
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(cfg.QuoteAddress) == "" {
|
|
||||||
cfg.QuoteAddress = cfg.Address
|
|
||||||
}
|
|
||||||
|
|
||||||
conn, err := dial(ctx, cfg, cfg.Address, opts...)
|
conn, err := dial(ctx, cfg, cfg.Address, opts...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
quoteConn := conn
|
|
||||||
if cfg.QuoteAddress != cfg.Address {
|
|
||||||
quoteConn, err = dial(ctx, cfg, cfg.QuoteAddress, opts...)
|
|
||||||
if err != nil {
|
|
||||||
_ = conn.Close()
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return &orchestratorClient{
|
return &orchestratorClient{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
conn: conn,
|
conn: conn,
|
||||||
quoteConn: quoteConn,
|
client: orchestrationv2.NewPaymentOrchestratorServiceClient(conn),
|
||||||
client: orchestrationv1.NewPaymentExecutionServiceClient(conn),
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,11 +74,6 @@ func dial(ctx context.Context, cfg Config, address string, opts ...grpc.DialOpti
|
|||||||
|
|
||||||
// NewWithClient injects a pre-built orchestrator client (useful for tests).
|
// NewWithClient injects a pre-built orchestrator client (useful for tests).
|
||||||
func NewWithClient(cfg Config, oc grpcOrchestratorClient) Client {
|
func NewWithClient(cfg Config, oc grpcOrchestratorClient) Client {
|
||||||
return NewWithClients(cfg, oc)
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewWithClients injects pre-built orchestrator and quotation clients (useful for tests).
|
|
||||||
func NewWithClients(cfg Config, oc grpcOrchestratorClient) Client {
|
|
||||||
cfg.setDefaults()
|
cfg.setDefaults()
|
||||||
return &orchestratorClient{
|
return &orchestratorClient{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
@@ -111,69 +81,36 @@ func NewWithClients(cfg Config, oc grpcOrchestratorClient) Client {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewWithClients injects pre-built orchestrator and quotation clients (useful for tests).
|
||||||
|
func NewWithClients(cfg Config, oc grpcOrchestratorClient) Client {
|
||||||
|
return NewWithClient(cfg, oc)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *orchestratorClient) Close() error {
|
func (c *orchestratorClient) Close() error {
|
||||||
var firstErr error
|
if c == nil || c.conn == nil {
|
||||||
if c.quoteConn != nil && c.quoteConn != c.conn {
|
return nil
|
||||||
if err := c.quoteConn.Close(); err != nil {
|
|
||||||
firstErr = err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if c.conn != nil {
|
return c.conn.Close()
|
||||||
if err := c.conn.Close(); err != nil && firstErr == nil {
|
|
||||||
firstErr = err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return firstErr
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *orchestratorClient) InitiatePayments(ctx context.Context, req *orchestratorv1.InitiatePaymentsRequest) (*orchestratorv1.InitiatePaymentsResponse, error) {
|
func (c *orchestratorClient) ExecutePayment(ctx context.Context, req *orchestrationv2.ExecutePaymentRequest) (*orchestrationv2.ExecutePaymentResponse, error) {
|
||||||
ctx, cancel := c.callContext(ctx)
|
ctx, cancel := c.callContext(ctx)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
return c.client.InitiatePayments(ctx, req)
|
return c.client.ExecutePayment(ctx, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *orchestratorClient) InitiatePayment(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error) {
|
func (c *orchestratorClient) GetPayment(ctx context.Context, req *orchestrationv2.GetPaymentRequest) (*orchestrationv2.GetPaymentResponse, error) {
|
||||||
ctx, cancel := c.callContext(ctx)
|
|
||||||
defer cancel()
|
|
||||||
return c.client.InitiatePayment(ctx, req)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *orchestratorClient) CancelPayment(ctx context.Context, req *orchestratorv1.CancelPaymentRequest) (*orchestratorv1.CancelPaymentResponse, error) {
|
|
||||||
ctx, cancel := c.callContext(ctx)
|
|
||||||
defer cancel()
|
|
||||||
return c.client.CancelPayment(ctx, req)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *orchestratorClient) GetPayment(ctx context.Context, req *orchestratorv1.GetPaymentRequest) (*orchestratorv1.GetPaymentResponse, error) {
|
|
||||||
ctx, cancel := c.callContext(ctx)
|
ctx, cancel := c.callContext(ctx)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
return c.client.GetPayment(ctx, req)
|
return c.client.GetPayment(ctx, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *orchestratorClient) ListPayments(ctx context.Context, req *orchestratorv1.ListPaymentsRequest) (*orchestratorv1.ListPaymentsResponse, error) {
|
func (c *orchestratorClient) ListPayments(ctx context.Context, req *orchestrationv2.ListPaymentsRequest) (*orchestrationv2.ListPaymentsResponse, error) {
|
||||||
ctx, cancel := c.callContext(ctx)
|
ctx, cancel := c.callContext(ctx)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
return c.client.ListPayments(ctx, req)
|
return c.client.ListPayments(ctx, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *orchestratorClient) InitiateConversion(ctx context.Context, req *orchestratorv1.InitiateConversionRequest) (*orchestratorv1.InitiateConversionResponse, error) {
|
|
||||||
ctx, cancel := c.callContext(ctx)
|
|
||||||
defer cancel()
|
|
||||||
return c.client.InitiateConversion(ctx, req)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *orchestratorClient) ProcessTransferUpdate(ctx context.Context, req *orchestratorv1.ProcessTransferUpdateRequest) (*orchestratorv1.ProcessTransferUpdateResponse, error) {
|
|
||||||
ctx, cancel := c.callContext(ctx)
|
|
||||||
defer cancel()
|
|
||||||
return c.client.ProcessTransferUpdate(ctx, req)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *orchestratorClient) ProcessDepositObserved(ctx context.Context, req *orchestratorv1.ProcessDepositObservedRequest) (*orchestratorv1.ProcessDepositObservedResponse, error) {
|
|
||||||
ctx, cancel := c.callContext(ctx)
|
|
||||||
defer cancel()
|
|
||||||
return c.client.ProcessDepositObserved(ctx, req)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *orchestratorClient) callContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
func (c *orchestratorClient) callContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||||
timeout := c.cfg.CallTimeout
|
timeout := c.cfg.CallTimeout
|
||||||
if timeout <= 0 {
|
if timeout <= 0 {
|
||||||
|
|||||||
@@ -4,11 +4,10 @@ import "time"
|
|||||||
|
|
||||||
// Config captures connection settings for the payment orchestrator gRPC service.
|
// Config captures connection settings for the payment orchestrator gRPC service.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Address string
|
Address string
|
||||||
QuoteAddress string
|
DialTimeout time.Duration
|
||||||
DialTimeout time.Duration
|
CallTimeout time.Duration
|
||||||
CallTimeout time.Duration
|
Insecure bool
|
||||||
Insecure bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) setDefaults() {
|
func (c *Config) setDefaults() {
|
||||||
|
|||||||
@@ -3,76 +3,36 @@ package client
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestration/v1"
|
orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Fake implements Client for tests.
|
// Fake implements Client for tests.
|
||||||
type Fake struct {
|
type Fake struct {
|
||||||
InitiatePaymentsFn func(ctx context.Context, req *orchestratorv1.InitiatePaymentsRequest) (*orchestratorv1.InitiatePaymentsResponse, error)
|
ExecutePaymentFn func(ctx context.Context, req *orchestrationv2.ExecutePaymentRequest) (*orchestrationv2.ExecutePaymentResponse, error)
|
||||||
InitiatePaymentFn func(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error)
|
GetPaymentFn func(ctx context.Context, req *orchestrationv2.GetPaymentRequest) (*orchestrationv2.GetPaymentResponse, error)
|
||||||
CancelPaymentFn func(ctx context.Context, req *orchestratorv1.CancelPaymentRequest) (*orchestratorv1.CancelPaymentResponse, error)
|
ListPaymentsFn func(ctx context.Context, req *orchestrationv2.ListPaymentsRequest) (*orchestrationv2.ListPaymentsResponse, error)
|
||||||
GetPaymentFn func(ctx context.Context, req *orchestratorv1.GetPaymentRequest) (*orchestratorv1.GetPaymentResponse, error)
|
CloseFn func() error
|
||||||
ListPaymentsFn func(ctx context.Context, req *orchestratorv1.ListPaymentsRequest) (*orchestratorv1.ListPaymentsResponse, error)
|
|
||||||
InitiateConversionFn func(ctx context.Context, req *orchestratorv1.InitiateConversionRequest) (*orchestratorv1.InitiateConversionResponse, error)
|
|
||||||
ProcessTransferUpdateFn func(ctx context.Context, req *orchestratorv1.ProcessTransferUpdateRequest) (*orchestratorv1.ProcessTransferUpdateResponse, error)
|
|
||||||
ProcessDepositObservedFn func(ctx context.Context, req *orchestratorv1.ProcessDepositObservedRequest) (*orchestratorv1.ProcessDepositObservedResponse, error)
|
|
||||||
CloseFn func() error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *Fake) InitiatePayments(ctx context.Context, req *orchestratorv1.InitiatePaymentsRequest) (*orchestratorv1.InitiatePaymentsResponse, error) {
|
func (f *Fake) ExecutePayment(ctx context.Context, req *orchestrationv2.ExecutePaymentRequest) (*orchestrationv2.ExecutePaymentResponse, error) {
|
||||||
if f.InitiatePaymentsFn != nil {
|
if f.ExecutePaymentFn != nil {
|
||||||
return f.InitiatePaymentsFn(ctx, req)
|
return f.ExecutePaymentFn(ctx, req)
|
||||||
}
|
}
|
||||||
return &orchestratorv1.InitiatePaymentsResponse{}, nil
|
return &orchestrationv2.ExecutePaymentResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *Fake) InitiatePayment(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error) {
|
func (f *Fake) GetPayment(ctx context.Context, req *orchestrationv2.GetPaymentRequest) (*orchestrationv2.GetPaymentResponse, error) {
|
||||||
if f.InitiatePaymentFn != nil {
|
|
||||||
return f.InitiatePaymentFn(ctx, req)
|
|
||||||
}
|
|
||||||
return &orchestratorv1.InitiatePaymentResponse{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *Fake) CancelPayment(ctx context.Context, req *orchestratorv1.CancelPaymentRequest) (*orchestratorv1.CancelPaymentResponse, error) {
|
|
||||||
if f.CancelPaymentFn != nil {
|
|
||||||
return f.CancelPaymentFn(ctx, req)
|
|
||||||
}
|
|
||||||
return &orchestratorv1.CancelPaymentResponse{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *Fake) GetPayment(ctx context.Context, req *orchestratorv1.GetPaymentRequest) (*orchestratorv1.GetPaymentResponse, error) {
|
|
||||||
if f.GetPaymentFn != nil {
|
if f.GetPaymentFn != nil {
|
||||||
return f.GetPaymentFn(ctx, req)
|
return f.GetPaymentFn(ctx, req)
|
||||||
}
|
}
|
||||||
return &orchestratorv1.GetPaymentResponse{}, nil
|
return &orchestrationv2.GetPaymentResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *Fake) ListPayments(ctx context.Context, req *orchestratorv1.ListPaymentsRequest) (*orchestratorv1.ListPaymentsResponse, error) {
|
func (f *Fake) ListPayments(ctx context.Context, req *orchestrationv2.ListPaymentsRequest) (*orchestrationv2.ListPaymentsResponse, error) {
|
||||||
if f.ListPaymentsFn != nil {
|
if f.ListPaymentsFn != nil {
|
||||||
return f.ListPaymentsFn(ctx, req)
|
return f.ListPaymentsFn(ctx, req)
|
||||||
}
|
}
|
||||||
return &orchestratorv1.ListPaymentsResponse{}, nil
|
return &orchestrationv2.ListPaymentsResponse{}, nil
|
||||||
}
|
|
||||||
|
|
||||||
func (f *Fake) InitiateConversion(ctx context.Context, req *orchestratorv1.InitiateConversionRequest) (*orchestratorv1.InitiateConversionResponse, error) {
|
|
||||||
if f.InitiateConversionFn != nil {
|
|
||||||
return f.InitiateConversionFn(ctx, req)
|
|
||||||
}
|
|
||||||
return &orchestratorv1.InitiateConversionResponse{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *Fake) ProcessTransferUpdate(ctx context.Context, req *orchestratorv1.ProcessTransferUpdateRequest) (*orchestratorv1.ProcessTransferUpdateResponse, error) {
|
|
||||||
if f.ProcessTransferUpdateFn != nil {
|
|
||||||
return f.ProcessTransferUpdateFn(ctx, req)
|
|
||||||
}
|
|
||||||
return &orchestratorv1.ProcessTransferUpdateResponse{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *Fake) ProcessDepositObserved(ctx context.Context, req *orchestratorv1.ProcessDepositObservedRequest) (*orchestratorv1.ProcessDepositObservedResponse, error) {
|
|
||||||
if f.ProcessDepositObservedFn != nil {
|
|
||||||
return f.ProcessDepositObservedFn(ctx, req)
|
|
||||||
}
|
|
||||||
return &orchestratorv1.ProcessDepositObservedResponse{}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *Fake) Close() error {
|
func (f *Fake) Close() error {
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ replace github.com/tech/sendico/payments/storage => ../storage
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/prometheus/client_golang v1.23.2
|
|
||||||
github.com/shopspring/decimal v1.4.0
|
github.com/shopspring/decimal v1.4.0
|
||||||
github.com/tech/sendico/fx/oracle v0.0.0-00010101000000-000000000000
|
github.com/tech/sendico/fx/oracle v0.0.0-00010101000000-000000000000
|
||||||
github.com/tech/sendico/gateway/chain v0.0.0-00010101000000-000000000000
|
github.com/tech/sendico/gateway/chain v0.0.0-00010101000000-000000000000
|
||||||
@@ -46,9 +45,10 @@ require (
|
|||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/nats-io/nats.go v1.48.0 // indirect
|
github.com/nats-io/nats.go v1.49.0 // indirect
|
||||||
github.com/nats-io/nkeys v0.4.15 // indirect
|
github.com/nats-io/nkeys v0.4.15 // indirect
|
||||||
github.com/nats-io/nuid v1.0.1 // indirect
|
github.com/nats-io/nuid v1.0.1 // indirect
|
||||||
|
github.com/prometheus/client_golang v1.23.2 // indirect
|
||||||
github.com/prometheus/client_model v0.6.2 // indirect
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
github.com/prometheus/common v0.67.5 // indirect
|
github.com/prometheus/common v0.67.5 // indirect
|
||||||
github.com/prometheus/procfs v0.19.2 // indirect
|
github.com/prometheus/procfs v0.19.2 // indirect
|
||||||
@@ -64,5 +64,5 @@ require (
|
|||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.41.0 // indirect
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
golang.org/x/text v0.34.0 // indirect
|
golang.org/x/text v0.34.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -91,8 +91,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
|||||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
|
github.com/nats-io/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE=
|
||||||
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
github.com/nats-io/nats.go v1.49.0/go.mod h1:fDCn3mN5cY8HooHwE2ukiLb4p4G4ImmzvXyJt+tGwdw=
|
||||||
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
|
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
|
||||||
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
|
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
|
||||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||||
@@ -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=
|
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 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc h1:51Wupg8spF+5FC6D+iMKbOddFjMckETnNnEiZ+HX37s=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||||
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
||||||
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
ledgerclient "github.com/tech/sendico/ledger/client"
|
ledgerclient "github.com/tech/sendico/ledger/client"
|
||||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrator"
|
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrator"
|
||||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||||
quotationv1 "github.com/tech/sendico/pkg/proto/payments/quotation/v1"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type orchestratorDeps struct {
|
type orchestratorDeps struct {
|
||||||
@@ -14,7 +13,6 @@ type orchestratorDeps struct {
|
|||||||
ledgerClient ledgerclient.Client
|
ledgerClient ledgerclient.Client
|
||||||
mntxClient mntxclient.Client
|
mntxClient mntxclient.Client
|
||||||
oracleClient oracleclient.Client
|
oracleClient oracleclient.Client
|
||||||
quotationClient quotationv1.QuotationServiceClient
|
|
||||||
gatewayInvokeResolver orchestrator.GatewayInvokeResolver
|
gatewayInvokeResolver orchestrator.GatewayInvokeResolver
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,7 +30,6 @@ func (i *Imp) initDependencies(_ *config) *orchestratorDeps {
|
|||||||
deps.ledgerClient = &discoveryLedgerClient{resolver: i.discoveryClients}
|
deps.ledgerClient = &discoveryLedgerClient{resolver: i.discoveryClients}
|
||||||
deps.oracleClient = &discoveryOracleClient{resolver: i.discoveryClients}
|
deps.oracleClient = &discoveryOracleClient{resolver: i.discoveryClients}
|
||||||
deps.mntxClient = &discoveryMntxClient{resolver: i.discoveryClients}
|
deps.mntxClient = &discoveryMntxClient{resolver: i.discoveryClients}
|
||||||
deps.quotationClient = &discoveryQuotationClient{resolver: i.discoveryClients}
|
|
||||||
deps.gatewayInvokeResolver = discoveryGatewayInvokeResolver{resolver: i.discoveryClients}
|
deps.gatewayInvokeResolver = discoveryGatewayInvokeResolver{resolver: i.discoveryClients}
|
||||||
return deps
|
return deps
|
||||||
}
|
}
|
||||||
@@ -52,9 +49,6 @@ func (i *Imp) buildServiceOptions(cfg *config, deps *orchestratorDeps) []orchest
|
|||||||
opts = append(opts, orchestrator.WithMntxGateway(deps.mntxClient))
|
opts = append(opts, orchestrator.WithMntxGateway(deps.mntxClient))
|
||||||
}
|
}
|
||||||
|
|
||||||
if deps.quotationClient != nil {
|
|
||||||
opts = append(opts, orchestrator.WithQuotationService(deps.quotationClient))
|
|
||||||
}
|
|
||||||
opts = append(opts, orchestrator.WithMaxFXQuoteTTLMillis(cfg.maxFXQuoteTTLMillis()))
|
opts = append(opts, orchestrator.WithMaxFXQuoteTTLMillis(cfg.maxFXQuoteTTLMillis()))
|
||||||
if deps.gatewayInvokeResolver != nil {
|
if deps.gatewayInvokeResolver != nil {
|
||||||
opts = append(opts, orchestrator.WithGatewayInvokeResolver(deps.gatewayInvokeResolver))
|
opts = append(opts, orchestrator.WithGatewayInvokeResolver(deps.gatewayInvokeResolver))
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import (
|
|||||||
"github.com/tech/sendico/pkg/mlogger"
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
"github.com/tech/sendico/pkg/mservice"
|
"github.com/tech/sendico/pkg/mservice"
|
||||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||||
quotationv1 "github.com/tech/sendico/pkg/proto/payments/quotation/v1"
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
"google.golang.org/grpc/credentials"
|
"google.golang.org/grpc/credentials"
|
||||||
@@ -33,7 +32,6 @@ var (
|
|||||||
ledgerServiceNames = []string{"LEDGER", string(mservice.Ledger)}
|
ledgerServiceNames = []string{"LEDGER", string(mservice.Ledger)}
|
||||||
oracleServiceNames = []string{"FX_ORACLE", string(mservice.FXOracle)}
|
oracleServiceNames = []string{"FX_ORACLE", string(mservice.FXOracle)}
|
||||||
mntxServiceNames = []string{"CARD_PAYOUT_RAIL_GATEWAY", string(mservice.MntxGateway)}
|
mntxServiceNames = []string{"CARD_PAYOUT_RAIL_GATEWAY", string(mservice.MntxGateway)}
|
||||||
quoteServiceNames = []string{"PAYMENT_QUOTATION", "payment_quotation"}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type discoveryEndpoint struct {
|
type discoveryEndpoint struct {
|
||||||
@@ -55,9 +53,6 @@ type discoveryClientResolver struct {
|
|||||||
feesConn *grpc.ClientConn
|
feesConn *grpc.ClientConn
|
||||||
feesEndpoint discoveryEndpoint
|
feesEndpoint discoveryEndpoint
|
||||||
|
|
||||||
quoteConn *grpc.ClientConn
|
|
||||||
quoteEndpoint discoveryEndpoint
|
|
||||||
|
|
||||||
ledgerClient ledgerclient.Client
|
ledgerClient ledgerclient.Client
|
||||||
ledgerEndpoint discoveryEndpoint
|
ledgerEndpoint discoveryEndpoint
|
||||||
|
|
||||||
@@ -93,10 +88,6 @@ func (r *discoveryClientResolver) Close() {
|
|||||||
_ = r.feesConn.Close()
|
_ = r.feesConn.Close()
|
||||||
r.feesConn = nil
|
r.feesConn = nil
|
||||||
}
|
}
|
||||||
if r.quoteConn != nil {
|
|
||||||
_ = r.quoteConn.Close()
|
|
||||||
r.quoteConn = nil
|
|
||||||
}
|
|
||||||
if r.ledgerClient != nil {
|
if r.ledgerClient != nil {
|
||||||
_ = r.ledgerClient.Close()
|
_ = r.ledgerClient.Close()
|
||||||
r.ledgerClient = nil
|
r.ledgerClient = nil
|
||||||
@@ -137,11 +128,6 @@ func (r *discoveryClientResolver) MntxAvailable() bool {
|
|||||||
return ok
|
return ok
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *discoveryClientResolver) QuotationAvailable() bool {
|
|
||||||
_, ok := r.findEntry("quotation", quoteServiceNames, "", "")
|
|
||||||
return ok
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *discoveryClientResolver) FeesClient(ctx context.Context) (feesv1.FeeEngineClient, error) {
|
func (r *discoveryClientResolver) FeesClient(ctx context.Context) (feesv1.FeeEngineClient, error) {
|
||||||
entry, ok := r.findEntry("fees", feesServiceNames, "", "")
|
entry, ok := r.findEntry("fees", feesServiceNames, "", "")
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -173,37 +159,6 @@ func (r *discoveryClientResolver) FeesClient(ctx context.Context) (feesv1.FeeEng
|
|||||||
return feesv1.NewFeeEngineClient(r.feesConn), nil
|
return feesv1.NewFeeEngineClient(r.feesConn), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *discoveryClientResolver) QuotationClient(ctx context.Context) (quotationv1.QuotationServiceClient, error) {
|
|
||||||
entry, ok := r.findEntry("quotation", quoteServiceNames, "", "")
|
|
||||||
if !ok {
|
|
||||||
return nil, merrors.NoData("discovery: quotation service unavailable")
|
|
||||||
}
|
|
||||||
endpoint, err := parseDiscoveryEndpoint(entry.InvokeURI)
|
|
||||||
if err != nil {
|
|
||||||
r.logMissing("quotation", "invalid quotation invoke uri", entry.InvokeURI, err)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
r.mu.Lock()
|
|
||||||
defer r.mu.Unlock()
|
|
||||||
|
|
||||||
if r.quoteConn == nil || r.quoteEndpoint.key() != endpoint.key() || r.quoteEndpoint.address != endpoint.address {
|
|
||||||
if r.quoteConn != nil {
|
|
||||||
_ = r.quoteConn.Close()
|
|
||||||
r.quoteConn = nil
|
|
||||||
}
|
|
||||||
conn, dialErr := dialGrpc(ctx, endpoint)
|
|
||||||
if dialErr != nil {
|
|
||||||
r.logMissing("quotation", "failed to dial quotation service", endpoint.raw, dialErr)
|
|
||||||
return nil, dialErr
|
|
||||||
}
|
|
||||||
r.quoteConn = conn
|
|
||||||
r.quoteEndpoint = endpoint
|
|
||||||
}
|
|
||||||
|
|
||||||
return quotationv1.NewQuotationServiceClient(r.quoteConn), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *discoveryClientResolver) LedgerClient(ctx context.Context) (ledgerclient.Client, error) {
|
func (r *discoveryClientResolver) LedgerClient(ctx context.Context) (ledgerclient.Client, error) {
|
||||||
entry, ok := r.findEntry("ledger", ledgerServiceNames, "", "")
|
entry, ok := r.findEntry("ledger", ledgerServiceNames, "", "")
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -404,9 +359,6 @@ func (r *discoveryClientResolver) findEntry(key string, services []string, rail
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *discoveryClientResolver) logSelection(key, entryKey string, entry discovery.RegistryEntry) {
|
func (r *discoveryClientResolver) logSelection(key, entryKey string, entry discovery.RegistryEntry) {
|
||||||
if r.logger == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
last := r.lastSelection[key]
|
last := r.lastSelection[key]
|
||||||
if last == entryKey {
|
if last == entryKey {
|
||||||
@@ -426,9 +378,6 @@ func (r *discoveryClientResolver) logSelection(key, entryKey string, entry disco
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *discoveryClientResolver) logMissing(key, message, invokeURI string, err error) {
|
func (r *discoveryClientResolver) logMissing(key, message, invokeURI string, err error) {
|
||||||
if r.logger == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
last := r.lastMissing[key]
|
last := r.lastMissing[key]
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import (
|
|||||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||||
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||||
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
|
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
|
||||||
quotationv1 "github.com/tech/sendico/pkg/proto/payments/quotation/v1"
|
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -52,33 +51,6 @@ func (c *discoveryFeeClient) ValidateFeeToken(ctx context.Context, req *feesv1.V
|
|||||||
return client.ValidateFeeToken(ctx, req, opts...)
|
return client.ValidateFeeToken(ctx, req, opts...)
|
||||||
}
|
}
|
||||||
|
|
||||||
type discoveryQuotationClient struct {
|
|
||||||
resolver *discoveryClientResolver
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *discoveryQuotationClient) Available() bool {
|
|
||||||
if c == nil || c.resolver == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return c.resolver.QuotationAvailable()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *discoveryQuotationClient) QuotePayment(ctx context.Context, req *quotationv1.QuotePaymentRequest, opts ...grpc.CallOption) (*quotationv1.QuotePaymentResponse, error) {
|
|
||||||
client, err := c.resolver.QuotationClient(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return client.QuotePayment(ctx, req, opts...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *discoveryQuotationClient) QuotePayments(ctx context.Context, req *quotationv1.QuotePaymentsRequest, opts ...grpc.CallOption) (*quotationv1.QuotePaymentsResponse, error) {
|
|
||||||
client, err := c.resolver.QuotationClient(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return client.QuotePayments(ctx, req, opts...)
|
|
||||||
}
|
|
||||||
|
|
||||||
type discoveryLedgerClient struct {
|
type discoveryLedgerClient struct {
|
||||||
resolver *discoveryClientResolver
|
resolver *discoveryClientResolver
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ func (i *Imp) Start() error {
|
|||||||
return svc, nil
|
return svc, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
app, err := grpcapp.NewApp(i.logger, "payments_orchestrator", cfg.Config, i.debug, repoFactory, serviceFactory)
|
app, err := grpcapp.NewApp(i.logger, "payments.orchestrator", cfg.Config, i.debug, repoFactory, serviceFactory)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
|
|
||||||
"github.com/tech/sendico/payments/storage/model"
|
"github.com/tech/sendico/payments/storage/model"
|
||||||
"github.com/tech/sendico/pkg/db/storable"
|
"github.com/tech/sendico/pkg/db/storable"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
pm "github.com/tech/sendico/pkg/model"
|
pm "github.com/tech/sendico/pkg/model"
|
||||||
"go.mongodb.org/mongo-driver/v2/bson"
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
)
|
)
|
||||||
@@ -91,9 +92,19 @@ type Payment struct {
|
|||||||
StepExecutions []StepExecution
|
StepExecutions []StepExecution
|
||||||
}
|
}
|
||||||
|
|
||||||
func New() Factory {
|
// Dependencies configures aggregate factory integrations.
|
||||||
|
type Dependencies struct {
|
||||||
|
Logger mlogger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(deps ...Dependencies) Factory {
|
||||||
|
var dep Dependencies
|
||||||
|
if len(deps) > 0 {
|
||||||
|
dep = deps[0]
|
||||||
|
}
|
||||||
return &svc{
|
return &svc{
|
||||||
now: func() time.Time { return time.Now().UTC() },
|
logger: dep.Logger.Named("agg"),
|
||||||
|
now: func() time.Time { return time.Now().UTC() },
|
||||||
newID: func() bson.ObjectID {
|
newID: func() bson.ObjectID {
|
||||||
return bson.NewObjectID()
|
return bson.NewObjectID()
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,18 +7,45 @@ import (
|
|||||||
"github.com/tech/sendico/payments/storage/model"
|
"github.com/tech/sendico/payments/storage/model"
|
||||||
"github.com/tech/sendico/pkg/db/storable"
|
"github.com/tech/sendico/pkg/db/storable"
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
pm "github.com/tech/sendico/pkg/model"
|
pm "github.com/tech/sendico/pkg/model"
|
||||||
"go.mongodb.org/mongo-driver/v2/bson"
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
const initialVersion uint64 = 1
|
const initialVersion uint64 = 1
|
||||||
|
|
||||||
type svc struct {
|
type svc struct {
|
||||||
now func() time.Time
|
logger mlogger.Logger
|
||||||
newID func() bson.ObjectID
|
now func() time.Time
|
||||||
|
newID func() bson.ObjectID
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *svc) Create(in Input) (*Payment, error) {
|
func (s *svc) Create(in Input) (payment *Payment, err error) {
|
||||||
|
logger := s.logger
|
||||||
|
logger.Debug("Starting Create",
|
||||||
|
zap.String("organization_ref", in.OrganizationRef.Hex()),
|
||||||
|
zap.String("quotation_ref", strings.TrimSpace(in.QuotationRef)),
|
||||||
|
zap.Int("steps_count", len(in.Steps)),
|
||||||
|
)
|
||||||
|
defer func(start time.Time) {
|
||||||
|
fields := []zap.Field{zap.Int64("duration_ms", time.Since(start).Milliseconds())}
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("Failed to create", append(fields, zap.Error(err))...)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if payment == nil {
|
||||||
|
logger.Debug("Completed Create", append(fields, zap.Bool("payment_nil", true))...)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fields = append(fields,
|
||||||
|
zap.String("payment_ref", strings.TrimSpace(payment.PaymentRef)),
|
||||||
|
zap.String("state", string(payment.State)),
|
||||||
|
zap.Uint64("version", payment.Version),
|
||||||
|
)
|
||||||
|
logger.Debug("Completed Create", fields...)
|
||||||
|
}(time.Now())
|
||||||
|
|
||||||
if in.OrganizationRef.IsZero() {
|
if in.OrganizationRef.IsZero() {
|
||||||
return nil, merrors.InvalidArgument("organization_id is required")
|
return nil, merrors.InvalidArgument("organization_id is required")
|
||||||
}
|
}
|
||||||
@@ -67,7 +94,7 @@ func (s *svc) Create(in Input) (*Payment, error) {
|
|||||||
now := s.now().UTC()
|
now := s.now().UTC()
|
||||||
id := s.newID()
|
id := s.newID()
|
||||||
|
|
||||||
return &Payment{
|
payment = &Payment{
|
||||||
Base: storable.Base{
|
Base: storable.Base{
|
||||||
ID: id,
|
ID: id,
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
@@ -85,7 +112,8 @@ func (s *svc) Create(in Input) (*Payment, error) {
|
|||||||
State: StateCreated,
|
State: StateCreated,
|
||||||
Version: initialVersion,
|
Version: initialVersion,
|
||||||
StepExecutions: stepExecutions,
|
StepExecutions: stepExecutions,
|
||||||
}, nil
|
}
|
||||||
|
return payment, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildInitialStepTelemetry(shell []StepShell) ([]StepExecution, error) {
|
func buildInitialStepTelemetry(shell []StepShell) ([]StepExecution, error) {
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package erecon
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrStepNotFound = errors.New("step execution not found")
|
||||||
|
ErrAmbiguousStepMatch = errors.New("ambiguous step execution match")
|
||||||
|
ErrStepTransitionInvalid = errors.New("step transition invalid")
|
||||||
|
)
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
package erecon
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
)
|
||||||
|
|
||||||
|
type normalizedEvent struct {
|
||||||
|
stepRef string
|
||||||
|
matchRefs []agg.ExternalRef
|
||||||
|
appendRefs []agg.ExternalRef
|
||||||
|
targetState agg.StepState
|
||||||
|
failureCode string
|
||||||
|
failureMsg string
|
||||||
|
occurredAt *time.Time
|
||||||
|
forceAggregateFailed bool
|
||||||
|
forceAggregateNeedsAttention bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeEvent(event Event) (*normalizedEvent, error) {
|
||||||
|
if countPayloads(event) != 1 {
|
||||||
|
return nil, merrors.InvalidArgument("exactly one event payload is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if event.Gateway != nil {
|
||||||
|
return normalizeGatewayEvent(*event.Gateway)
|
||||||
|
}
|
||||||
|
if event.Ledger != nil {
|
||||||
|
return normalizeLedgerEvent(*event.Ledger)
|
||||||
|
}
|
||||||
|
return normalizeCardEvent(*event.Card)
|
||||||
|
}
|
||||||
|
|
||||||
|
func countPayloads(event Event) int {
|
||||||
|
count := 0
|
||||||
|
if event.Gateway != nil {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
if event.Ledger != nil {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
if event.Card != nil {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeGatewayEvent(src GatewayEvent) (*normalizedEvent, error) {
|
||||||
|
status, ok := normalizeGatewayStatus(src.Status)
|
||||||
|
if !ok {
|
||||||
|
return nil, merrors.InvalidArgument("gateway status is invalid")
|
||||||
|
}
|
||||||
|
|
||||||
|
target, needsAttention := mapFailureTarget(status, src.Retryable)
|
||||||
|
ev := &normalizedEvent{
|
||||||
|
stepRef: strings.TrimSpace(src.StepRef),
|
||||||
|
targetState: target,
|
||||||
|
failureCode: strings.TrimSpace(src.FailureCode),
|
||||||
|
failureMsg: strings.TrimSpace(src.FailureMsg),
|
||||||
|
occurredAt: normalizeTimePtr(src.OccurredAt),
|
||||||
|
forceAggregateFailed: src.TerminalFailure,
|
||||||
|
forceAggregateNeedsAttention: needsAttention,
|
||||||
|
}
|
||||||
|
ev.matchRefs = normalizeRefList([]agg.ExternalRef{
|
||||||
|
{
|
||||||
|
GatewayInstanceID: strings.TrimSpace(src.GatewayInstanceID),
|
||||||
|
Kind: ExternalRefKindOperation,
|
||||||
|
Ref: strings.TrimSpace(src.OperationRef),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
GatewayInstanceID: strings.TrimSpace(src.GatewayInstanceID),
|
||||||
|
Kind: ExternalRefKindTransfer,
|
||||||
|
Ref: strings.TrimSpace(src.TransferRef),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
ev.appendRefs = cloneRefs(ev.matchRefs)
|
||||||
|
|
||||||
|
if ev.stepRef == "" && len(ev.matchRefs) == 0 {
|
||||||
|
return nil, merrors.InvalidArgument("gateway event must include step_ref or operation/transfer reference")
|
||||||
|
}
|
||||||
|
if ev.targetState == agg.StepStateFailed && ev.failureMsg == "" {
|
||||||
|
ev.failureMsg = "gateway operation failed"
|
||||||
|
}
|
||||||
|
return ev, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeLedgerEvent(src LedgerEvent) (*normalizedEvent, error) {
|
||||||
|
status, ok := normalizeLedgerStatus(src.Status)
|
||||||
|
if !ok {
|
||||||
|
return nil, merrors.InvalidArgument("ledger status is invalid")
|
||||||
|
}
|
||||||
|
|
||||||
|
target, needsAttention := mapFailureTarget(status, src.Retryable)
|
||||||
|
ev := &normalizedEvent{
|
||||||
|
stepRef: strings.TrimSpace(src.StepRef),
|
||||||
|
targetState: target,
|
||||||
|
failureCode: strings.TrimSpace(src.FailureCode),
|
||||||
|
failureMsg: strings.TrimSpace(src.FailureMsg),
|
||||||
|
occurredAt: normalizeTimePtr(src.OccurredAt),
|
||||||
|
forceAggregateFailed: src.TerminalFailure,
|
||||||
|
forceAggregateNeedsAttention: needsAttention,
|
||||||
|
}
|
||||||
|
ev.matchRefs = normalizeRefList([]agg.ExternalRef{
|
||||||
|
{
|
||||||
|
Kind: ExternalRefKindLedger,
|
||||||
|
Ref: strings.TrimSpace(src.EntryRef),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
ev.appendRefs = cloneRefs(ev.matchRefs)
|
||||||
|
|
||||||
|
if ev.stepRef == "" && len(ev.matchRefs) == 0 {
|
||||||
|
return nil, merrors.InvalidArgument("ledger event must include step_ref or entry_ref")
|
||||||
|
}
|
||||||
|
if ev.targetState == agg.StepStateFailed && ev.failureMsg == "" {
|
||||||
|
ev.failureMsg = "ledger operation failed"
|
||||||
|
}
|
||||||
|
return ev, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeCardEvent(src CardEvent) (*normalizedEvent, error) {
|
||||||
|
status, ok := normalizeCardStatus(src.Status)
|
||||||
|
if !ok {
|
||||||
|
return nil, merrors.InvalidArgument("card status is invalid")
|
||||||
|
}
|
||||||
|
|
||||||
|
target, needsAttention := mapFailureTarget(status, src.Retryable)
|
||||||
|
ev := &normalizedEvent{
|
||||||
|
stepRef: strings.TrimSpace(src.StepRef),
|
||||||
|
targetState: target,
|
||||||
|
failureCode: strings.TrimSpace(src.FailureCode),
|
||||||
|
failureMsg: strings.TrimSpace(src.FailureMsg),
|
||||||
|
occurredAt: normalizeTimePtr(src.OccurredAt),
|
||||||
|
forceAggregateFailed: src.TerminalFailure,
|
||||||
|
forceAggregateNeedsAttention: needsAttention,
|
||||||
|
}
|
||||||
|
ev.matchRefs = normalizeRefList([]agg.ExternalRef{
|
||||||
|
{
|
||||||
|
GatewayInstanceID: strings.TrimSpace(src.GatewayInstanceID),
|
||||||
|
Kind: ExternalRefKindCardPayout,
|
||||||
|
Ref: strings.TrimSpace(src.PayoutRef),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
ev.appendRefs = cloneRefs(ev.matchRefs)
|
||||||
|
|
||||||
|
if ev.stepRef == "" && len(ev.matchRefs) == 0 {
|
||||||
|
return nil, merrors.InvalidArgument("card event must include step_ref or payout_ref")
|
||||||
|
}
|
||||||
|
if ev.targetState == agg.StepStateFailed && ev.failureMsg == "" {
|
||||||
|
ev.failureMsg = "card payout failed"
|
||||||
|
}
|
||||||
|
return ev, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeGatewayStatus(status GatewayStatus) (GatewayStatus, bool) {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(string(status))) {
|
||||||
|
case string(GatewayStatusCreated):
|
||||||
|
return GatewayStatusCreated, true
|
||||||
|
case string(GatewayStatusProcessing):
|
||||||
|
return GatewayStatusProcessing, true
|
||||||
|
case string(GatewayStatusWaiting):
|
||||||
|
return GatewayStatusWaiting, true
|
||||||
|
case string(GatewayStatusSuccess):
|
||||||
|
return GatewayStatusSuccess, true
|
||||||
|
case string(GatewayStatusFailed):
|
||||||
|
return GatewayStatusFailed, true
|
||||||
|
case string(GatewayStatusCancelled):
|
||||||
|
return GatewayStatusCancelled, true
|
||||||
|
default:
|
||||||
|
return GatewayStatusUnspecified, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeLedgerStatus(status LedgerStatus) (LedgerStatus, bool) {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(string(status))) {
|
||||||
|
case string(LedgerStatusPending):
|
||||||
|
return LedgerStatusPending, true
|
||||||
|
case string(LedgerStatusProcessing):
|
||||||
|
return LedgerStatusProcessing, true
|
||||||
|
case string(LedgerStatusPosted):
|
||||||
|
return LedgerStatusPosted, true
|
||||||
|
case string(LedgerStatusFailed):
|
||||||
|
return LedgerStatusFailed, true
|
||||||
|
case string(LedgerStatusCancelled):
|
||||||
|
return LedgerStatusCancelled, true
|
||||||
|
default:
|
||||||
|
return LedgerStatusUnspecified, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeCardStatus(status CardStatus) (CardStatus, bool) {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(string(status))) {
|
||||||
|
case string(CardStatusCreated):
|
||||||
|
return CardStatusCreated, true
|
||||||
|
case string(CardStatusProcessing):
|
||||||
|
return CardStatusProcessing, true
|
||||||
|
case string(CardStatusWaiting):
|
||||||
|
return CardStatusWaiting, true
|
||||||
|
case string(CardStatusSuccess):
|
||||||
|
return CardStatusSuccess, true
|
||||||
|
case string(CardStatusFailed):
|
||||||
|
return CardStatusFailed, true
|
||||||
|
case string(CardStatusCancelled):
|
||||||
|
return CardStatusCancelled, true
|
||||||
|
default:
|
||||||
|
return CardStatusUnspecified, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapFailureTarget(status any, retryable *bool) (agg.StepState, bool) {
|
||||||
|
switch status {
|
||||||
|
case GatewayStatusCreated, GatewayStatusProcessing, GatewayStatusWaiting:
|
||||||
|
return agg.StepStateRunning, false
|
||||||
|
case LedgerStatusPending, LedgerStatusProcessing:
|
||||||
|
return agg.StepStateRunning, false
|
||||||
|
case CardStatusCreated, CardStatusProcessing, CardStatusWaiting:
|
||||||
|
return agg.StepStateRunning, false
|
||||||
|
case GatewayStatusSuccess, LedgerStatusPosted, CardStatusSuccess:
|
||||||
|
return agg.StepStateCompleted, false
|
||||||
|
case GatewayStatusFailed, GatewayStatusCancelled, LedgerStatusFailed, LedgerStatusCancelled, CardStatusFailed, CardStatusCancelled:
|
||||||
|
if retryable != nil && !*retryable {
|
||||||
|
return agg.StepStateNeedsAttention, true
|
||||||
|
}
|
||||||
|
return agg.StepStateFailed, false
|
||||||
|
default:
|
||||||
|
return agg.StepStateUnspecified, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
out := make([]agg.ExternalRef, 0, len(refs))
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
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)
|
||||||
|
if ref.Kind == "" || ref.Ref == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := ref.GatewayInstanceID + "\x1f" + strings.ToLower(ref.Kind) + "\x1f" + strings.ToLower(ref.Ref)
|
||||||
|
if _, ok := seen[key]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
out = append(out, ref)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneRefs(refs []agg.ExternalRef) []agg.ExternalRef {
|
||||||
|
if len(refs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]agg.ExternalRef, 0, len(refs))
|
||||||
|
out = append(out, refs...)
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package erecon
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||||
|
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xerr"
|
||||||
|
)
|
||||||
|
|
||||||
|
func findStepIndex(payment *agg.Payment, event *normalizedEvent) (int, error) {
|
||||||
|
if payment == nil {
|
||||||
|
return -1, ErrStepNotFound
|
||||||
|
}
|
||||||
|
if event == nil {
|
||||||
|
return -1, ErrStepNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if stepRef := strings.TrimSpace(event.stepRef); stepRef != "" {
|
||||||
|
for i := range payment.StepExecutions {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(payment.StepExecutions[i].StepRef), stepRef) {
|
||||||
|
return i, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1, xerr.Wrapf(ErrStepNotFound, "step_ref=%s", stepRef)
|
||||||
|
}
|
||||||
|
|
||||||
|
matches := make([]int, 0, 1)
|
||||||
|
for i := range payment.StepExecutions {
|
||||||
|
if stepMatchesAnyRef(payment.StepExecutions[i], event.matchRefs) {
|
||||||
|
matches = append(matches, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch len(matches) {
|
||||||
|
case 0:
|
||||||
|
return -1, ErrStepNotFound
|
||||||
|
case 1:
|
||||||
|
return matches[0], nil
|
||||||
|
default:
|
||||||
|
return -1, ErrAmbiguousStepMatch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stepMatchesAnyRef(step agg.StepExecution, refs []agg.ExternalRef) bool {
|
||||||
|
if len(refs) == 0 || len(step.ExternalRefs) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := range refs {
|
||||||
|
if hasExternalRef(step.ExternalRefs, refs[i]) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasExternalRef(existing []agg.ExternalRef, ref agg.ExternalRef) bool {
|
||||||
|
kind := strings.TrimSpace(ref.Kind)
|
||||||
|
value := strings.TrimSpace(ref.Ref)
|
||||||
|
gatewayID := strings.TrimSpace(ref.GatewayInstanceID)
|
||||||
|
if kind == "" || value == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := range existing {
|
||||||
|
candidate := existing[i]
|
||||||
|
if !strings.EqualFold(strings.TrimSpace(candidate.Kind), kind) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(strings.TrimSpace(candidate.Ref), value) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if gatewayID != "" && strings.TrimSpace(candidate.GatewayInstanceID) != "" && !strings.EqualFold(strings.TrimSpace(candidate.GatewayInstanceID), gatewayID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeExternalRefs(existing []agg.ExternalRef, additions []agg.ExternalRef) ([]agg.ExternalRef, bool) {
|
||||||
|
if len(additions) == 0 {
|
||||||
|
return cloneRefs(existing), false
|
||||||
|
}
|
||||||
|
out := cloneRefs(existing)
|
||||||
|
changed := false
|
||||||
|
for i := range additions {
|
||||||
|
ref := additions[i]
|
||||||
|
if hasExternalRef(out, ref) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, agg.ExternalRef{
|
||||||
|
GatewayInstanceID: strings.TrimSpace(ref.GatewayInstanceID),
|
||||||
|
Kind: strings.TrimSpace(ref.Kind),
|
||||||
|
Ref: strings.TrimSpace(ref.Ref),
|
||||||
|
})
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
return out, changed
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
package erecon
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reconciler applies external async events to payment runtime state.
|
||||||
|
type Reconciler interface {
|
||||||
|
Reconcile(in Input) (*Output, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Input is the reconciliation payload.
|
||||||
|
type Input struct {
|
||||||
|
Payment *agg.Payment
|
||||||
|
Event Event
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output is the reconciliation result.
|
||||||
|
type Output struct {
|
||||||
|
Payment *agg.Payment
|
||||||
|
MatchedStepRef string
|
||||||
|
StepChanged bool
|
||||||
|
AggregateChanged bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event is one transport-agnostic external event envelope.
|
||||||
|
// Exactly one payload must be set.
|
||||||
|
type Event struct {
|
||||||
|
Gateway *GatewayEvent
|
||||||
|
Ledger *LedgerEvent
|
||||||
|
Card *CardEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
// GatewayStatus is gateway operation lifecycle status.
|
||||||
|
type GatewayStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
GatewayStatusUnspecified GatewayStatus = "unspecified"
|
||||||
|
GatewayStatusCreated GatewayStatus = "created"
|
||||||
|
GatewayStatusProcessing GatewayStatus = "processing"
|
||||||
|
GatewayStatusWaiting GatewayStatus = "waiting"
|
||||||
|
GatewayStatusSuccess GatewayStatus = "success"
|
||||||
|
GatewayStatusFailed GatewayStatus = "failed"
|
||||||
|
GatewayStatusCancelled GatewayStatus = "cancelled"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LedgerStatus is ledger operation lifecycle status.
|
||||||
|
type LedgerStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
LedgerStatusUnspecified LedgerStatus = "unspecified"
|
||||||
|
LedgerStatusPending LedgerStatus = "pending"
|
||||||
|
LedgerStatusProcessing LedgerStatus = "processing"
|
||||||
|
LedgerStatusPosted LedgerStatus = "posted"
|
||||||
|
LedgerStatusFailed LedgerStatus = "failed"
|
||||||
|
LedgerStatusCancelled LedgerStatus = "cancelled"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CardStatus is card payout lifecycle status.
|
||||||
|
type CardStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
CardStatusUnspecified CardStatus = "unspecified"
|
||||||
|
CardStatusCreated CardStatus = "created"
|
||||||
|
CardStatusProcessing CardStatus = "processing"
|
||||||
|
CardStatusWaiting CardStatus = "waiting"
|
||||||
|
CardStatusSuccess CardStatus = "success"
|
||||||
|
CardStatusFailed CardStatus = "failed"
|
||||||
|
CardStatusCancelled CardStatus = "cancelled"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GatewayEvent is one async event from gateway execution flow.
|
||||||
|
type GatewayEvent struct {
|
||||||
|
StepRef string
|
||||||
|
OperationRef string
|
||||||
|
TransferRef string
|
||||||
|
GatewayInstanceID string
|
||||||
|
Status GatewayStatus
|
||||||
|
FailureCode string
|
||||||
|
FailureMsg string
|
||||||
|
Retryable *bool
|
||||||
|
TerminalFailure bool
|
||||||
|
OccurredAt *time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// LedgerEvent is one async event from ledger flow.
|
||||||
|
type LedgerEvent struct {
|
||||||
|
StepRef string
|
||||||
|
EntryRef string
|
||||||
|
Status LedgerStatus
|
||||||
|
FailureCode string
|
||||||
|
FailureMsg string
|
||||||
|
Retryable *bool
|
||||||
|
TerminalFailure bool
|
||||||
|
OccurredAt *time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// CardEvent is one async event from card payout flow.
|
||||||
|
type CardEvent struct {
|
||||||
|
StepRef string
|
||||||
|
PayoutRef string
|
||||||
|
GatewayInstanceID string
|
||||||
|
Status CardStatus
|
||||||
|
FailureCode string
|
||||||
|
FailureMsg string
|
||||||
|
Retryable *bool
|
||||||
|
TerminalFailure bool
|
||||||
|
OccurredAt *time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
ExternalRefKindOperation = "operation_ref"
|
||||||
|
ExternalRefKindTransfer = "transfer_ref"
|
||||||
|
ExternalRefKindLedger = "ledger_entry_ref"
|
||||||
|
ExternalRefKindCardPayout = "card_payout_ref"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Dependencies configures reconciliation service integrations.
|
||||||
|
type Dependencies struct {
|
||||||
|
Logger mlogger.Logger
|
||||||
|
Now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(deps ...Dependencies) Reconciler {
|
||||||
|
var dep Dependencies
|
||||||
|
if len(deps) > 0 {
|
||||||
|
dep = deps[0]
|
||||||
|
}
|
||||||
|
now := dep.Now
|
||||||
|
if now == nil {
|
||||||
|
now = defaultNow
|
||||||
|
}
|
||||||
|
return &svc{
|
||||||
|
logger: dep.Logger.Named("erecon"),
|
||||||
|
now: now,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package erecon
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||||
|
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/ostate"
|
||||||
|
)
|
||||||
|
|
||||||
|
func reduceAggregateState(payment *agg.Payment, event *normalizedEvent, sm ostate.StateMachine) (bool, error) {
|
||||||
|
if payment == nil || sm == nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
target := deriveAggregateTarget(payment, event, sm)
|
||||||
|
return applyAggregateTarget(payment, target, sm)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deriveAggregateTarget(payment *agg.Payment, event *normalizedEvent, sm ostate.StateMachine) agg.State {
|
||||||
|
if payment == nil {
|
||||||
|
return agg.StateUnspecified
|
||||||
|
}
|
||||||
|
if event != nil && event.forceAggregateFailed {
|
||||||
|
return agg.StateFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
hasNeedsAttention := false
|
||||||
|
hasWork := false
|
||||||
|
allTerminalSuccessOrSkipped := len(payment.StepExecutions) > 0
|
||||||
|
|
||||||
|
for i := range payment.StepExecutions {
|
||||||
|
state := payment.StepExecutions[i].State
|
||||||
|
switch state {
|
||||||
|
case agg.StepStateCompleted, agg.StepStateSkipped:
|
||||||
|
hasWork = true
|
||||||
|
case agg.StepStatePending, agg.StepStateUnspecified:
|
||||||
|
allTerminalSuccessOrSkipped = false
|
||||||
|
case agg.StepStateNeedsAttention:
|
||||||
|
hasWork = true
|
||||||
|
hasNeedsAttention = true
|
||||||
|
allTerminalSuccessOrSkipped = false
|
||||||
|
case agg.StepStateRunning, agg.StepStateFailed:
|
||||||
|
hasWork = true
|
||||||
|
allTerminalSuccessOrSkipped = false
|
||||||
|
default:
|
||||||
|
allTerminalSuccessOrSkipped = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if allTerminalSuccessOrSkipped {
|
||||||
|
return agg.StateSettled
|
||||||
|
}
|
||||||
|
if hasNeedsAttention || (event != nil && event.forceAggregateNeedsAttention) {
|
||||||
|
return agg.StateNeedsAttention
|
||||||
|
}
|
||||||
|
if hasWork {
|
||||||
|
return agg.StateExecuting
|
||||||
|
}
|
||||||
|
if sm.IsAggregateTerminal(payment.State) {
|
||||||
|
return payment.State
|
||||||
|
}
|
||||||
|
return agg.StateCreated
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyAggregateTarget(payment *agg.Payment, target agg.State, sm ostate.StateMachine) (bool, error) {
|
||||||
|
if payment == nil || sm == nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
current := payment.State
|
||||||
|
if current == target {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if sm.IsAggregateTerminal(current) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
original := current
|
||||||
|
for i := 0; i < 6 && current != target; i++ {
|
||||||
|
if sm.EnsureAggregateTransition(current, target) == nil {
|
||||||
|
current = target
|
||||||
|
break
|
||||||
|
}
|
||||||
|
next, ok := nextAggregateHop(current, target)
|
||||||
|
if !ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if sm.EnsureAggregateTransition(current, next) != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
current = next
|
||||||
|
}
|
||||||
|
if current != target {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
payment.State = current
|
||||||
|
return payment.State != original, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func nextAggregateHop(current, target agg.State) (agg.State, bool) {
|
||||||
|
switch current {
|
||||||
|
case agg.StateUnspecified:
|
||||||
|
return agg.StateCreated, true
|
||||||
|
case agg.StateCreated:
|
||||||
|
if target == agg.StateFailed {
|
||||||
|
return agg.StateFailed, true
|
||||||
|
}
|
||||||
|
if target == agg.StateExecuting {
|
||||||
|
return agg.StateExecuting, true
|
||||||
|
}
|
||||||
|
return agg.StateExecuting, true
|
||||||
|
case agg.StateExecuting:
|
||||||
|
return target, true
|
||||||
|
case agg.StateNeedsAttention:
|
||||||
|
if target == agg.StateCreated {
|
||||||
|
return agg.StateExecuting, true
|
||||||
|
}
|
||||||
|
return target, true
|
||||||
|
default:
|
||||||
|
return agg.StateUnspecified, false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
package erecon
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/ostate"
|
||||||
|
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xerr"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
type svc struct {
|
||||||
|
logger mlogger.Logger
|
||||||
|
now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultNow() time.Time {
|
||||||
|
return time.Now().UTC()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *svc) Reconcile(in Input) (out *Output, err error) {
|
||||||
|
logger := s.logger
|
||||||
|
paymentRef := ""
|
||||||
|
if in.Payment != nil {
|
||||||
|
paymentRef = strings.TrimSpace(in.Payment.PaymentRef)
|
||||||
|
}
|
||||||
|
logger.Debug("Starting Reconcile",
|
||||||
|
zap.String("payment_ref", paymentRef),
|
||||||
|
zap.String("event_source", eventSource(in.Event)),
|
||||||
|
)
|
||||||
|
defer func(start time.Time) {
|
||||||
|
fields := []zap.Field{zap.Int64("duration_ms", time.Since(start).Milliseconds())}
|
||||||
|
if out != nil {
|
||||||
|
fields = append(fields,
|
||||||
|
zap.String("matched_step_ref", strings.TrimSpace(out.MatchedStepRef)),
|
||||||
|
zap.Bool("step_changed", out.StepChanged),
|
||||||
|
zap.Bool("aggregate_changed", out.AggregateChanged),
|
||||||
|
)
|
||||||
|
if out.Payment != nil {
|
||||||
|
fields = append(fields,
|
||||||
|
zap.String("payment_state", string(out.Payment.State)),
|
||||||
|
zap.Uint64("version", out.Payment.Version),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("Failed to reconcile", append(fields, zap.Error(err))...)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger.Debug("Completed Reconcile", fields...)
|
||||||
|
}(time.Now())
|
||||||
|
|
||||||
|
if in.Payment == nil {
|
||||||
|
return nil, merrors.InvalidArgument("payment is required")
|
||||||
|
}
|
||||||
|
if len(in.Payment.StepExecutions) == 0 {
|
||||||
|
return nil, merrors.InvalidArgument("payment.step_executions are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
event, err := normalizeEvent(in.Event)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
payment, err := clonePayment(in.Payment)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
idx, err := findStepIndex(payment, event)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
sm := ostate.New(ostate.Dependencies{Logger: logger.Named("ostate")})
|
||||||
|
stepChanged, err := s.applyStepEvent(&payment.StepExecutions[idx], event, sm)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
aggregateChanged, err := reduceAggregateState(payment, event, sm)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if stepChanged || aggregateChanged {
|
||||||
|
payment.Version++
|
||||||
|
payment.UpdatedAt = s.now().UTC()
|
||||||
|
}
|
||||||
|
|
||||||
|
out = &Output{
|
||||||
|
Payment: payment,
|
||||||
|
MatchedStepRef: payment.StepExecutions[idx].StepRef,
|
||||||
|
StepChanged: stepChanged,
|
||||||
|
AggregateChanged: aggregateChanged,
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *svc) applyStepEvent(step *agg.StepExecution, event *normalizedEvent, sm ostate.StateMachine) (bool, error) {
|
||||||
|
if step == nil || event == nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
changed := false
|
||||||
|
out := *step
|
||||||
|
|
||||||
|
refs, refsChanged := mergeExternalRefs(out.ExternalRefs, event.appendRefs)
|
||||||
|
if refsChanged {
|
||||||
|
out.ExternalRefs = refs
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
target := event.targetState
|
||||||
|
if target == agg.StepStateUnspecified {
|
||||||
|
*step = out
|
||||||
|
return changed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if out.State == target {
|
||||||
|
changed = s.applyStepDiagnostics(&out, event) || changed
|
||||||
|
*step = out
|
||||||
|
return changed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if sm.IsStepTerminal(out.State) {
|
||||||
|
*step = out
|
||||||
|
return changed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
next, transitionChanged, err := transitionStepState(out, target, sm)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
out = next
|
||||||
|
changed = changed || transitionChanged
|
||||||
|
changed = s.applyStepDiagnostics(&out, event) || changed
|
||||||
|
|
||||||
|
*step = out
|
||||||
|
return changed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func transitionStepState(step agg.StepExecution, target agg.StepState, sm ostate.StateMachine) (agg.StepExecution, bool, error) {
|
||||||
|
if step.State == target {
|
||||||
|
return step, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if sm.EnsureStepTransition(step.State, target) == nil {
|
||||||
|
step.State = target
|
||||||
|
return step, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
original := step.State
|
||||||
|
bridge := []agg.StepState{agg.StepStateRunning, target}
|
||||||
|
for i := range bridge {
|
||||||
|
next := bridge[i]
|
||||||
|
if step.State == next {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if sm.EnsureStepTransition(step.State, next) != nil {
|
||||||
|
return step, false, xerr.Wrapf(ErrStepTransitionInvalid, "%s -> %s", original, target)
|
||||||
|
}
|
||||||
|
step.State = next
|
||||||
|
}
|
||||||
|
return step, step.State != original, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *svc) applyStepDiagnostics(step *agg.StepExecution, event *normalizedEvent) bool {
|
||||||
|
if step == nil || event == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
now := s.now().UTC()
|
||||||
|
at := now
|
||||||
|
if event.occurredAt != nil {
|
||||||
|
at = event.occurredAt.UTC()
|
||||||
|
}
|
||||||
|
|
||||||
|
changed := false
|
||||||
|
switch step.State {
|
||||||
|
case agg.StepStateRunning:
|
||||||
|
if step.StartedAt == nil {
|
||||||
|
step.StartedAt = &at
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if step.CompletedAt != nil {
|
||||||
|
step.CompletedAt = nil
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if step.FailureCode != "" || step.FailureMsg != "" {
|
||||||
|
step.FailureCode = ""
|
||||||
|
step.FailureMsg = ""
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
case agg.StepStateCompleted:
|
||||||
|
if step.StartedAt == nil {
|
||||||
|
step.StartedAt = &at
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if step.CompletedAt == nil || !step.CompletedAt.Equal(at) {
|
||||||
|
step.CompletedAt = &at
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if step.FailureCode != "" || step.FailureMsg != "" {
|
||||||
|
step.FailureCode = ""
|
||||||
|
step.FailureMsg = ""
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
case agg.StepStateFailed, agg.StepStateNeedsAttention:
|
||||||
|
if step.StartedAt == nil {
|
||||||
|
step.StartedAt = &at
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if step.CompletedAt == nil || !step.CompletedAt.Equal(at) {
|
||||||
|
step.CompletedAt = &at
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
fc := strings.TrimSpace(event.failureCode)
|
||||||
|
fm := strings.TrimSpace(event.failureMsg)
|
||||||
|
if step.FailureCode != fc || step.FailureMsg != fm {
|
||||||
|
step.FailureCode = fc
|
||||||
|
step.FailureMsg = fm
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed
|
||||||
|
}
|
||||||
|
|
||||||
|
func clonePayment(payment *agg.Payment) (*agg.Payment, error) {
|
||||||
|
data, err := bson.Marshal(payment)
|
||||||
|
if err != nil {
|
||||||
|
return nil, merrors.Internal("payment clone failed")
|
||||||
|
}
|
||||||
|
var out agg.Payment
|
||||||
|
if err := bson.Unmarshal(data, &out); err != nil {
|
||||||
|
return nil, merrors.Internal("payment clone failed")
|
||||||
|
}
|
||||||
|
return &out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func eventSource(event Event) string {
|
||||||
|
switch {
|
||||||
|
case event.Gateway != nil:
|
||||||
|
return "gateway"
|
||||||
|
case event.Ledger != nil:
|
||||||
|
return "ledger"
|
||||||
|
case event.Card != nil:
|
||||||
|
return "card"
|
||||||
|
default:
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
package erecon
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestReconcile_GatewayWaiting_UpdatesRunningAndRefs(t *testing.T) {
|
||||||
|
now := time.Date(2026, time.January, 10, 11, 12, 13, 0, time.UTC)
|
||||||
|
reconciler := &svc{now: func() time.Time { return now }}
|
||||||
|
|
||||||
|
in := &agg.Payment{
|
||||||
|
PaymentRef: "p1",
|
||||||
|
State: agg.StateCreated,
|
||||||
|
Version: 7,
|
||||||
|
StepExecutions: []agg.StepExecution{
|
||||||
|
{StepRef: "s1", StepCode: "send", State: agg.StepStatePending, Attempt: 1},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := reconciler.Reconcile(Input{
|
||||||
|
Payment: in,
|
||||||
|
Event: Event{
|
||||||
|
Gateway: &GatewayEvent{
|
||||||
|
StepRef: "s1",
|
||||||
|
OperationRef: "op-1",
|
||||||
|
TransferRef: "tx-1",
|
||||||
|
GatewayInstanceID: "gw-1",
|
||||||
|
Status: GatewayStatusWaiting,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Reconcile returned error: %v", err)
|
||||||
|
}
|
||||||
|
if !out.StepChanged {
|
||||||
|
t.Fatal("expected step_changed")
|
||||||
|
}
|
||||||
|
if !out.AggregateChanged {
|
||||||
|
t.Fatal("expected aggregate_changed")
|
||||||
|
}
|
||||||
|
|
||||||
|
got := out.Payment.StepExecutions[0]
|
||||||
|
if got.State != agg.StepStateRunning {
|
||||||
|
t.Fatalf("step state mismatch: got=%q want=%q", got.State, agg.StepStateRunning)
|
||||||
|
}
|
||||||
|
if got.StartedAt == nil || !got.StartedAt.Equal(now) {
|
||||||
|
t.Fatalf("started_at mismatch: got=%v want=%v", got.StartedAt, now)
|
||||||
|
}
|
||||||
|
if got.CompletedAt != nil {
|
||||||
|
t.Fatalf("expected nil completed_at, got %v", got.CompletedAt)
|
||||||
|
}
|
||||||
|
if !hasRef(got.ExternalRefs, agg.ExternalRef{GatewayInstanceID: "gw-1", Kind: ExternalRefKindOperation, Ref: "op-1"}) {
|
||||||
|
t.Fatalf("expected operation_ref external reference")
|
||||||
|
}
|
||||||
|
if !hasRef(got.ExternalRefs, agg.ExternalRef{GatewayInstanceID: "gw-1", Kind: ExternalRefKindTransfer, Ref: "tx-1"}) {
|
||||||
|
t.Fatalf("expected transfer_ref external reference")
|
||||||
|
}
|
||||||
|
if out.Payment.State != agg.StateExecuting {
|
||||||
|
t.Fatalf("aggregate state mismatch: got=%q want=%q", out.Payment.State, agg.StateExecuting)
|
||||||
|
}
|
||||||
|
if out.Payment.Version != 8 {
|
||||||
|
t.Fatalf("version mismatch: got=%d want=%d", out.Payment.Version, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
if in.StepExecutions[0].State != agg.StepStatePending {
|
||||||
|
t.Fatalf("input payment was mutated")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReconcile_GatewaySuccess_SettlesPayment(t *testing.T) {
|
||||||
|
now := time.Date(2026, time.January, 10, 11, 12, 13, 0, time.UTC)
|
||||||
|
reconciler := &svc{now: func() time.Time { return now }}
|
||||||
|
|
||||||
|
out, err := reconciler.Reconcile(Input{
|
||||||
|
Payment: &agg.Payment{
|
||||||
|
PaymentRef: "p1",
|
||||||
|
State: agg.StateCreated,
|
||||||
|
Version: 1,
|
||||||
|
StepExecutions: []agg.StepExecution{
|
||||||
|
{StepRef: "s1", StepCode: "observe", State: agg.StepStatePending, Attempt: 1},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Event: Event{
|
||||||
|
Gateway: &GatewayEvent{
|
||||||
|
StepRef: "s1",
|
||||||
|
Status: GatewayStatusSuccess,
|
||||||
|
OperationRef: "op-1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Reconcile returned error: %v", err)
|
||||||
|
}
|
||||||
|
step := out.Payment.StepExecutions[0]
|
||||||
|
if step.State != agg.StepStateCompleted {
|
||||||
|
t.Fatalf("step state mismatch: got=%q want=%q", step.State, agg.StepStateCompleted)
|
||||||
|
}
|
||||||
|
if step.CompletedAt == nil || !step.CompletedAt.Equal(now) {
|
||||||
|
t.Fatalf("completed_at mismatch: got=%v want=%v", step.CompletedAt, now)
|
||||||
|
}
|
||||||
|
if out.Payment.State != agg.StateSettled {
|
||||||
|
t.Fatalf("aggregate state mismatch: got=%q want=%q", out.Payment.State, agg.StateSettled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReconcile_GatewayFailureMapping(t *testing.T) {
|
||||||
|
now := time.Date(2026, time.January, 10, 11, 12, 13, 0, time.UTC)
|
||||||
|
reconciler := &svc{now: func() time.Time { return now }}
|
||||||
|
|
||||||
|
retryable := true
|
||||||
|
out, err := reconciler.Reconcile(Input{
|
||||||
|
Payment: &agg.Payment{
|
||||||
|
PaymentRef: "p1",
|
||||||
|
State: agg.StateExecuting,
|
||||||
|
StepExecutions: []agg.StepExecution{
|
||||||
|
{StepRef: "s1", StepCode: "observe", State: agg.StepStateRunning, Attempt: 1},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Event: Event{
|
||||||
|
Gateway: &GatewayEvent{
|
||||||
|
StepRef: "s1",
|
||||||
|
Status: GatewayStatusFailed,
|
||||||
|
Retryable: &retryable,
|
||||||
|
FailureCode: "gw_timeout",
|
||||||
|
FailureMsg: "timeout",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Reconcile returned error: %v", err)
|
||||||
|
}
|
||||||
|
step := out.Payment.StepExecutions[0]
|
||||||
|
if step.State != agg.StepStateFailed {
|
||||||
|
t.Fatalf("step state mismatch: got=%q want=%q", step.State, agg.StepStateFailed)
|
||||||
|
}
|
||||||
|
if step.FailureCode != "gw_timeout" || step.FailureMsg != "timeout" {
|
||||||
|
t.Fatalf("failure details mismatch: code=%q msg=%q", step.FailureCode, step.FailureMsg)
|
||||||
|
}
|
||||||
|
if out.Payment.State != agg.StateExecuting {
|
||||||
|
t.Fatalf("aggregate state mismatch: got=%q want=%q", out.Payment.State, agg.StateExecuting)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonRetryable := false
|
||||||
|
out, err = reconciler.Reconcile(Input{
|
||||||
|
Payment: &agg.Payment{
|
||||||
|
PaymentRef: "p2",
|
||||||
|
State: agg.StateExecuting,
|
||||||
|
StepExecutions: []agg.StepExecution{
|
||||||
|
{StepRef: "s1", StepCode: "observe", State: agg.StepStateRunning, Attempt: 1},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Event: Event{
|
||||||
|
Gateway: &GatewayEvent{
|
||||||
|
StepRef: "s1",
|
||||||
|
Status: GatewayStatusFailed,
|
||||||
|
Retryable: &nonRetryable,
|
||||||
|
FailureCode: "gw_rejected",
|
||||||
|
FailureMsg: "rejected",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Reconcile returned error: %v", err)
|
||||||
|
}
|
||||||
|
step = out.Payment.StepExecutions[0]
|
||||||
|
if step.State != agg.StepStateNeedsAttention {
|
||||||
|
t.Fatalf("step state mismatch: got=%q want=%q", step.State, agg.StepStateNeedsAttention)
|
||||||
|
}
|
||||||
|
if out.Payment.State != agg.StateNeedsAttention {
|
||||||
|
t.Fatalf("aggregate state mismatch: got=%q want=%q", out.Payment.State, agg.StateNeedsAttention)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReconcile_LedgerTerminalFailure_ForcesAggregateFailed(t *testing.T) {
|
||||||
|
now := time.Date(2026, time.January, 10, 11, 12, 13, 0, time.UTC)
|
||||||
|
reconciler := &svc{now: func() time.Time { return now }}
|
||||||
|
|
||||||
|
out, err := reconciler.Reconcile(Input{
|
||||||
|
Payment: &agg.Payment{
|
||||||
|
PaymentRef: "p1",
|
||||||
|
State: agg.StateExecuting,
|
||||||
|
StepExecutions: []agg.StepExecution{
|
||||||
|
{
|
||||||
|
StepRef: "s1", StepCode: "ledger.debit", State: agg.StepStateRunning, Attempt: 1,
|
||||||
|
ExternalRefs: []agg.ExternalRef{{Kind: ExternalRefKindLedger, Ref: "entry-1"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Event: Event{
|
||||||
|
Ledger: &LedgerEvent{
|
||||||
|
EntryRef: "entry-1",
|
||||||
|
Status: LedgerStatusFailed,
|
||||||
|
FailureCode: "ledger_declined",
|
||||||
|
TerminalFailure: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Reconcile returned error: %v", err)
|
||||||
|
}
|
||||||
|
if out.Payment.StepExecutions[0].State != agg.StepStateFailed {
|
||||||
|
t.Fatalf("step state mismatch: got=%q want=%q", out.Payment.StepExecutions[0].State, agg.StepStateFailed)
|
||||||
|
}
|
||||||
|
if out.Payment.State != agg.StateFailed {
|
||||||
|
t.Fatalf("aggregate state mismatch: got=%q want=%q", out.Payment.State, agg.StateFailed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReconcile_CardMatchByExternalRef(t *testing.T) {
|
||||||
|
reconciler := &svc{now: defaultNow}
|
||||||
|
|
||||||
|
out, err := reconciler.Reconcile(Input{
|
||||||
|
Payment: &agg.Payment{
|
||||||
|
PaymentRef: "p1",
|
||||||
|
State: agg.StateExecuting,
|
||||||
|
StepExecutions: []agg.StepExecution{
|
||||||
|
{
|
||||||
|
StepRef: "s1", StepCode: "card.observe", State: agg.StepStateRunning, Attempt: 1,
|
||||||
|
ExternalRefs: []agg.ExternalRef{
|
||||||
|
{Kind: ExternalRefKindCardPayout, Ref: "payout-1"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Event: Event{
|
||||||
|
Card: &CardEvent{
|
||||||
|
PayoutRef: "payout-1",
|
||||||
|
Status: CardStatusSuccess,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Reconcile returned error: %v", err)
|
||||||
|
}
|
||||||
|
if out.MatchedStepRef != "s1" {
|
||||||
|
t.Fatalf("matched step mismatch: got=%q want=%q", out.MatchedStepRef, "s1")
|
||||||
|
}
|
||||||
|
if out.Payment.StepExecutions[0].State != agg.StepStateCompleted {
|
||||||
|
t.Fatalf("step state mismatch: got=%q want=%q", out.Payment.StepExecutions[0].State, agg.StepStateCompleted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReconcile_MatchingErrors(t *testing.T) {
|
||||||
|
reconciler := &svc{now: defaultNow}
|
||||||
|
|
||||||
|
_, err := reconciler.Reconcile(Input{
|
||||||
|
Payment: &agg.Payment{
|
||||||
|
PaymentRef: "p1",
|
||||||
|
State: agg.StateExecuting,
|
||||||
|
StepExecutions: []agg.StepExecution{
|
||||||
|
{
|
||||||
|
StepRef: "s1", StepCode: "a", State: agg.StepStateRunning,
|
||||||
|
ExternalRefs: []agg.ExternalRef{{Kind: ExternalRefKindTransfer, Ref: "tx-1"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
StepRef: "s2", StepCode: "b", State: agg.StepStateRunning,
|
||||||
|
ExternalRefs: []agg.ExternalRef{{Kind: ExternalRefKindTransfer, Ref: "tx-1"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Event: Event{
|
||||||
|
Gateway: &GatewayEvent{
|
||||||
|
TransferRef: "tx-1",
|
||||||
|
Status: GatewayStatusSuccess,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if !errors.Is(err, ErrAmbiguousStepMatch) {
|
||||||
|
t.Fatalf("expected ErrAmbiguousStepMatch, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = reconciler.Reconcile(Input{
|
||||||
|
Payment: &agg.Payment{
|
||||||
|
PaymentRef: "p1",
|
||||||
|
State: agg.StateExecuting,
|
||||||
|
StepExecutions: []agg.StepExecution{{StepRef: "s1", StepCode: "a", State: agg.StepStateRunning}},
|
||||||
|
},
|
||||||
|
Event: Event{
|
||||||
|
Gateway: &GatewayEvent{
|
||||||
|
TransferRef: "missing",
|
||||||
|
Status: GatewayStatusSuccess,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if !errors.Is(err, ErrStepNotFound) {
|
||||||
|
t.Fatalf("expected ErrStepNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReconcile_ValidationErrors(t *testing.T) {
|
||||||
|
reconciler := &svc{now: defaultNow}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
in Input
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "missing payment",
|
||||||
|
in: Input{
|
||||||
|
Event: Event{Gateway: &GatewayEvent{StepRef: "s1", Status: GatewayStatusSuccess}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing step executions",
|
||||||
|
in: Input{
|
||||||
|
Payment: &agg.Payment{},
|
||||||
|
Event: Event{Gateway: &GatewayEvent{StepRef: "s1", Status: GatewayStatusSuccess}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple payloads",
|
||||||
|
in: Input{
|
||||||
|
Payment: &agg.Payment{
|
||||||
|
StepExecutions: []agg.StepExecution{{StepRef: "s1", StepCode: "a", State: agg.StepStatePending}},
|
||||||
|
},
|
||||||
|
Event: Event{
|
||||||
|
Gateway: &GatewayEvent{StepRef: "s1", Status: GatewayStatusSuccess},
|
||||||
|
Ledger: &LedgerEvent{StepRef: "s1", Status: LedgerStatusPosted},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid status",
|
||||||
|
in: Input{
|
||||||
|
Payment: &agg.Payment{
|
||||||
|
StepExecutions: []agg.StepExecution{{StepRef: "s1", StepCode: "a", State: agg.StepStatePending}},
|
||||||
|
},
|
||||||
|
Event: Event{
|
||||||
|
Card: &CardEvent{StepRef: "s1", Status: CardStatus("bad")},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
_, err := reconciler.Reconcile(tt.in)
|
||||||
|
if !errors.Is(err, merrors.ErrInvalidArg) {
|
||||||
|
t.Fatalf("expected invalid argument error, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasRef(refs []agg.ExternalRef, wanted agg.ExternalRef) bool {
|
||||||
|
for i := range refs {
|
||||||
|
ref := refs[i]
|
||||||
|
if ref.Kind != wanted.Kind {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ref.Ref != wanted.Ref {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ref.GatewayInstanceID != wanted.GatewayInstanceID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package idem
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/payments/storage"
|
||||||
|
"github.com/tech/sendico/payments/storage/model"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeStore struct {
|
||||||
|
createFn func(ctx context.Context, payment *model.Payment) error
|
||||||
|
getByIdempotencyKeyFn func(ctx context.Context, orgRef bson.ObjectID, idempotencyKey string) (*model.Payment, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) Create(ctx context.Context, payment *model.Payment) error {
|
||||||
|
if f.createFn == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return f.createFn(ctx, payment)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) GetByIdempotencyKey(ctx context.Context, orgRef bson.ObjectID, idempotencyKey string) (*model.Payment, error) {
|
||||||
|
if f.getByIdempotencyKeyFn == nil {
|
||||||
|
return nil, storage.ErrPaymentNotFound
|
||||||
|
}
|
||||||
|
return f.getByIdempotencyKeyFn(ctx, orgRef, idempotencyKey)
|
||||||
|
}
|
||||||
@@ -4,13 +4,31 @@ import (
|
|||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
const hashSep = "\x1f"
|
const hashSep = "\x1f"
|
||||||
|
|
||||||
func (s *svc) Fingerprint(in FPInput) (string, error) {
|
func (s *svc) Fingerprint(in FPInput) (fingerprint string, err error) {
|
||||||
|
logger := s.logger
|
||||||
|
logger.Debug("Starting Fingerprint",
|
||||||
|
zap.String("organization_ref", strings.ToLower(strings.TrimSpace(in.OrganizationRef))),
|
||||||
|
zap.String("quotation_ref", strings.TrimSpace(in.QuotationRef)),
|
||||||
|
zap.String("intent_ref", strings.TrimSpace(in.IntentRef)),
|
||||||
|
zap.Bool("has_client_payment_ref", strings.TrimSpace(in.ClientPaymentRef) != ""),
|
||||||
|
)
|
||||||
|
defer func(start time.Time) {
|
||||||
|
fields := []zap.Field{zap.Int64("duration_ms", time.Since(start).Milliseconds())}
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("Failed to fingerprint", append(fields, zap.Error(err))...)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger.Debug("Completed Fingerprint", append(fields, zap.Bool("generated", strings.TrimSpace(fingerprint) != ""))...)
|
||||||
|
}(time.Now())
|
||||||
|
|
||||||
orgRef := strings.ToLower(strings.TrimSpace(in.OrganizationRef))
|
orgRef := strings.ToLower(strings.TrimSpace(in.OrganizationRef))
|
||||||
if orgRef == "" {
|
if orgRef == "" {
|
||||||
return "", merrors.InvalidArgument("organization_ref is required")
|
return "", merrors.InvalidArgument("organization_ref is required")
|
||||||
@@ -29,7 +47,8 @@ func (s *svc) Fingerprint(in FPInput) (string, error) {
|
|||||||
"client=" + clientPaymentRef,
|
"client=" + clientPaymentRef,
|
||||||
}, hashSep)
|
}, hashSep)
|
||||||
|
|
||||||
return hashBytes([]byte(payload)), nil
|
fingerprint = hashBytes([]byte(payload))
|
||||||
|
return fingerprint, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func hashBytes(data []byte) string {
|
func hashBytes(data []byte) string {
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package idem
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFingerprint_StableAndTrimmed(t *testing.T) {
|
||||||
|
svc := New()
|
||||||
|
|
||||||
|
a, err := svc.Fingerprint(FPInput{
|
||||||
|
OrganizationRef: " 65f1a2c6f3c5e2e7a1b2c3d4 ",
|
||||||
|
QuotationRef: " quote-1 ",
|
||||||
|
IntentRef: " intent-1 ",
|
||||||
|
ClientPaymentRef: " client-1 ",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Fingerprint returned error: %v", err)
|
||||||
|
}
|
||||||
|
b, err := svc.Fingerprint(FPInput{
|
||||||
|
OrganizationRef: "65F1A2C6F3C5E2E7A1B2C3D4",
|
||||||
|
QuotationRef: "quote-1",
|
||||||
|
IntentRef: "intent-1",
|
||||||
|
ClientPaymentRef: "client-1",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Fingerprint returned error: %v", err)
|
||||||
|
}
|
||||||
|
if a != b {
|
||||||
|
t.Fatalf("expected deterministic fingerprint, got %q vs %q", a, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFingerprint_ChangesOnPayload(t *testing.T) {
|
||||||
|
svc := New()
|
||||||
|
|
||||||
|
base, err := svc.Fingerprint(FPInput{
|
||||||
|
OrganizationRef: "65f1a2c6f3c5e2e7a1b2c3d4",
|
||||||
|
QuotationRef: "quote-1",
|
||||||
|
IntentRef: "intent-1",
|
||||||
|
ClientPaymentRef: "client-1",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Fingerprint returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
diffQuote, err := svc.Fingerprint(FPInput{
|
||||||
|
OrganizationRef: "65f1a2c6f3c5e2e7a1b2c3d4",
|
||||||
|
QuotationRef: "quote-2",
|
||||||
|
IntentRef: "intent-1",
|
||||||
|
ClientPaymentRef: "client-1",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Fingerprint returned error: %v", err)
|
||||||
|
}
|
||||||
|
if base == diffQuote {
|
||||||
|
t.Fatalf("expected different fingerprint for different quotation_ref")
|
||||||
|
}
|
||||||
|
|
||||||
|
diffClient, err := svc.Fingerprint(FPInput{
|
||||||
|
OrganizationRef: "65f1a2c6f3c5e2e7a1b2c3d4",
|
||||||
|
QuotationRef: "quote-1",
|
||||||
|
IntentRef: "intent-1",
|
||||||
|
ClientPaymentRef: "client-2",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Fingerprint returned error: %v", err)
|
||||||
|
}
|
||||||
|
if base == diffClient {
|
||||||
|
t.Fatalf("expected different fingerprint for different client_payment_ref")
|
||||||
|
}
|
||||||
|
|
||||||
|
diffIntent, err := svc.Fingerprint(FPInput{
|
||||||
|
OrganizationRef: "65f1a2c6f3c5e2e7a1b2c3d4",
|
||||||
|
QuotationRef: "quote-1",
|
||||||
|
IntentRef: "intent-2",
|
||||||
|
ClientPaymentRef: "client-1",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Fingerprint returned error: %v", err)
|
||||||
|
}
|
||||||
|
if base == diffIntent {
|
||||||
|
t.Fatalf("expected different fingerprint for different intent_ref")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFingerprint_RequiresBusinessFields(t *testing.T) {
|
||||||
|
svc := New()
|
||||||
|
|
||||||
|
if _, err := svc.Fingerprint(FPInput{
|
||||||
|
QuotationRef: "quote-1",
|
||||||
|
}); err == nil {
|
||||||
|
t.Fatal("expected error for empty organization_ref")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := svc.Fingerprint(FPInput{
|
||||||
|
OrganizationRef: "65f1a2c6f3c5e2e7a1b2c3d4",
|
||||||
|
}); err == nil {
|
||||||
|
t.Fatal("expected error for empty quotation_ref")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
|
|
||||||
"github.com/tech/sendico/payments/storage/model"
|
"github.com/tech/sendico/payments/storage/model"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
"go.mongodb.org/mongo-driver/v2/bson"
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -41,6 +42,15 @@ type CreateInput struct {
|
|||||||
Reuse ReuseInput
|
Reuse ReuseInput
|
||||||
}
|
}
|
||||||
|
|
||||||
func New() Service {
|
// Dependencies configures idempotency service integrations.
|
||||||
return &svc{}
|
type Dependencies struct {
|
||||||
|
Logger mlogger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(deps ...Dependencies) Service {
|
||||||
|
var dep Dependencies
|
||||||
|
if len(deps) > 0 {
|
||||||
|
dep = deps[0]
|
||||||
|
}
|
||||||
|
return &svc{logger: dep.Logger.Named("idem")}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,101 +10,6 @@ import (
|
|||||||
"go.mongodb.org/mongo-driver/v2/bson"
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestFingerprint_StableAndTrimmed(t *testing.T) {
|
|
||||||
svc := New()
|
|
||||||
|
|
||||||
a, err := svc.Fingerprint(FPInput{
|
|
||||||
OrganizationRef: " 65f1a2c6f3c5e2e7a1b2c3d4 ",
|
|
||||||
QuotationRef: " quote-1 ",
|
|
||||||
IntentRef: " intent-1 ",
|
|
||||||
ClientPaymentRef: " client-1 ",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Fingerprint returned error: %v", err)
|
|
||||||
}
|
|
||||||
b, err := svc.Fingerprint(FPInput{
|
|
||||||
OrganizationRef: "65F1A2C6F3C5E2E7A1B2C3D4",
|
|
||||||
QuotationRef: "quote-1",
|
|
||||||
IntentRef: "intent-1",
|
|
||||||
ClientPaymentRef: "client-1",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Fingerprint returned error: %v", err)
|
|
||||||
}
|
|
||||||
if a != b {
|
|
||||||
t.Fatalf("expected deterministic fingerprint, got %q vs %q", a, b)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFingerprint_ChangesOnPayload(t *testing.T) {
|
|
||||||
svc := New()
|
|
||||||
|
|
||||||
base, err := svc.Fingerprint(FPInput{
|
|
||||||
OrganizationRef: "65f1a2c6f3c5e2e7a1b2c3d4",
|
|
||||||
QuotationRef: "quote-1",
|
|
||||||
IntentRef: "intent-1",
|
|
||||||
ClientPaymentRef: "client-1",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Fingerprint returned error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
diffQuote, err := svc.Fingerprint(FPInput{
|
|
||||||
OrganizationRef: "65f1a2c6f3c5e2e7a1b2c3d4",
|
|
||||||
QuotationRef: "quote-2",
|
|
||||||
IntentRef: "intent-1",
|
|
||||||
ClientPaymentRef: "client-1",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Fingerprint returned error: %v", err)
|
|
||||||
}
|
|
||||||
if base == diffQuote {
|
|
||||||
t.Fatalf("expected different fingerprint for different quotation_ref")
|
|
||||||
}
|
|
||||||
|
|
||||||
diffClient, err := svc.Fingerprint(FPInput{
|
|
||||||
OrganizationRef: "65f1a2c6f3c5e2e7a1b2c3d4",
|
|
||||||
QuotationRef: "quote-1",
|
|
||||||
IntentRef: "intent-1",
|
|
||||||
ClientPaymentRef: "client-2",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Fingerprint returned error: %v", err)
|
|
||||||
}
|
|
||||||
if base == diffClient {
|
|
||||||
t.Fatalf("expected different fingerprint for different client_payment_ref")
|
|
||||||
}
|
|
||||||
|
|
||||||
diffIntent, err := svc.Fingerprint(FPInput{
|
|
||||||
OrganizationRef: "65f1a2c6f3c5e2e7a1b2c3d4",
|
|
||||||
QuotationRef: "quote-1",
|
|
||||||
IntentRef: "intent-2",
|
|
||||||
ClientPaymentRef: "client-1",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Fingerprint returned error: %v", err)
|
|
||||||
}
|
|
||||||
if base == diffIntent {
|
|
||||||
t.Fatalf("expected different fingerprint for different intent_ref")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFingerprint_RequiresBusinessFields(t *testing.T) {
|
|
||||||
svc := New()
|
|
||||||
|
|
||||||
if _, err := svc.Fingerprint(FPInput{
|
|
||||||
QuotationRef: "quote-1",
|
|
||||||
}); err == nil {
|
|
||||||
t.Fatal("expected error for empty organization_ref")
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := svc.Fingerprint(FPInput{
|
|
||||||
OrganizationRef: "65f1a2c6f3c5e2e7a1b2c3d4",
|
|
||||||
}); err == nil {
|
|
||||||
t.Fatal("expected error for empty quotation_ref")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTryReuse_NotFound(t *testing.T) {
|
func TestTryReuse_NotFound(t *testing.T) {
|
||||||
svc := New()
|
svc := New()
|
||||||
store := &fakeStore{
|
store := &fakeStore{
|
||||||
@@ -294,22 +199,3 @@ func TestCreateOrReuse_DuplicateWithoutReusableRecordReturnsDuplicate(t *testing
|
|||||||
t.Fatalf("expected ErrDuplicatePayment, got %v", err)
|
t.Fatalf("expected ErrDuplicatePayment, got %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type fakeStore struct {
|
|
||||||
createFn func(ctx context.Context, payment *model.Payment) error
|
|
||||||
getByIdempotencyKeyFn func(ctx context.Context, orgRef bson.ObjectID, idempotencyKey string) (*model.Payment, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeStore) Create(ctx context.Context, payment *model.Payment) error {
|
|
||||||
if f.createFn == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return f.createFn(ctx, payment)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeStore) GetByIdempotencyKey(ctx context.Context, orgRef bson.ObjectID, idempotencyKey string) (*model.Payment, error) {
|
|
||||||
if f.getByIdempotencyKeyFn == nil {
|
|
||||||
return nil, storage.ErrPaymentNotFound
|
|
||||||
}
|
|
||||||
return f.getByIdempotencyKeyFn(ctx, orgRef, idempotencyKey)
|
|
||||||
}
|
|
||||||
@@ -4,21 +4,46 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/tech/sendico/payments/storage"
|
"github.com/tech/sendico/payments/storage"
|
||||||
"github.com/tech/sendico/payments/storage/model"
|
"github.com/tech/sendico/payments/storage/model"
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
const reqHashMetaKey = "_orchestrator_v2_req_hash"
|
const reqHashMetaKey = "_orchestrator_v2_req_hash"
|
||||||
|
|
||||||
type svc struct{}
|
type svc struct {
|
||||||
|
logger mlogger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
func (s *svc) TryReuse(
|
func (s *svc) TryReuse(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
store Store,
|
store Store,
|
||||||
in ReuseInput,
|
in ReuseInput,
|
||||||
) (*model.Payment, bool, error) {
|
) (payment *model.Payment, reused bool, err error) {
|
||||||
|
logger := s.logger
|
||||||
|
logger.Debug("Starting Try reuse",
|
||||||
|
zap.String("organization_ref", in.OrganizationID.Hex()),
|
||||||
|
zap.Bool("has_idempotency_key", strings.TrimSpace(in.IdempotencyKey) != ""),
|
||||||
|
)
|
||||||
|
defer func(start time.Time) {
|
||||||
|
fields := []zap.Field{
|
||||||
|
zap.Int64("duration_ms", time.Since(start).Milliseconds()),
|
||||||
|
zap.Bool("reused", reused),
|
||||||
|
}
|
||||||
|
if payment != nil {
|
||||||
|
fields = append(fields, zap.String("payment_ref", strings.TrimSpace(payment.PaymentRef)))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("Failed to try reuse", append(fields, zap.Error(err))...)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger.Debug("Completed Try reuse", fields...)
|
||||||
|
}(time.Now())
|
||||||
|
|
||||||
if store == nil {
|
if store == nil {
|
||||||
return nil, false, merrors.InvalidArgument("payments store is required")
|
return nil, false, merrors.InvalidArgument("payments store is required")
|
||||||
}
|
}
|
||||||
@@ -28,7 +53,7 @@ func (s *svc) TryReuse(
|
|||||||
return nil, false, err
|
return nil, false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
payment, err := store.GetByIdempotencyKey(ctx, in.OrganizationID, idempotencyKey)
|
payment, err = store.GetByIdempotencyKey(ctx, in.OrganizationID, idempotencyKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, storage.ErrPaymentNotFound) || errors.Is(err, merrors.ErrNoData) {
|
if errors.Is(err, storage.ErrPaymentNotFound) || errors.Is(err, merrors.ErrNoData) {
|
||||||
return nil, false, nil
|
return nil, false, nil
|
||||||
@@ -50,7 +75,28 @@ func (s *svc) CreateOrReuse(
|
|||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
store Store,
|
store Store,
|
||||||
in CreateInput,
|
in CreateInput,
|
||||||
) (*model.Payment, bool, error) {
|
) (payment *model.Payment, reused bool, err error) {
|
||||||
|
logger := s.logger
|
||||||
|
logger.Debug("Starting Create or reuse",
|
||||||
|
zap.String("organization_ref", in.Reuse.OrganizationID.Hex()),
|
||||||
|
zap.Bool("has_payment", in.Payment != nil),
|
||||||
|
zap.Bool("has_idempotency_key", strings.TrimSpace(in.Reuse.IdempotencyKey) != ""),
|
||||||
|
)
|
||||||
|
defer func(start time.Time) {
|
||||||
|
fields := []zap.Field{
|
||||||
|
zap.Int64("duration_ms", time.Since(start).Milliseconds()),
|
||||||
|
zap.Bool("reused", reused),
|
||||||
|
}
|
||||||
|
if payment != nil {
|
||||||
|
fields = append(fields, zap.String("payment_ref", strings.TrimSpace(payment.PaymentRef)))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("Failed to create or reuse", append(fields, zap.Error(err))...)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger.Debug("Completed Create or reuse", fields...)
|
||||||
|
}(time.Now())
|
||||||
|
|
||||||
if store == nil {
|
if store == nil {
|
||||||
return nil, false, merrors.InvalidArgument("payments store is required")
|
return nil, false, merrors.InvalidArgument("payments store is required")
|
||||||
}
|
}
|
||||||
@@ -64,19 +110,19 @@ func (s *svc) CreateOrReuse(
|
|||||||
}
|
}
|
||||||
setPaymentReqHash(in.Payment, fingerprint)
|
setPaymentReqHash(in.Payment, fingerprint)
|
||||||
|
|
||||||
if err := store.Create(ctx, in.Payment); err != nil {
|
if createErr := store.Create(ctx, in.Payment); createErr != nil {
|
||||||
if !errors.Is(err, storage.ErrDuplicatePayment) {
|
if !errors.Is(createErr, storage.ErrDuplicatePayment) {
|
||||||
return nil, false, err
|
return nil, false, createErr
|
||||||
}
|
}
|
||||||
|
|
||||||
payment, reused, reuseErr := s.TryReuse(ctx, store, in.Reuse)
|
payment, reused, err = s.TryReuse(ctx, store, in.Reuse)
|
||||||
if reuseErr != nil {
|
if err != nil {
|
||||||
return nil, false, reuseErr
|
return nil, false, err
|
||||||
}
|
}
|
||||||
if reused {
|
if reused {
|
||||||
return payment, true, nil
|
return payment, true, nil
|
||||||
}
|
}
|
||||||
return nil, false, err
|
return nil, false, createErr
|
||||||
}
|
}
|
||||||
|
|
||||||
return in.Payment, false, nil
|
return in.Payment, false, nil
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package oobs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
)
|
||||||
|
|
||||||
|
type memoryAuditStore struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
byPayment map[string][]TimelineEntry
|
||||||
|
byStep map[string][]TimelineEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMemoryAuditStore() AuditStore {
|
||||||
|
return &memoryAuditStore{
|
||||||
|
byPayment: map[string][]TimelineEntry{},
|
||||||
|
byStep: map[string][]TimelineEntry{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *memoryAuditStore) Append(entry TimelineEntry) error {
|
||||||
|
paymentRef := strings.TrimSpace(entry.PaymentRef)
|
||||||
|
if paymentRef == "" {
|
||||||
|
return merrors.InvalidArgument("timeline.payment_ref is required")
|
||||||
|
}
|
||||||
|
stepRef := strings.TrimSpace(entry.StepRef)
|
||||||
|
entry.PaymentRef = paymentRef
|
||||||
|
entry.StepRef = stepRef
|
||||||
|
entry.StepCode = strings.TrimSpace(entry.StepCode)
|
||||||
|
entry.Event = strings.TrimSpace(entry.Event)
|
||||||
|
entry.State = strings.TrimSpace(entry.State)
|
||||||
|
entry.Message = strings.TrimSpace(entry.Message)
|
||||||
|
entry.Fields = trimStringMap(entry.Fields)
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
s.byPayment[paymentRef] = append(s.byPayment[paymentRef], cloneTimelineEntry(entry))
|
||||||
|
if stepRef == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
key := stepAttemptKey(paymentRef, stepRef, entry.Attempt)
|
||||||
|
s.byStep[key] = append(s.byStep[key], cloneTimelineEntry(entry))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *memoryAuditStore) ListByPayment(paymentRef string, limit int32, desc bool) ([]TimelineEntry, error) {
|
||||||
|
ref := strings.TrimSpace(paymentRef)
|
||||||
|
if ref == "" {
|
||||||
|
return nil, merrors.InvalidArgument("payment_ref is required")
|
||||||
|
}
|
||||||
|
limit = normalizeLimit(limit)
|
||||||
|
|
||||||
|
s.mu.RLock()
|
||||||
|
items := append([]TimelineEntry(nil), s.byPayment[ref]...)
|
||||||
|
s.mu.RUnlock()
|
||||||
|
|
||||||
|
return paginateEntries(items, limit, desc), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *memoryAuditStore) ListByStepAttempt(
|
||||||
|
paymentRef string,
|
||||||
|
stepRef string,
|
||||||
|
attempt uint32,
|
||||||
|
limit int32,
|
||||||
|
desc bool,
|
||||||
|
) ([]TimelineEntry, error) {
|
||||||
|
ref := strings.TrimSpace(paymentRef)
|
||||||
|
if ref == "" {
|
||||||
|
return nil, merrors.InvalidArgument("payment_ref is required")
|
||||||
|
}
|
||||||
|
step := strings.TrimSpace(stepRef)
|
||||||
|
if step == "" {
|
||||||
|
return nil, merrors.InvalidArgument("step_ref is required")
|
||||||
|
}
|
||||||
|
if attempt == 0 {
|
||||||
|
return nil, merrors.InvalidArgument("attempt is required")
|
||||||
|
}
|
||||||
|
limit = normalizeLimit(limit)
|
||||||
|
|
||||||
|
key := stepAttemptKey(ref, step, attempt)
|
||||||
|
s.mu.RLock()
|
||||||
|
items := append([]TimelineEntry(nil), s.byStep[key]...)
|
||||||
|
s.mu.RUnlock()
|
||||||
|
|
||||||
|
return paginateEntries(items, limit, desc), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func paginateEntries(items []TimelineEntry, limit int32, desc bool) []TimelineEntry {
|
||||||
|
if desc {
|
||||||
|
items = reverseEntries(items)
|
||||||
|
}
|
||||||
|
if len(items) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if int32(len(items)) > limit {
|
||||||
|
items = items[:limit]
|
||||||
|
}
|
||||||
|
out := make([]TimelineEntry, 0, len(items))
|
||||||
|
for i := range items {
|
||||||
|
out = append(out, cloneTimelineEntry(items[i]))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneTimelineEntry(entry TimelineEntry) TimelineEntry {
|
||||||
|
entry.Fields = cloneStringMap(entry.Fields)
|
||||||
|
return entry
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package oobs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultTimelineLimit int32 = 100
|
||||||
|
maxTimelineLimit int32 = 1000
|
||||||
|
)
|
||||||
|
|
||||||
|
func normalizePaymentEvent(event PaymentEvent) (PaymentEvent, bool) {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(string(event))) {
|
||||||
|
case string(PaymentEventCreated):
|
||||||
|
return PaymentEventCreated, true
|
||||||
|
case string(PaymentEventStateChanged):
|
||||||
|
return PaymentEventStateChanged, true
|
||||||
|
case string(PaymentEventNeedsAttention):
|
||||||
|
return PaymentEventNeedsAttention, true
|
||||||
|
case string(PaymentEventSettled):
|
||||||
|
return PaymentEventSettled, true
|
||||||
|
case string(PaymentEventFailed):
|
||||||
|
return PaymentEventFailed, true
|
||||||
|
default:
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeStepEvent(event StepEvent) (StepEvent, bool) {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(string(event))) {
|
||||||
|
case string(StepEventScheduled):
|
||||||
|
return StepEventScheduled, true
|
||||||
|
case string(StepEventStarted):
|
||||||
|
return StepEventStarted, true
|
||||||
|
case string(StepEventCompleted):
|
||||||
|
return StepEventCompleted, true
|
||||||
|
case string(StepEventFailed):
|
||||||
|
return StepEventFailed, true
|
||||||
|
case string(StepEventSkipped):
|
||||||
|
return StepEventSkipped, true
|
||||||
|
case string(StepEventBlocked):
|
||||||
|
return StepEventBlocked, true
|
||||||
|
default:
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeExternalSource(source ExternalSource) (ExternalSource, bool) {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(string(source))) {
|
||||||
|
case string(ExternalSourceGateway):
|
||||||
|
return ExternalSourceGateway, true
|
||||||
|
case string(ExternalSourceLedger):
|
||||||
|
return ExternalSourceLedger, true
|
||||||
|
case string(ExternalSourceCard):
|
||||||
|
return ExternalSourceCard, true
|
||||||
|
default:
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeAggregateState(state agg.State) (agg.State, bool) {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(string(state))) {
|
||||||
|
case string(agg.StateCreated):
|
||||||
|
return agg.StateCreated, true
|
||||||
|
case string(agg.StateExecuting):
|
||||||
|
return agg.StateExecuting, true
|
||||||
|
case string(agg.StateNeedsAttention):
|
||||||
|
return agg.StateNeedsAttention, true
|
||||||
|
case string(agg.StateSettled):
|
||||||
|
return agg.StateSettled, true
|
||||||
|
case string(agg.StateFailed):
|
||||||
|
return agg.StateFailed, true
|
||||||
|
default:
|
||||||
|
return agg.StateUnspecified, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeStepState(state agg.StepState) (agg.StepState, bool) {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(string(state))) {
|
||||||
|
case string(agg.StepStatePending):
|
||||||
|
return agg.StepStatePending, true
|
||||||
|
case string(agg.StepStateRunning):
|
||||||
|
return agg.StepStateRunning, true
|
||||||
|
case string(agg.StepStateCompleted):
|
||||||
|
return agg.StepStateCompleted, true
|
||||||
|
case string(agg.StepStateFailed):
|
||||||
|
return agg.StepStateFailed, true
|
||||||
|
case string(agg.StepStateNeedsAttention):
|
||||||
|
return agg.StepStateNeedsAttention, true
|
||||||
|
case string(agg.StepStateSkipped):
|
||||||
|
return agg.StepStateSkipped, true
|
||||||
|
default:
|
||||||
|
return agg.StepStateUnspecified, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeLimit(limit int32) int32 {
|
||||||
|
if limit <= 0 {
|
||||||
|
return defaultTimelineLimit
|
||||||
|
}
|
||||||
|
if limit > maxTimelineLimit {
|
||||||
|
return maxTimelineLimit
|
||||||
|
}
|
||||||
|
return limit
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package oobs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func nowUTC(nowFn func() time.Time) time.Time {
|
||||||
|
if nowFn == nil {
|
||||||
|
return time.Now().UTC()
|
||||||
|
}
|
||||||
|
return nowFn().UTC()
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneStringMap(src map[string]string) map[string]string {
|
||||||
|
if len(src) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make(map[string]string, len(src))
|
||||||
|
for key, value := range src {
|
||||||
|
out[strings.TrimSpace(key)] = strings.TrimSpace(value)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimStringMap(src map[string]string) map[string]string {
|
||||||
|
if len(src) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make(map[string]string, len(src))
|
||||||
|
for key, value := range src {
|
||||||
|
k := strings.TrimSpace(key)
|
||||||
|
v := strings.TrimSpace(value)
|
||||||
|
if k == "" && v == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeMaps(left map[string]string, right map[string]string) map[string]string {
|
||||||
|
if len(left) == 0 && len(right) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make(map[string]string, len(left)+len(right))
|
||||||
|
for key, value := range left {
|
||||||
|
out[key] = value
|
||||||
|
}
|
||||||
|
for key, value := range right {
|
||||||
|
out[key] = value
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func stepAttemptKey(paymentRef string, stepRef string, attempt uint32) string {
|
||||||
|
return paymentRef + "|" + stepRef + "|" + uint32String(attempt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func uint32String(v uint32) string {
|
||||||
|
if v == 0 {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
var buf [10]byte
|
||||||
|
i := len(buf)
|
||||||
|
for v > 0 {
|
||||||
|
i--
|
||||||
|
buf[i] = byte('0' + v%10)
|
||||||
|
v /= 10
|
||||||
|
}
|
||||||
|
return string(buf[i:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func reverseEntries(items []TimelineEntry) []TimelineEntry {
|
||||||
|
if len(items) <= 1 {
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
out := make([]TimelineEntry, 0, len(items))
|
||||||
|
for i := len(items) - 1; i >= 0; i-- {
|
||||||
|
out = append(out, items[i])
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package oobs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||||
|
)
|
||||||
|
|
||||||
|
type noopMetrics struct{}
|
||||||
|
|
||||||
|
func newNoopMetrics() Metrics {
|
||||||
|
return noopMetrics{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (noopMetrics) IncPaymentEvent(_ PaymentEvent, _ agg.State) {}
|
||||||
|
|
||||||
|
func (noopMetrics) IncStepEvent(_ StepEvent, _ string, _ agg.StepState) {}
|
||||||
|
|
||||||
|
func (noopMetrics) IncExternalEvent(_ ExternalSource, _ string) {}
|
||||||
|
|
||||||
|
func (noopMetrics) ObserveStepDuration(_ string, _ agg.StepState, _ time.Duration) {}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package oobs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Observer records orchestration-v2 telemetry and exposes audit timelines.
|
||||||
|
type Observer interface {
|
||||||
|
RecordPayment(ctx context.Context, in RecordPaymentInput) error
|
||||||
|
RecordStep(ctx context.Context, in RecordStepInput) error
|
||||||
|
RecordExternal(ctx context.Context, in RecordExternalInput) error
|
||||||
|
|
||||||
|
PaymentTimeline(ctx context.Context, in PaymentTimelineInput) (*TimelineOutput, error)
|
||||||
|
StepTimeline(ctx context.Context, in StepTimelineInput) (*TimelineOutput, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PaymentEvent classifies aggregate-level observability events.
|
||||||
|
type PaymentEvent string
|
||||||
|
|
||||||
|
const (
|
||||||
|
PaymentEventCreated PaymentEvent = "created"
|
||||||
|
PaymentEventStateChanged PaymentEvent = "state_changed"
|
||||||
|
PaymentEventNeedsAttention PaymentEvent = "needs_attention"
|
||||||
|
PaymentEventSettled PaymentEvent = "settled"
|
||||||
|
PaymentEventFailed PaymentEvent = "failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StepEvent classifies step-attempt-level observability events.
|
||||||
|
type StepEvent string
|
||||||
|
|
||||||
|
const (
|
||||||
|
StepEventScheduled StepEvent = "scheduled"
|
||||||
|
StepEventStarted StepEvent = "started"
|
||||||
|
StepEventCompleted StepEvent = "completed"
|
||||||
|
StepEventFailed StepEvent = "failed"
|
||||||
|
StepEventSkipped StepEvent = "skipped"
|
||||||
|
StepEventBlocked StepEvent = "blocked"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ExternalSource identifies asynchronous event origin.
|
||||||
|
type ExternalSource string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ExternalSourceGateway ExternalSource = "gateway"
|
||||||
|
ExternalSourceLedger ExternalSource = "ledger"
|
||||||
|
ExternalSourceCard ExternalSource = "card"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TimelineScope classifies timeline item scope.
|
||||||
|
type TimelineScope string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ScopePayment TimelineScope = "payment"
|
||||||
|
ScopeStep TimelineScope = "step"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TimelineEntry is one immutable audit timeline item.
|
||||||
|
type TimelineEntry struct {
|
||||||
|
OccurredAt time.Time
|
||||||
|
Scope TimelineScope
|
||||||
|
PaymentRef string
|
||||||
|
StepRef string
|
||||||
|
StepCode string
|
||||||
|
Attempt uint32
|
||||||
|
Event string
|
||||||
|
State string
|
||||||
|
Message string
|
||||||
|
Fields map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
// TimelineOutput is one timeline query result page.
|
||||||
|
type TimelineOutput struct {
|
||||||
|
Items []TimelineEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordPaymentInput is aggregate-level telemetry payload.
|
||||||
|
type RecordPaymentInput struct {
|
||||||
|
Payment *agg.Payment
|
||||||
|
Event PaymentEvent
|
||||||
|
Message string
|
||||||
|
Fields map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordStepInput is step-attempt-level telemetry payload.
|
||||||
|
type RecordStepInput struct {
|
||||||
|
PaymentRef string
|
||||||
|
Step agg.StepExecution
|
||||||
|
Event StepEvent
|
||||||
|
Message string
|
||||||
|
Duration time.Duration
|
||||||
|
Fields map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordExternalInput is external-event telemetry payload.
|
||||||
|
type RecordExternalInput struct {
|
||||||
|
PaymentRef string
|
||||||
|
StepRef string
|
||||||
|
Attempt uint32
|
||||||
|
Source ExternalSource
|
||||||
|
Status string
|
||||||
|
RefKind string
|
||||||
|
Ref string
|
||||||
|
Message string
|
||||||
|
Fields map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
// PaymentTimelineInput scopes payment-level timeline lookup.
|
||||||
|
type PaymentTimelineInput struct {
|
||||||
|
PaymentRef string
|
||||||
|
Limit int32
|
||||||
|
Desc bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// StepTimelineInput scopes step-attempt timeline lookup.
|
||||||
|
type StepTimelineInput struct {
|
||||||
|
PaymentRef string
|
||||||
|
StepRef string
|
||||||
|
Attempt uint32
|
||||||
|
Limit int32
|
||||||
|
Desc bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Metrics captures counters and durations for orchestration telemetry.
|
||||||
|
type Metrics interface {
|
||||||
|
IncPaymentEvent(event PaymentEvent, state agg.State)
|
||||||
|
IncStepEvent(event StepEvent, stepCode string, state agg.StepState)
|
||||||
|
IncExternalEvent(source ExternalSource, status string)
|
||||||
|
ObserveStepDuration(stepCode string, state agg.StepState, duration time.Duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuditStore persists timeline events and serves timeline lookups.
|
||||||
|
type AuditStore interface {
|
||||||
|
Append(entry TimelineEntry) error
|
||||||
|
ListByPayment(paymentRef string, limit int32, desc bool) ([]TimelineEntry, error)
|
||||||
|
ListByStepAttempt(paymentRef string, stepRef string, attempt uint32, limit int32, desc bool) ([]TimelineEntry, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dependencies configures observer integrations.
|
||||||
|
type Dependencies struct {
|
||||||
|
Logger mlogger.Logger
|
||||||
|
Metrics Metrics
|
||||||
|
Store AuditStore
|
||||||
|
Now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(deps Dependencies) (Observer, error) {
|
||||||
|
return newService(deps)
|
||||||
|
}
|
||||||
@@ -0,0 +1,414 @@
|
|||||||
|
package oobs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
type svc struct {
|
||||||
|
logger mlogger.Logger
|
||||||
|
metrics Metrics
|
||||||
|
store AuditStore
|
||||||
|
now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func newService(deps Dependencies) (Observer, error) {
|
||||||
|
store := deps.Store
|
||||||
|
if store == nil {
|
||||||
|
store = newMemoryAuditStore()
|
||||||
|
}
|
||||||
|
|
||||||
|
logger := deps.Logger.Named("oobs")
|
||||||
|
|
||||||
|
metrics := deps.Metrics
|
||||||
|
if metrics == nil {
|
||||||
|
metrics = newNoopMetrics()
|
||||||
|
}
|
||||||
|
|
||||||
|
return &svc{
|
||||||
|
logger: logger,
|
||||||
|
metrics: metrics,
|
||||||
|
store: store,
|
||||||
|
now: deps.Now,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *svc) RecordPayment(_ context.Context, in RecordPaymentInput) (err error) {
|
||||||
|
logger := s.logger
|
||||||
|
paymentRef := ""
|
||||||
|
if in.Payment != nil {
|
||||||
|
paymentRef = strings.TrimSpace(in.Payment.PaymentRef)
|
||||||
|
}
|
||||||
|
logger.Debug("Starting Record payment",
|
||||||
|
zap.String("payment_ref", paymentRef),
|
||||||
|
zap.String("event", string(in.Event)),
|
||||||
|
)
|
||||||
|
defer func(start time.Time) {
|
||||||
|
fields := []zap.Field{
|
||||||
|
zap.Int64("duration_ms", time.Since(start).Milliseconds()),
|
||||||
|
zap.String("payment_ref", paymentRef),
|
||||||
|
zap.String("event", string(in.Event)),
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("Failed to record payment", append(fields, zap.Error(err))...)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger.Debug("Completed Record payment", fields...)
|
||||||
|
}(time.Now())
|
||||||
|
|
||||||
|
entry, payment, event, err := buildPaymentEntry(nowUTC(s.now), in)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.store.Append(entry); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.metrics.IncPaymentEvent(event, payment.State)
|
||||||
|
s.logPayment(entry, payment.State, payment.Version)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *svc) RecordStep(_ context.Context, in RecordStepInput) (err error) {
|
||||||
|
logger := s.logger
|
||||||
|
logger.Debug("Starting Record step",
|
||||||
|
zap.String("payment_ref", strings.TrimSpace(in.PaymentRef)),
|
||||||
|
zap.String("step_ref", strings.TrimSpace(in.Step.StepRef)),
|
||||||
|
zap.Uint32("attempt", in.Step.Attempt),
|
||||||
|
zap.String("event", string(in.Event)),
|
||||||
|
)
|
||||||
|
defer func(start time.Time) {
|
||||||
|
fields := []zap.Field{
|
||||||
|
zap.Int64("duration_ms", time.Since(start).Milliseconds()),
|
||||||
|
zap.String("payment_ref", strings.TrimSpace(in.PaymentRef)),
|
||||||
|
zap.String("step_ref", strings.TrimSpace(in.Step.StepRef)),
|
||||||
|
zap.Uint32("attempt", in.Step.Attempt),
|
||||||
|
zap.String("event", string(in.Event)),
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("Failed to record step", append(fields, zap.Error(err))...)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger.Debug("Completed Record step", fields...)
|
||||||
|
}(time.Now())
|
||||||
|
|
||||||
|
entry, step, event, duration, err := buildStepEntry(nowUTC(s.now), in)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.store.Append(entry); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.metrics.IncStepEvent(event, step.StepCode, step.State)
|
||||||
|
if duration > 0 {
|
||||||
|
s.metrics.ObserveStepDuration(step.StepCode, step.State, duration)
|
||||||
|
}
|
||||||
|
s.logStep(entry, step.State, duration)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *svc) RecordExternal(_ context.Context, in RecordExternalInput) (err error) {
|
||||||
|
logger := s.logger
|
||||||
|
logger.Debug("Starting Record external",
|
||||||
|
zap.String("payment_ref", strings.TrimSpace(in.PaymentRef)),
|
||||||
|
zap.String("step_ref", strings.TrimSpace(in.StepRef)),
|
||||||
|
zap.Uint32("attempt", in.Attempt),
|
||||||
|
zap.String("source", string(in.Source)),
|
||||||
|
zap.String("status", strings.TrimSpace(in.Status)),
|
||||||
|
)
|
||||||
|
defer func(start time.Time) {
|
||||||
|
fields := []zap.Field{
|
||||||
|
zap.Int64("duration_ms", time.Since(start).Milliseconds()),
|
||||||
|
zap.String("payment_ref", strings.TrimSpace(in.PaymentRef)),
|
||||||
|
zap.String("step_ref", strings.TrimSpace(in.StepRef)),
|
||||||
|
zap.Uint32("attempt", in.Attempt),
|
||||||
|
zap.String("source", string(in.Source)),
|
||||||
|
zap.String("status", strings.TrimSpace(in.Status)),
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("Failed to record external", append(fields, zap.Error(err))...)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger.Debug("Completed Record external", fields...)
|
||||||
|
}(time.Now())
|
||||||
|
|
||||||
|
entry, source, status, err := buildExternalEntry(nowUTC(s.now), in)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.store.Append(entry); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.metrics.IncExternalEvent(source, status)
|
||||||
|
s.logExternal(entry, source, status)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *svc) PaymentTimeline(_ context.Context, in PaymentTimelineInput) (out *TimelineOutput, err error) {
|
||||||
|
logger := s.logger
|
||||||
|
logger.Debug("Starting Payment timeline",
|
||||||
|
zap.String("payment_ref", strings.TrimSpace(in.PaymentRef)),
|
||||||
|
zap.Int32("limit", in.Limit),
|
||||||
|
zap.Bool("desc", in.Desc),
|
||||||
|
)
|
||||||
|
defer func(start time.Time) {
|
||||||
|
fields := []zap.Field{zap.Int64("duration_ms", time.Since(start).Milliseconds())}
|
||||||
|
if out != nil {
|
||||||
|
fields = append(fields, zap.Int("items_count", len(out.Items)))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("Failed to payment timeline", append(fields, zap.Error(err))...)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger.Debug("Completed Payment timeline", fields...)
|
||||||
|
}(time.Now())
|
||||||
|
|
||||||
|
paymentRef := strings.TrimSpace(in.PaymentRef)
|
||||||
|
if paymentRef == "" {
|
||||||
|
return nil, merrors.InvalidArgument("payment_ref is required")
|
||||||
|
}
|
||||||
|
items, err := s.store.ListByPayment(paymentRef, in.Limit, in.Desc)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = &TimelineOutput{Items: items}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *svc) StepTimeline(_ context.Context, in StepTimelineInput) (out *TimelineOutput, err error) {
|
||||||
|
logger := s.logger
|
||||||
|
logger.Debug("Starting Step timeline",
|
||||||
|
zap.String("payment_ref", strings.TrimSpace(in.PaymentRef)),
|
||||||
|
zap.String("step_ref", strings.TrimSpace(in.StepRef)),
|
||||||
|
zap.Uint32("attempt", in.Attempt),
|
||||||
|
zap.Int32("limit", in.Limit),
|
||||||
|
zap.Bool("desc", in.Desc),
|
||||||
|
)
|
||||||
|
defer func(start time.Time) {
|
||||||
|
fields := []zap.Field{zap.Int64("duration_ms", time.Since(start).Milliseconds())}
|
||||||
|
if out != nil {
|
||||||
|
fields = append(fields, zap.Int("items_count", len(out.Items)))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("Failed to step timeline", append(fields, zap.Error(err))...)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger.Debug("Completed Step timeline", fields...)
|
||||||
|
}(time.Now())
|
||||||
|
|
||||||
|
paymentRef := strings.TrimSpace(in.PaymentRef)
|
||||||
|
if paymentRef == "" {
|
||||||
|
return nil, merrors.InvalidArgument("payment_ref is required")
|
||||||
|
}
|
||||||
|
stepRef := strings.TrimSpace(in.StepRef)
|
||||||
|
if stepRef == "" {
|
||||||
|
return nil, merrors.InvalidArgument("step_ref is required")
|
||||||
|
}
|
||||||
|
if in.Attempt == 0 {
|
||||||
|
return nil, merrors.InvalidArgument("attempt is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
items, err := s.store.ListByStepAttempt(paymentRef, stepRef, in.Attempt, in.Limit, in.Desc)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = &TimelineOutput{Items: items}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildPaymentEntry(now time.Time, in RecordPaymentInput) (TimelineEntry, *agg.Payment, PaymentEvent, error) {
|
||||||
|
payment := in.Payment
|
||||||
|
if payment == nil {
|
||||||
|
return TimelineEntry{}, nil, "", merrors.InvalidArgument("payment is required")
|
||||||
|
}
|
||||||
|
event, ok := normalizePaymentEvent(in.Event)
|
||||||
|
if !ok {
|
||||||
|
return TimelineEntry{}, nil, "", merrors.InvalidArgument("event is invalid")
|
||||||
|
}
|
||||||
|
state, ok := normalizeAggregateState(payment.State)
|
||||||
|
if !ok {
|
||||||
|
return TimelineEntry{}, nil, "", merrors.InvalidArgument("payment.state is invalid")
|
||||||
|
}
|
||||||
|
paymentRef := strings.TrimSpace(payment.PaymentRef)
|
||||||
|
if paymentRef == "" {
|
||||||
|
return TimelineEntry{}, nil, "", merrors.InvalidArgument("payment.payment_ref is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := TimelineEntry{
|
||||||
|
OccurredAt: now,
|
||||||
|
Scope: ScopePayment,
|
||||||
|
PaymentRef: paymentRef,
|
||||||
|
Event: string(event),
|
||||||
|
State: string(state),
|
||||||
|
Message: strings.TrimSpace(in.Message),
|
||||||
|
Fields: trimStringMap(in.Fields),
|
||||||
|
}
|
||||||
|
return entry, payment, event, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildStepEntry(now time.Time, in RecordStepInput) (TimelineEntry, agg.StepExecution, StepEvent, time.Duration, error) {
|
||||||
|
event, ok := normalizeStepEvent(in.Event)
|
||||||
|
if !ok {
|
||||||
|
return TimelineEntry{}, agg.StepExecution{}, "", 0, merrors.InvalidArgument("event is invalid")
|
||||||
|
}
|
||||||
|
paymentRef := strings.TrimSpace(in.PaymentRef)
|
||||||
|
if paymentRef == "" {
|
||||||
|
return TimelineEntry{}, agg.StepExecution{}, "", 0, merrors.InvalidArgument("payment_ref is required")
|
||||||
|
}
|
||||||
|
stepRef := strings.TrimSpace(in.Step.StepRef)
|
||||||
|
if stepRef == "" {
|
||||||
|
return TimelineEntry{}, agg.StepExecution{}, "", 0, merrors.InvalidArgument("step.step_ref is required")
|
||||||
|
}
|
||||||
|
stepState, ok := normalizeStepState(in.Step.State)
|
||||||
|
if !ok {
|
||||||
|
return TimelineEntry{}, agg.StepExecution{}, "", 0, merrors.InvalidArgument("step.state is invalid")
|
||||||
|
}
|
||||||
|
|
||||||
|
attempt := in.Step.Attempt
|
||||||
|
if attempt == 0 {
|
||||||
|
attempt = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := TimelineEntry{
|
||||||
|
OccurredAt: now,
|
||||||
|
Scope: ScopeStep,
|
||||||
|
PaymentRef: paymentRef,
|
||||||
|
StepRef: stepRef,
|
||||||
|
StepCode: strings.TrimSpace(in.Step.StepCode),
|
||||||
|
Attempt: attempt,
|
||||||
|
Event: string(event),
|
||||||
|
State: string(stepState),
|
||||||
|
Message: strings.TrimSpace(in.Message),
|
||||||
|
Fields: trimStringMap(in.Fields),
|
||||||
|
}
|
||||||
|
|
||||||
|
step := in.Step
|
||||||
|
step.State = stepState
|
||||||
|
step.StepRef = stepRef
|
||||||
|
step.StepCode = strings.TrimSpace(step.StepCode)
|
||||||
|
step.Attempt = attempt
|
||||||
|
|
||||||
|
return entry, step, event, in.Duration, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildExternalEntry(now time.Time, in RecordExternalInput) (TimelineEntry, ExternalSource, string, error) {
|
||||||
|
source, ok := normalizeExternalSource(in.Source)
|
||||||
|
if !ok {
|
||||||
|
return TimelineEntry{}, "", "", merrors.InvalidArgument("source is invalid")
|
||||||
|
}
|
||||||
|
paymentRef := strings.TrimSpace(in.PaymentRef)
|
||||||
|
if paymentRef == "" {
|
||||||
|
return TimelineEntry{}, "", "", merrors.InvalidArgument("payment_ref is required")
|
||||||
|
}
|
||||||
|
stepRef := strings.TrimSpace(in.StepRef)
|
||||||
|
if stepRef == "" {
|
||||||
|
return TimelineEntry{}, "", "", merrors.InvalidArgument("step_ref is required")
|
||||||
|
}
|
||||||
|
if in.Attempt == 0 {
|
||||||
|
return TimelineEntry{}, "", "", merrors.InvalidArgument("attempt is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
status := strings.ToLower(strings.TrimSpace(in.Status))
|
||||||
|
if status == "" {
|
||||||
|
status = "received"
|
||||||
|
}
|
||||||
|
fields := mergeMaps(trimStringMap(in.Fields), map[string]string{
|
||||||
|
"source": string(source),
|
||||||
|
"status": status,
|
||||||
|
"refKind": strings.TrimSpace(in.RefKind),
|
||||||
|
"ref": strings.TrimSpace(in.Ref),
|
||||||
|
})
|
||||||
|
entry := TimelineEntry{
|
||||||
|
OccurredAt: now,
|
||||||
|
Scope: ScopeStep,
|
||||||
|
PaymentRef: paymentRef,
|
||||||
|
StepRef: stepRef,
|
||||||
|
Attempt: in.Attempt,
|
||||||
|
Event: "external." + string(source) + "." + status,
|
||||||
|
State: status,
|
||||||
|
Message: strings.TrimSpace(in.Message),
|
||||||
|
Fields: trimStringMap(fields),
|
||||||
|
}
|
||||||
|
return entry, source, status, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *svc) logPayment(entry TimelineEntry, state agg.State, version uint64) {
|
||||||
|
logger := s.logger.With(
|
||||||
|
zap.String("payment_ref", entry.PaymentRef),
|
||||||
|
zap.String("event", entry.Event),
|
||||||
|
zap.String("state", string(state)),
|
||||||
|
zap.Uint64("version", version),
|
||||||
|
zap.String("scope", string(entry.Scope)),
|
||||||
|
)
|
||||||
|
if entry.Message != "" {
|
||||||
|
logger = logger.With(zap.String("message", entry.Message))
|
||||||
|
}
|
||||||
|
if len(entry.Fields) > 0 {
|
||||||
|
logger = logger.With(zap.Any("fields", entry.Fields))
|
||||||
|
}
|
||||||
|
switch state {
|
||||||
|
case agg.StateFailed, agg.StateNeedsAttention:
|
||||||
|
logger.Warn("Orchestration payment event")
|
||||||
|
default:
|
||||||
|
logger.Info("Orchestration payment event")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *svc) logStep(entry TimelineEntry, state agg.StepState, duration time.Duration) {
|
||||||
|
logger := s.logger.With(
|
||||||
|
zap.String("payment_ref", entry.PaymentRef),
|
||||||
|
zap.String("step_ref", entry.StepRef),
|
||||||
|
zap.String("step_code", entry.StepCode),
|
||||||
|
zap.Uint32("attempt", entry.Attempt),
|
||||||
|
zap.String("event", entry.Event),
|
||||||
|
zap.String("state", string(state)),
|
||||||
|
zap.String("scope", string(entry.Scope)),
|
||||||
|
)
|
||||||
|
if duration > 0 {
|
||||||
|
logger = logger.With(zap.Int64("duration_ms", duration.Milliseconds()))
|
||||||
|
}
|
||||||
|
if entry.Message != "" {
|
||||||
|
logger = logger.With(zap.String("message", entry.Message))
|
||||||
|
}
|
||||||
|
if len(entry.Fields) > 0 {
|
||||||
|
logger = logger.With(zap.Any("fields", entry.Fields))
|
||||||
|
}
|
||||||
|
switch state {
|
||||||
|
case agg.StepStateFailed, agg.StepStateNeedsAttention:
|
||||||
|
logger.Warn("Orchestration step event")
|
||||||
|
default:
|
||||||
|
logger.Info("Orchestration step event")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *svc) logExternal(entry TimelineEntry, source ExternalSource, status string) {
|
||||||
|
logger := s.logger.With(
|
||||||
|
zap.String("payment_ref", entry.PaymentRef),
|
||||||
|
zap.String("step_ref", entry.StepRef),
|
||||||
|
zap.Uint32("attempt", entry.Attempt),
|
||||||
|
zap.String("source", string(source)),
|
||||||
|
zap.String("status", status),
|
||||||
|
zap.String("event", entry.Event),
|
||||||
|
zap.String("scope", string(entry.Scope)),
|
||||||
|
)
|
||||||
|
if entry.Message != "" {
|
||||||
|
logger = logger.With(zap.String("message", entry.Message))
|
||||||
|
}
|
||||||
|
if len(entry.Fields) > 0 {
|
||||||
|
logger = logger.With(zap.Any("fields", entry.Fields))
|
||||||
|
}
|
||||||
|
if strings.Contains(status, "failed") || strings.Contains(status, "cancel") {
|
||||||
|
logger.Warn("Orchestration external event")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger.Info("Orchestration external event")
|
||||||
|
}
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
package oobs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRecordAndTimelineQueries(t *testing.T) {
|
||||||
|
now := time.Date(2026, time.February, 21, 18, 0, 0, 0, time.UTC)
|
||||||
|
metrics := &fakeMetrics{}
|
||||||
|
observer := mustObserver(t, Dependencies{
|
||||||
|
Logger: zap.NewNop(),
|
||||||
|
Metrics: metrics,
|
||||||
|
Now: func() time.Time { return now },
|
||||||
|
})
|
||||||
|
|
||||||
|
payment := &agg.Payment{
|
||||||
|
PaymentRef: "pay-1",
|
||||||
|
State: agg.StateExecuting,
|
||||||
|
Version: 4,
|
||||||
|
}
|
||||||
|
if err := observer.RecordPayment(context.Background(), RecordPaymentInput{
|
||||||
|
Payment: payment,
|
||||||
|
Event: PaymentEventStateChanged,
|
||||||
|
Message: "execution started",
|
||||||
|
Fields: map[string]string{
|
||||||
|
"quotationRef": "quote-1",
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("RecordPayment returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
step := agg.StepExecution{
|
||||||
|
StepRef: "s1",
|
||||||
|
StepCode: "hop.10.crypto.send",
|
||||||
|
State: agg.StepStateRunning,
|
||||||
|
Attempt: 1,
|
||||||
|
}
|
||||||
|
if err := observer.RecordStep(context.Background(), RecordStepInput{
|
||||||
|
PaymentRef: payment.PaymentRef,
|
||||||
|
Step: step,
|
||||||
|
Event: StepEventStarted,
|
||||||
|
Duration: 1800 * time.Millisecond,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("RecordStep(started) returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := observer.RecordExternal(context.Background(), RecordExternalInput{
|
||||||
|
PaymentRef: payment.PaymentRef,
|
||||||
|
StepRef: step.StepRef,
|
||||||
|
Attempt: 1,
|
||||||
|
Source: ExternalSourceGateway,
|
||||||
|
Status: "success",
|
||||||
|
RefKind: "transfer_ref",
|
||||||
|
Ref: "tr-1",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("RecordExternal returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
step.State = agg.StepStateFailed
|
||||||
|
if err := observer.RecordStep(context.Background(), RecordStepInput{
|
||||||
|
PaymentRef: payment.PaymentRef,
|
||||||
|
Step: step,
|
||||||
|
Event: StepEventFailed,
|
||||||
|
Message: "terminal failure",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("RecordStep(failed) returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
paymentTimeline, err := observer.PaymentTimeline(context.Background(), PaymentTimelineInput{
|
||||||
|
PaymentRef: payment.PaymentRef,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PaymentTimeline returned error: %v", err)
|
||||||
|
}
|
||||||
|
if len(paymentTimeline.Items) != 4 {
|
||||||
|
t.Fatalf("payment timeline size mismatch: got=%d want=4", len(paymentTimeline.Items))
|
||||||
|
}
|
||||||
|
assertEventOrder(t, paymentTimeline.Items, []string{
|
||||||
|
"state_changed",
|
||||||
|
"started",
|
||||||
|
"external.gateway.success",
|
||||||
|
"failed",
|
||||||
|
})
|
||||||
|
|
||||||
|
stepTimeline, err := observer.StepTimeline(context.Background(), StepTimelineInput{
|
||||||
|
PaymentRef: payment.PaymentRef,
|
||||||
|
StepRef: step.StepRef,
|
||||||
|
Attempt: 1,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("StepTimeline returned error: %v", err)
|
||||||
|
}
|
||||||
|
if len(stepTimeline.Items) != 3 {
|
||||||
|
t.Fatalf("step timeline size mismatch: got=%d want=3", len(stepTimeline.Items))
|
||||||
|
}
|
||||||
|
assertEventOrder(t, stepTimeline.Items, []string{
|
||||||
|
"started",
|
||||||
|
"external.gateway.success",
|
||||||
|
"failed",
|
||||||
|
})
|
||||||
|
|
||||||
|
descTimeline, err := observer.PaymentTimeline(context.Background(), PaymentTimelineInput{
|
||||||
|
PaymentRef: payment.PaymentRef,
|
||||||
|
Limit: 2,
|
||||||
|
Desc: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PaymentTimeline(desc) returned error: %v", err)
|
||||||
|
}
|
||||||
|
if len(descTimeline.Items) != 2 {
|
||||||
|
t.Fatalf("desc timeline size mismatch: got=%d want=2", len(descTimeline.Items))
|
||||||
|
}
|
||||||
|
assertEventOrder(t, descTimeline.Items, []string{"failed", "external.gateway.success"})
|
||||||
|
|
||||||
|
if metrics.paymentEvents != 1 {
|
||||||
|
t.Fatalf("payment metric mismatch: got=%d want=1", metrics.paymentEvents)
|
||||||
|
}
|
||||||
|
if metrics.stepEvents != 2 {
|
||||||
|
t.Fatalf("step metric mismatch: got=%d want=2", metrics.stepEvents)
|
||||||
|
}
|
||||||
|
if metrics.externalEvents != 1 {
|
||||||
|
t.Fatalf("external metric mismatch: got=%d want=1", metrics.externalEvents)
|
||||||
|
}
|
||||||
|
if metrics.stepDurations != 1 {
|
||||||
|
t.Fatalf("duration metric mismatch: got=%d want=1", metrics.stepDurations)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStepTimeline_AttemptIsolation(t *testing.T) {
|
||||||
|
observer := mustObserver(t, Dependencies{Logger: zap.NewNop()})
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
for _, attempt := range []uint32{1, 2} {
|
||||||
|
err := observer.RecordStep(ctx, RecordStepInput{
|
||||||
|
PaymentRef: "pay-1",
|
||||||
|
Step: agg.StepExecution{
|
||||||
|
StepRef: "s1",
|
||||||
|
StepCode: "hop.10.crypto.send",
|
||||||
|
State: agg.StepStateCompleted,
|
||||||
|
Attempt: attempt,
|
||||||
|
},
|
||||||
|
Event: StepEventCompleted,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RecordStep attempt=%d returned error: %v", attempt, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
a1, err := observer.StepTimeline(ctx, StepTimelineInput{
|
||||||
|
PaymentRef: "pay-1",
|
||||||
|
StepRef: "s1",
|
||||||
|
Attempt: 1,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("StepTimeline(attempt=1) returned error: %v", err)
|
||||||
|
}
|
||||||
|
if len(a1.Items) != 1 {
|
||||||
|
t.Fatalf("attempt=1 timeline size mismatch: got=%d want=1", len(a1.Items))
|
||||||
|
}
|
||||||
|
if got, want := a1.Items[0].Attempt, uint32(1); got != want {
|
||||||
|
t.Fatalf("attempt mismatch: got=%d want=%d", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidationErrors(t *testing.T) {
|
||||||
|
observer := mustObserver(t, Dependencies{Logger: zap.NewNop()})
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
run func() error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "record payment missing payment",
|
||||||
|
run: func() error {
|
||||||
|
return observer.RecordPayment(ctx, RecordPaymentInput{
|
||||||
|
Event: PaymentEventCreated,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "record payment invalid event",
|
||||||
|
run: func() error {
|
||||||
|
return observer.RecordPayment(ctx, RecordPaymentInput{
|
||||||
|
Payment: &agg.Payment{PaymentRef: "pay-1", State: agg.StateCreated},
|
||||||
|
Event: PaymentEvent("bad"),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "record step missing payment ref",
|
||||||
|
run: func() error {
|
||||||
|
return observer.RecordStep(ctx, RecordStepInput{
|
||||||
|
Step: agg.StepExecution{
|
||||||
|
StepRef: "s1",
|
||||||
|
State: agg.StepStatePending,
|
||||||
|
},
|
||||||
|
Event: StepEventScheduled,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "record step invalid state",
|
||||||
|
run: func() error {
|
||||||
|
return observer.RecordStep(ctx, RecordStepInput{
|
||||||
|
PaymentRef: "pay-1",
|
||||||
|
Step: agg.StepExecution{
|
||||||
|
StepRef: "s1",
|
||||||
|
State: agg.StepStateUnspecified,
|
||||||
|
},
|
||||||
|
Event: StepEventScheduled,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "record external invalid source",
|
||||||
|
run: func() error {
|
||||||
|
return observer.RecordExternal(ctx, RecordExternalInput{
|
||||||
|
PaymentRef: "pay-1",
|
||||||
|
StepRef: "s1",
|
||||||
|
Attempt: 1,
|
||||||
|
Source: ExternalSource("bad"),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "query step timeline missing attempt",
|
||||||
|
run: func() error {
|
||||||
|
_, err := observer.StepTimeline(ctx, StepTimelineInput{
|
||||||
|
PaymentRef: "pay-1",
|
||||||
|
StepRef: "s1",
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if err := tt.run(); !errors.Is(err, merrors.ErrInvalidArg) {
|
||||||
|
t.Fatalf("expected invalid argument error, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertEventOrder(t *testing.T, items []TimelineEntry, expected []string) {
|
||||||
|
t.Helper()
|
||||||
|
if len(items) != len(expected) {
|
||||||
|
t.Fatalf("event count mismatch: got=%d want=%d", len(items), len(expected))
|
||||||
|
}
|
||||||
|
for i := range expected {
|
||||||
|
if got, want := items[i].Event, expected[i]; got != want {
|
||||||
|
t.Fatalf("event[%d] mismatch: got=%q want=%q", i, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustObserver(t *testing.T, deps Dependencies) Observer {
|
||||||
|
t.Helper()
|
||||||
|
out, err := New(deps)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New returned error: %v", err)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeMetrics struct {
|
||||||
|
paymentEvents int
|
||||||
|
stepEvents int
|
||||||
|
externalEvents int
|
||||||
|
stepDurations int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeMetrics) IncPaymentEvent(_ PaymentEvent, _ agg.State) {
|
||||||
|
f.paymentEvents++
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeMetrics) IncStepEvent(_ StepEvent, _ string, _ agg.StepState) {
|
||||||
|
f.stepEvents++
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeMetrics) IncExternalEvent(_ ExternalSource, _ string) {
|
||||||
|
f.externalEvents++
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeMetrics) ObserveStepDuration(_ string, _ agg.StepState, _ time.Duration) {
|
||||||
|
f.stepDurations++
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
package opagg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAggregate_GroupsCompatibleItemsByRecipient(t *testing.T) {
|
||||||
|
aggregator := New()
|
||||||
|
|
||||||
|
in := Input{
|
||||||
|
Items: []Item{
|
||||||
|
{
|
||||||
|
IntentSnapshot: sampleIntent("intent-a", "card-1", "100"),
|
||||||
|
QuoteSnapshot: sampleQuote("quote-batch", "100", "9150", "1.8"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
IntentSnapshot: sampleIntent("intent-b", "card-1", "125"),
|
||||||
|
QuoteSnapshot: sampleQuote("quote-batch", "125", "11437.5", "1.8"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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 := len(group.IntentRefs), 2; got != want {
|
||||||
|
t.Fatalf("intent_refs count mismatch: got=%d want=%d", got, want)
|
||||||
|
}
|
||||||
|
if got, want := group.IntentRefs[0], "intent-a"; got != want {
|
||||||
|
t.Fatalf("intent_refs[0] mismatch: got=%q want=%q", got, want)
|
||||||
|
}
|
||||||
|
if got, want := group.IntentRefs[1], "intent-b"; got != want {
|
||||||
|
t.Fatalf("intent_refs[1] mismatch: got=%q want=%q", got, want)
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
if got, want := group.QuoteSnapshot.ExpectedFeeTotal.Amount, "3.6"; got != want {
|
||||||
|
t.Fatalf("fee total mismatch: got=%q want=%q", got, want)
|
||||||
|
}
|
||||||
|
if got, want := group.QuoteSnapshot.TotalCost.Amount, "228.6"; got != want {
|
||||||
|
t.Fatalf("total cost mismatch: got=%q want=%q", got, want)
|
||||||
|
}
|
||||||
|
if got, want := len(group.QuoteSnapshot.FeeLines), 2; got != want {
|
||||||
|
t.Fatalf("fee lines mismatch: got=%d want=%d", got, want)
|
||||||
|
}
|
||||||
|
if got, want := group.QuoteSnapshot.FeeLines[0].Money.Amount, "3"; got != want {
|
||||||
|
t.Fatalf("platform fee mismatch: got=%q want=%q", got, want)
|
||||||
|
}
|
||||||
|
if got, want := group.QuoteSnapshot.FeeLines[1].Money.Amount, "0.6"; got != want {
|
||||||
|
t.Fatalf("tax fee mismatch: got=%q want=%q", got, want)
|
||||||
|
}
|
||||||
|
if group.IntentSnapshot.Attributes[attrAggregatedByRecipient] != "true" {
|
||||||
|
t.Fatalf("expected aggregated attribute %q=true", attrAggregatedByRecipient)
|
||||||
|
}
|
||||||
|
if got, want := group.IntentSnapshot.Attributes[attrAggregatedItems], "2"; got != want {
|
||||||
|
t.Fatalf("aggregated items mismatch: got=%q want=%q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAggregate_DoesNotMergeDifferentRecipients(t *testing.T) {
|
||||||
|
aggregator := New()
|
||||||
|
|
||||||
|
in := Input{
|
||||||
|
Items: []Item{
|
||||||
|
{
|
||||||
|
IntentSnapshot: sampleIntent("intent-a", "card-1", "100"),
|
||||||
|
QuoteSnapshot: sampleQuote("quote-batch", "100", "9150", "1.8"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
IntentSnapshot: sampleIntent("intent-b", "card-2", "125"),
|
||||||
|
QuoteSnapshot: sampleQuote("quote-batch", "125", "11437.5", "1.8"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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), 2; got != want {
|
||||||
|
t.Fatalf("groups count mismatch: got=%d want=%d", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAggregate_DoesNotMergeWhenBatchingIneligible(t *testing.T) {
|
||||||
|
aggregator := New()
|
||||||
|
|
||||||
|
first := sampleQuote("quote-batch", "100", "9150", "1.8")
|
||||||
|
first.ExecutionConditions.BatchingEligible = false
|
||||||
|
second := sampleQuote("quote-batch", "125", "11437.5", "1.8")
|
||||||
|
second.ExecutionConditions.BatchingEligible = false
|
||||||
|
|
||||||
|
in := Input{
|
||||||
|
Items: []Item{
|
||||||
|
{
|
||||||
|
IntentSnapshot: sampleIntent("intent-a", "card-1", "100"),
|
||||||
|
QuoteSnapshot: first,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
IntentSnapshot: sampleIntent("intent-b", "card-1", "125"),
|
||||||
|
QuoteSnapshot: second,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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), 2; got != want {
|
||||||
|
t.Fatalf("groups count mismatch: got=%d want=%d", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAggregate_UserBatchQuoteSampleCompactsToSingleRecipientOperation(t *testing.T) {
|
||||||
|
aggregator := New()
|
||||||
|
|
||||||
|
in := Input{
|
||||||
|
Items: []Item{
|
||||||
|
{
|
||||||
|
IntentSnapshot: sampleIntent("q-intent-1771599670962253000", "card-1", "100"),
|
||||||
|
QuoteSnapshot: sampleQuote("quote-batch-usdt-rub", "100", "9150", "1.8"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
IntentSnapshot: sampleIntent("q-intent-1771599670962255000", "card-1", "125"),
|
||||||
|
QuoteSnapshot: sampleQuote("quote-batch-usdt-rub", "125", "11437.5", "1.8"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
IntentSnapshot: sampleIntent("q-intent-1771599670962256000", "card-1", "80"),
|
||||||
|
QuoteSnapshot: sampleQuote("quote-batch-usdt-rub", "80", "7320", "1.8"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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 := len(group.IntentRefs), 3; got != want {
|
||||||
|
t.Fatalf("intent_refs count mismatch: got=%d want=%d", got, want)
|
||||||
|
}
|
||||||
|
if got, want := group.IntentSnapshot.Amount.Amount, "305"; 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, "305"; got != want {
|
||||||
|
t.Fatalf("debit amount mismatch: got=%q want=%q", got, want)
|
||||||
|
}
|
||||||
|
if got, want := group.QuoteSnapshot.ExpectedSettlementAmount.Amount, "27907.5"; got != want {
|
||||||
|
t.Fatalf("settlement amount mismatch: got=%q want=%q", got, want)
|
||||||
|
}
|
||||||
|
if got, want := group.QuoteSnapshot.ExpectedFeeTotal.Amount, "5.4"; got != want {
|
||||||
|
t.Fatalf("fee total mismatch: got=%q want=%q", got, want)
|
||||||
|
}
|
||||||
|
if got, want := group.QuoteSnapshot.TotalCost.Amount, "310.4"; got != want {
|
||||||
|
t.Fatalf("total cost mismatch: got=%q want=%q", got, want)
|
||||||
|
}
|
||||||
|
if group.IntentSnapshot.Attributes[attrAggregatedByRecipient] != "true" {
|
||||||
|
t.Fatalf("expected aggregated attribute %q=true", attrAggregatedByRecipient)
|
||||||
|
}
|
||||||
|
if got, want := group.IntentSnapshot.Attributes[attrAggregatedItems], "3"; got != want {
|
||||||
|
t.Fatalf("aggregated items mismatch: got=%q want=%q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
package opagg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/payments/storage/model"
|
||||||
|
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
)
|
||||||
|
|
||||||
|
func cloneMoney(src *paymenttypes.Money) *paymenttypes.Money {
|
||||||
|
if src == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &paymenttypes.Money{
|
||||||
|
Amount: strings.TrimSpace(src.Amount),
|
||||||
|
Currency: normalizeCurrency(src.Currency),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneNetworkFee(src *paymenttypes.NetworkFeeEstimate) *paymenttypes.NetworkFeeEstimate {
|
||||||
|
if src == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &paymenttypes.NetworkFeeEstimate{
|
||||||
|
NetworkFee: cloneMoney(src.NetworkFee),
|
||||||
|
EstimationContext: strings.TrimSpace(src.EstimationContext),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneFeeLines(src []*paymenttypes.FeeLine) []*paymenttypes.FeeLine {
|
||||||
|
if len(src) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]*paymenttypes.FeeLine, 0, len(src))
|
||||||
|
for _, line := range src {
|
||||||
|
if line == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, cloneFeeLine(line))
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneFeeLine(src *paymenttypes.FeeLine) *paymenttypes.FeeLine {
|
||||||
|
if src == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &paymenttypes.FeeLine{
|
||||||
|
LedgerAccountRef: strings.TrimSpace(src.LedgerAccountRef),
|
||||||
|
Money: cloneMoney(src.Money),
|
||||||
|
LineType: src.LineType,
|
||||||
|
Side: src.Side,
|
||||||
|
Meta: cloneMetadata(src.Meta),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneFeeRules(src []*paymenttypes.AppliedRule) []*paymenttypes.AppliedRule {
|
||||||
|
if len(src) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]*paymenttypes.AppliedRule, 0, len(src))
|
||||||
|
for _, rule := range src {
|
||||||
|
if rule == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, cloneFeeRule(rule))
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneFeeRule(src *paymenttypes.AppliedRule) *paymenttypes.AppliedRule {
|
||||||
|
if src == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &paymenttypes.AppliedRule{
|
||||||
|
RuleID: strings.TrimSpace(src.RuleID),
|
||||||
|
RuleVersion: strings.TrimSpace(src.RuleVersion),
|
||||||
|
Formula: strings.TrimSpace(src.Formula),
|
||||||
|
Rounding: src.Rounding,
|
||||||
|
TaxCode: strings.TrimSpace(src.TaxCode),
|
||||||
|
TaxRate: strings.TrimSpace(src.TaxRate),
|
||||||
|
Parameters: cloneMetadata(src.Parameters),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneFXQuote(src *paymenttypes.FXQuote) *paymenttypes.FXQuote {
|
||||||
|
if src == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
dst := &paymenttypes.FXQuote{
|
||||||
|
QuoteRef: strings.TrimSpace(src.QuoteRef),
|
||||||
|
Side: src.Side,
|
||||||
|
ExpiresAtUnixMs: src.ExpiresAtUnixMs,
|
||||||
|
PricedAtUnixMs: src.PricedAtUnixMs,
|
||||||
|
Provider: strings.TrimSpace(src.Provider),
|
||||||
|
RateRef: strings.TrimSpace(src.RateRef),
|
||||||
|
Firm: src.Firm,
|
||||||
|
BaseAmount: cloneMoney(src.BaseAmount),
|
||||||
|
QuoteAmount: cloneMoney(src.QuoteAmount),
|
||||||
|
}
|
||||||
|
if src.Pair != nil {
|
||||||
|
dst.Pair = &paymenttypes.CurrencyPair{
|
||||||
|
Base: normalizeCurrency(src.Pair.Base),
|
||||||
|
Quote: normalizeCurrency(src.Pair.Quote),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if src.Price != nil {
|
||||||
|
dst.Price = &paymenttypes.Decimal{Value: strings.TrimSpace(src.Price.Value)}
|
||||||
|
}
|
||||||
|
return dst
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneRoute(src *paymenttypes.QuoteRouteSpecification) *paymenttypes.QuoteRouteSpecification {
|
||||||
|
if src == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
dst := &paymenttypes.QuoteRouteSpecification{
|
||||||
|
Rail: strings.TrimSpace(src.Rail),
|
||||||
|
Provider: strings.TrimSpace(src.Provider),
|
||||||
|
PayoutMethod: strings.TrimSpace(src.PayoutMethod),
|
||||||
|
Network: strings.TrimSpace(src.Network),
|
||||||
|
RouteRef: strings.TrimSpace(src.RouteRef),
|
||||||
|
PricingProfileRef: strings.TrimSpace(src.PricingProfileRef),
|
||||||
|
}
|
||||||
|
if src.Settlement != nil {
|
||||||
|
dst.Settlement = &paymenttypes.QuoteRouteSettlement{
|
||||||
|
Model: strings.TrimSpace(src.Settlement.Model),
|
||||||
|
Asset: cloneAsset(src.Settlement.Asset),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(src.Hops) > 0 {
|
||||||
|
dst.Hops = make([]*paymenttypes.QuoteRouteHop, 0, len(src.Hops))
|
||||||
|
for _, hop := range src.Hops {
|
||||||
|
if hop == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dst.Hops = append(dst.Hops, &paymenttypes.QuoteRouteHop{
|
||||||
|
Index: hop.Index,
|
||||||
|
Rail: strings.TrimSpace(hop.Rail),
|
||||||
|
Gateway: strings.TrimSpace(hop.Gateway),
|
||||||
|
InstanceID: strings.TrimSpace(hop.InstanceID),
|
||||||
|
Network: strings.TrimSpace(hop.Network),
|
||||||
|
Role: hop.Role,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dst
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneAsset(src *paymenttypes.Asset) *paymenttypes.Asset {
|
||||||
|
if src == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &paymenttypes.Asset{
|
||||||
|
Chain: strings.TrimSpace(src.Chain),
|
||||||
|
TokenSymbol: strings.TrimSpace(src.TokenSymbol),
|
||||||
|
ContractAddress: strings.TrimSpace(src.ContractAddress),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneExecutionConditions(src *paymenttypes.QuoteExecutionConditions) *paymenttypes.QuoteExecutionConditions {
|
||||||
|
if src == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
dst := &paymenttypes.QuoteExecutionConditions{
|
||||||
|
Readiness: src.Readiness,
|
||||||
|
BatchingEligible: src.BatchingEligible,
|
||||||
|
PrefundingRequired: src.PrefundingRequired,
|
||||||
|
PrefundingCostIncluded: src.PrefundingCostIncluded,
|
||||||
|
LiquidityCheckRequiredAtExecution: src.LiquidityCheckRequiredAtExecution,
|
||||||
|
LatencyHint: strings.TrimSpace(src.LatencyHint),
|
||||||
|
}
|
||||||
|
if len(src.Assumptions) > 0 {
|
||||||
|
dst.Assumptions = cloneStringSlice(src.Assumptions)
|
||||||
|
}
|
||||||
|
return dst
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneMetadata(src map[string]string) map[string]string {
|
||||||
|
if len(src) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make(map[string]string, len(src))
|
||||||
|
for key, value := range src {
|
||||||
|
k := strings.TrimSpace(key)
|
||||||
|
if k == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[k] = strings.TrimSpace(value)
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneStringSlice(src []string) []string {
|
||||||
|
if len(src) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(src))
|
||||||
|
for _, item := range src {
|
||||||
|
token := strings.TrimSpace(item)
|
||||||
|
if token == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, token)
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
return model.PaymentIntent{}, err
|
||||||
|
}
|
||||||
|
return dst, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneQuoteSnapshot(src *model.PaymentQuoteSnapshot) (*model.PaymentQuoteSnapshot, error) {
|
||||||
|
if src == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
dst := &model.PaymentQuoteSnapshot{}
|
||||||
|
if err := bsonClone(src, dst); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return dst, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func bsonClone(src any, dst any) error {
|
||||||
|
data, err := bson.Marshal(src)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return bson.Unmarshal(data, dst)
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package opagg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/shopspring/decimal"
|
||||||
|
"github.com/tech/sendico/payments/storage/model"
|
||||||
|
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
func sampleIntent(ref, cardToken, amount string) model.PaymentIntent {
|
||||||
|
return model.PaymentIntent{
|
||||||
|
Ref: ref,
|
||||||
|
Kind: model.PaymentKindPayout,
|
||||||
|
Source: model.PaymentEndpoint{
|
||||||
|
Type: model.EndpointTypeManagedWallet,
|
||||||
|
ManagedWallet: &model.ManagedWalletEndpoint{
|
||||||
|
ManagedWalletRef: "src-wallet-1",
|
||||||
|
Asset: &paymenttypes.Asset{Chain: "TRON", TokenSymbol: "USDT"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Destination: model.PaymentEndpoint{
|
||||||
|
Type: model.EndpointTypeCard,
|
||||||
|
Card: &model.CardEndpoint{
|
||||||
|
Token: cardToken,
|
||||||
|
Country: "RU",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Amount: &paymenttypes.Money{
|
||||||
|
Amount: amount,
|
||||||
|
Currency: "USDT",
|
||||||
|
},
|
||||||
|
SettlementMode: model.SettlementModeFixSource,
|
||||||
|
SettlementCurrency: "RUB",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sampleQuote(quoteRef, debit, settlement, feeTotal string) *model.PaymentQuoteSnapshot {
|
||||||
|
fee := "1.5"
|
||||||
|
tax := "0.3"
|
||||||
|
totalCost := addStrings(debit, feeTotal)
|
||||||
|
|
||||||
|
return &model.PaymentQuoteSnapshot{
|
||||||
|
QuoteRef: quoteRef,
|
||||||
|
DebitAmount: &paymenttypes.Money{
|
||||||
|
Amount: debit,
|
||||||
|
Currency: "USDT",
|
||||||
|
},
|
||||||
|
ExpectedSettlementAmount: &paymenttypes.Money{
|
||||||
|
Amount: settlement,
|
||||||
|
Currency: "RUB",
|
||||||
|
},
|
||||||
|
ExpectedFeeTotal: &paymenttypes.Money{
|
||||||
|
Amount: feeTotal,
|
||||||
|
Currency: "USDT",
|
||||||
|
},
|
||||||
|
TotalCost: &paymenttypes.Money{
|
||||||
|
Amount: totalCost,
|
||||||
|
Currency: "USDT",
|
||||||
|
},
|
||||||
|
FeeLines: []*paymenttypes.FeeLine{
|
||||||
|
{
|
||||||
|
LedgerAccountRef: "ledger:fees:usdt",
|
||||||
|
Money: &paymenttypes.Money{Amount: fee, Currency: "USDT"},
|
||||||
|
LineType: paymenttypes.PostingLineTypeFee,
|
||||||
|
Side: paymenttypes.EntrySideDebit,
|
||||||
|
Meta: map[string]string{"component": "platform_fee", "provider": "monetix"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
LedgerAccountRef: "ledger:tax:usdt",
|
||||||
|
Money: &paymenttypes.Money{Amount: tax, Currency: "USDT"},
|
||||||
|
LineType: paymenttypes.PostingLineTypeTax,
|
||||||
|
Side: paymenttypes.EntrySideDebit,
|
||||||
|
Meta: map[string]string{"component": "vat", "provider": "monetix"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
FeeRules: []*paymenttypes.AppliedRule{
|
||||||
|
{
|
||||||
|
RuleID: "rule.platform.usdt",
|
||||||
|
RuleVersion: "2026-02-01",
|
||||||
|
Formula: "flat(1.50)+tax(0.30)",
|
||||||
|
TaxCode: "VAT",
|
||||||
|
TaxRate: "0.20",
|
||||||
|
Parameters: map[string]string{"country": "RU"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
FXQuote: &paymenttypes.FXQuote{
|
||||||
|
QuoteRef: "fx-usdt-rub",
|
||||||
|
Provider: "test-oracle",
|
||||||
|
RateRef: "rate-usdt-rub",
|
||||||
|
Side: paymenttypes.FXSideSellBaseBuyQuote,
|
||||||
|
Firm: true,
|
||||||
|
Price: &paymenttypes.Decimal{Value: "91.5"},
|
||||||
|
BaseAmount: &paymenttypes.Money{Amount: debit, Currency: "USDT"},
|
||||||
|
QuoteAmount: &paymenttypes.Money{
|
||||||
|
Amount: settlement,
|
||||||
|
Currency: "RUB",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Route: &paymenttypes.QuoteRouteSpecification{
|
||||||
|
RouteRef: "rte-recipient-1",
|
||||||
|
PricingProfileRef: "fee_profile_1",
|
||||||
|
Hops: []*paymenttypes.QuoteRouteHop{
|
||||||
|
{Index: 1, Rail: "CRYPTO", Role: paymenttypes.QuoteRouteHopRoleSource},
|
||||||
|
{Index: 2, Rail: "LEDGER", Role: paymenttypes.QuoteRouteHopRoleTransit, Gateway: "internal"},
|
||||||
|
{Index: 3, Rail: "CARD_PAYOUT", Role: paymenttypes.QuoteRouteHopRoleDestination, Gateway: "monetix"},
|
||||||
|
},
|
||||||
|
Settlement: &paymenttypes.QuoteRouteSettlement{
|
||||||
|
Model: "fix_source",
|
||||||
|
Asset: &paymenttypes.Asset{TokenSymbol: "USDT"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ExecutionConditions: &paymenttypes.QuoteExecutionConditions{
|
||||||
|
Readiness: paymenttypes.QuoteExecutionReadinessLiquidityReady,
|
||||||
|
BatchingEligible: true,
|
||||||
|
LiquidityCheckRequiredAtExecution: true,
|
||||||
|
LatencyHint: "instant",
|
||||||
|
Assumptions: []string{"execution_time_liquidity_check"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func addStrings(left, right string) string {
|
||||||
|
l, lErr := decimal.NewFromString(left)
|
||||||
|
r, rErr := decimal.NewFromString(right)
|
||||||
|
if lErr != nil || rErr != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return l.Add(r).String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package opagg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/payments/storage/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func isEmptyIntentSnapshot(intent model.PaymentIntent) bool {
|
||||||
|
return intent.Amount == nil && (intent.Kind == "" || intent.Kind == model.PaymentKindUnspecified)
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmpty(values ...string) string {
|
||||||
|
for _, value := range values {
|
||||||
|
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func itoa(v int) string {
|
||||||
|
if v <= 0 {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
var buf [20]byte
|
||||||
|
i := len(buf)
|
||||||
|
for v > 0 {
|
||||||
|
i--
|
||||||
|
buf[i] = byte('0' + v%10)
|
||||||
|
v /= 10
|
||||||
|
}
|
||||||
|
return string(buf[i:])
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
package opagg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/payments/storage/model"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
func endpointKey(ep model.PaymentEndpoint) (string, error) {
|
||||||
|
endpointType := normalizeEndpointType(ep)
|
||||||
|
if endpointType == model.EndpointTypeUnspecified {
|
||||||
|
return "", merrors.InvalidArgument("endpoint type is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := []string{
|
||||||
|
"type=" + strings.ToLower(strings.TrimSpace(string(endpointType))),
|
||||||
|
"instance=" + strings.TrimSpace(ep.InstanceID),
|
||||||
|
}
|
||||||
|
|
||||||
|
switch endpointType {
|
||||||
|
case model.EndpointTypeLedger:
|
||||||
|
if ep.Ledger == nil {
|
||||||
|
return "", merrors.InvalidArgument("ledger endpoint is required")
|
||||||
|
}
|
||||||
|
parts = append(parts,
|
||||||
|
"account="+strings.TrimSpace(ep.Ledger.LedgerAccountRef),
|
||||||
|
"contra="+strings.TrimSpace(ep.Ledger.ContraLedgerAccountRef),
|
||||||
|
)
|
||||||
|
case model.EndpointTypeManagedWallet:
|
||||||
|
if ep.ManagedWallet == nil {
|
||||||
|
return "", merrors.InvalidArgument("managed_wallet endpoint is required")
|
||||||
|
}
|
||||||
|
parts = append(parts,
|
||||||
|
"wallet="+strings.TrimSpace(ep.ManagedWallet.ManagedWalletRef),
|
||||||
|
"asset="+assetKey(ep.ManagedWallet.Asset),
|
||||||
|
)
|
||||||
|
case model.EndpointTypeExternalChain:
|
||||||
|
if ep.ExternalChain == nil {
|
||||||
|
return "", merrors.InvalidArgument("external_chain endpoint is required")
|
||||||
|
}
|
||||||
|
parts = append(parts,
|
||||||
|
"address="+strings.TrimSpace(ep.ExternalChain.Address),
|
||||||
|
"memo="+strings.TrimSpace(ep.ExternalChain.Memo),
|
||||||
|
"asset="+assetKey(ep.ExternalChain.Asset),
|
||||||
|
)
|
||||||
|
case model.EndpointTypeCard:
|
||||||
|
if ep.Card == nil {
|
||||||
|
return "", merrors.InvalidArgument("card endpoint is required")
|
||||||
|
}
|
||||||
|
parts = append(parts,
|
||||||
|
"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 "", merrors.InvalidArgument("unsupported endpoint type")
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(parts, "|"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeEndpointType(ep model.PaymentEndpoint) model.PaymentEndpointType {
|
||||||
|
if ep.Type != model.EndpointTypeUnspecified {
|
||||||
|
return ep.Type
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case ep.Ledger != nil:
|
||||||
|
return model.EndpointTypeLedger
|
||||||
|
case ep.ManagedWallet != nil:
|
||||||
|
return model.EndpointTypeManagedWallet
|
||||||
|
case ep.ExternalChain != nil:
|
||||||
|
return model.EndpointTypeExternalChain
|
||||||
|
case ep.Card != nil:
|
||||||
|
return model.EndpointTypeCard
|
||||||
|
default:
|
||||||
|
return model.EndpointTypeUnspecified
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func routeSignature(route *paymenttypes.QuoteRouteSpecification) string {
|
||||||
|
if route == nil {
|
||||||
|
return "none"
|
||||||
|
}
|
||||||
|
parts := []string{
|
||||||
|
"route_ref=" + strings.TrimSpace(route.RouteRef),
|
||||||
|
"rail=" + strings.ToUpper(strings.TrimSpace(route.Rail)),
|
||||||
|
"provider=" + strings.TrimSpace(route.Provider),
|
||||||
|
"network=" + strings.TrimSpace(route.Network),
|
||||||
|
"settlement=" + settlementKey(route.Settlement),
|
||||||
|
}
|
||||||
|
for i, hop := range route.Hops {
|
||||||
|
if hop == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts = append(parts, fmt.Sprintf(
|
||||||
|
"hop[%d]=%d:%s:%s:%s:%s:%s",
|
||||||
|
i,
|
||||||
|
hop.Index,
|
||||||
|
strings.ToUpper(strings.TrimSpace(hop.Rail)),
|
||||||
|
strings.TrimSpace(hop.Gateway),
|
||||||
|
strings.TrimSpace(hop.InstanceID),
|
||||||
|
strings.TrimSpace(hop.Network),
|
||||||
|
strings.ToUpper(strings.TrimSpace(string(hop.Role))),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "|")
|
||||||
|
}
|
||||||
|
|
||||||
|
func settlementKey(s *paymenttypes.QuoteRouteSettlement) string {
|
||||||
|
if s == nil {
|
||||||
|
return "none"
|
||||||
|
}
|
||||||
|
return strings.Join([]string{
|
||||||
|
"model=" + strings.TrimSpace(s.Model),
|
||||||
|
"asset=" + assetKey(s.Asset),
|
||||||
|
}, "|")
|
||||||
|
}
|
||||||
|
|
||||||
|
func fxQuoteSignature(q *paymenttypes.FXQuote) string {
|
||||||
|
if q == nil {
|
||||||
|
return "none"
|
||||||
|
}
|
||||||
|
pair := "none"
|
||||||
|
if q.Pair != nil {
|
||||||
|
pair = strings.ToUpper(strings.TrimSpace(q.Pair.Base)) + "/" + strings.ToUpper(strings.TrimSpace(q.Pair.Quote))
|
||||||
|
}
|
||||||
|
price := ""
|
||||||
|
if q.Price != nil {
|
||||||
|
price = strings.TrimSpace(q.Price.Value)
|
||||||
|
}
|
||||||
|
return strings.Join([]string{
|
||||||
|
"pair=" + pair,
|
||||||
|
"side=" + strings.ToUpper(strings.TrimSpace(string(q.Side))),
|
||||||
|
"price=" + price,
|
||||||
|
"provider=" + strings.TrimSpace(q.Provider),
|
||||||
|
"rate_ref=" + strings.TrimSpace(q.RateRef),
|
||||||
|
"firm=" + strconv.FormatBool(q.Firm),
|
||||||
|
}, "|")
|
||||||
|
}
|
||||||
|
|
||||||
|
func feeLineKey(line *paymenttypes.FeeLine) string {
|
||||||
|
if line == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.Join([]string{
|
||||||
|
strings.TrimSpace(line.LedgerAccountRef),
|
||||||
|
strings.ToUpper(strings.TrimSpace(string(line.LineType))),
|
||||||
|
strings.ToUpper(strings.TrimSpace(string(line.Side))),
|
||||||
|
moneyCurrency(line.Money),
|
||||||
|
metadataSignature(line.Meta),
|
||||||
|
}, "|")
|
||||||
|
}
|
||||||
|
|
||||||
|
func feeRuleKey(rule *paymenttypes.AppliedRule) string {
|
||||||
|
if rule == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.Join([]string{
|
||||||
|
strings.TrimSpace(rule.RuleID),
|
||||||
|
strings.TrimSpace(rule.RuleVersion),
|
||||||
|
strings.TrimSpace(rule.Formula),
|
||||||
|
strings.TrimSpace(rule.TaxCode),
|
||||||
|
strings.TrimSpace(rule.TaxRate),
|
||||||
|
metadataSignature(rule.Parameters),
|
||||||
|
}, "|")
|
||||||
|
}
|
||||||
|
|
||||||
|
func metadataSignature(meta map[string]string) string {
|
||||||
|
if len(meta) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
keys := make([]string, 0, len(meta))
|
||||||
|
for key := range meta {
|
||||||
|
k := strings.TrimSpace(key)
|
||||||
|
if k == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
if len(keys) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
parts := make([]string, 0, len(keys))
|
||||||
|
for _, key := range keys {
|
||||||
|
parts = append(parts, key+"="+strings.TrimSpace(meta[key]))
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
func moneyCurrency(m *paymenttypes.Money) string {
|
||||||
|
if m == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return normalizeCurrency(m.Currency)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeCurrency(value string) string {
|
||||||
|
return strings.ToUpper(strings.TrimSpace(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
func assetKey(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),
|
||||||
|
}, ":")
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package opagg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/shopspring/decimal"
|
||||||
|
"github.com/tech/sendico/payments/storage/model"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
func mergeIntentSnapshot(dst *model.PaymentIntent, src model.PaymentIntent) error {
|
||||||
|
if dst == nil {
|
||||||
|
return merrors.InvalidArgument("intent_snapshot is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
sum, err := mergeMoney(dst.Amount, src.Amount, "intent_snapshot.amount")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
dst.Amount = sum
|
||||||
|
|
||||||
|
if dst.SettlementCurrency == "" {
|
||||||
|
dst.SettlementCurrency = strings.TrimSpace(src.SettlementCurrency)
|
||||||
|
} else if srcCurrency := strings.TrimSpace(src.SettlementCurrency); srcCurrency != "" && !strings.EqualFold(dst.SettlementCurrency, srcCurrency) {
|
||||||
|
return merrors.InvalidArgument("intent_snapshot.settlement_currency mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
if dst.Attributes == nil {
|
||||||
|
dst.Attributes = map[string]string{}
|
||||||
|
}
|
||||||
|
for key, value := range src.Attributes {
|
||||||
|
k := strings.TrimSpace(key)
|
||||||
|
if k == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, exists := dst.Attributes[k]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dst.Attributes[k] = strings.TrimSpace(value)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeQuoteSnapshot(dst *model.PaymentQuoteSnapshot, src *model.PaymentQuoteSnapshot) error {
|
||||||
|
if dst == nil {
|
||||||
|
return merrors.InvalidArgument("quote_snapshot is required")
|
||||||
|
}
|
||||||
|
if src == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
dst.DebitAmount, err = mergeMoney(dst.DebitAmount, src.DebitAmount, "quote_snapshot.debit_amount")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
dst.DebitSettlementAmount, err = mergeMoney(dst.DebitSettlementAmount, src.DebitSettlementAmount, "quote_snapshot.debit_settlement_amount")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
dst.ExpectedSettlementAmount, err = mergeMoney(dst.ExpectedSettlementAmount, src.ExpectedSettlementAmount, "quote_snapshot.expected_settlement_amount")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
dst.ExpectedFeeTotal, err = mergeMoney(dst.ExpectedFeeTotal, src.ExpectedFeeTotal, "quote_snapshot.expected_fee_total")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
dst.TotalCost, err = mergeMoney(dst.TotalCost, src.TotalCost, "quote_snapshot.total_cost")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
dst.NetworkFee, err = mergeNetworkFee(dst.NetworkFee, src.NetworkFee)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
dst.FeeLines, err = mergeFeeLines(dst.FeeLines, src.FeeLines)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
dst.FeeRules = mergeFeeRules(dst.FeeRules, src.FeeRules)
|
||||||
|
|
||||||
|
dst.Route, err = mergeRoute(dst.Route, src.Route)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
dst.ExecutionConditions = mergeExecutionConditions(dst.ExecutionConditions, src.ExecutionConditions)
|
||||||
|
|
||||||
|
dst.FXQuote, err = mergeFXQuote(dst.FXQuote, src.FXQuote)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeMoney(dst, src *paymenttypes.Money, field string) (*paymenttypes.Money, error) {
|
||||||
|
if dst == nil {
|
||||||
|
return cloneMoney(src), nil
|
||||||
|
}
|
||||||
|
if src == nil {
|
||||||
|
return dst, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
dstCurrency := normalizeCurrency(dst.Currency)
|
||||||
|
srcCurrency := normalizeCurrency(src.Currency)
|
||||||
|
if dstCurrency == "" || srcCurrency == "" {
|
||||||
|
return nil, merrors.InvalidArgument(field + ": currency is required")
|
||||||
|
}
|
||||||
|
if dstCurrency != srcCurrency {
|
||||||
|
return nil, merrors.InvalidArgument(field + ": currency mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
left, err := parseDecimal(dst.Amount)
|
||||||
|
if err != nil {
|
||||||
|
return nil, merrors.InvalidArgument(field + ": invalid amount")
|
||||||
|
}
|
||||||
|
right, err := parseDecimal(src.Amount)
|
||||||
|
if err != nil {
|
||||||
|
return nil, merrors.InvalidArgument(field + ": invalid amount")
|
||||||
|
}
|
||||||
|
dst.Amount = left.Add(right).String()
|
||||||
|
dst.Currency = dstCurrency
|
||||||
|
return dst, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseDecimal(raw string) (decimal.Decimal, error) {
|
||||||
|
return decimal.NewFromString(strings.TrimSpace(raw))
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
package opagg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
func mergeRoute(dst, src *paymenttypes.QuoteRouteSpecification) (*paymenttypes.QuoteRouteSpecification, error) {
|
||||||
|
if dst == nil {
|
||||||
|
return cloneRoute(src), nil
|
||||||
|
}
|
||||||
|
if src == nil {
|
||||||
|
return dst, nil
|
||||||
|
}
|
||||||
|
if routeSignature(dst) != routeSignature(src) {
|
||||||
|
return nil, merrors.InvalidArgument("quote_snapshot.route mismatch")
|
||||||
|
}
|
||||||
|
return dst, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeFXQuote(dst, src *paymenttypes.FXQuote) (*paymenttypes.FXQuote, error) {
|
||||||
|
if dst == nil {
|
||||||
|
return cloneFXQuote(src), nil
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
dst.QuoteAmount, err = mergeMoney(dst.QuoteAmount, src.QuoteAmount, "quote_snapshot.fx_quote.quote_amount")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if dst.ExpiresAtUnixMs == 0 || (src.ExpiresAtUnixMs > 0 && src.ExpiresAtUnixMs < dst.ExpiresAtUnixMs) {
|
||||||
|
dst.ExpiresAtUnixMs = src.ExpiresAtUnixMs
|
||||||
|
}
|
||||||
|
if src.PricedAtUnixMs > dst.PricedAtUnixMs {
|
||||||
|
dst.PricedAtUnixMs = src.PricedAtUnixMs
|
||||||
|
}
|
||||||
|
if dst.QuoteRef == "" {
|
||||||
|
dst.QuoteRef = src.QuoteRef
|
||||||
|
}
|
||||||
|
return dst, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeExecutionConditions(dst, src *paymenttypes.QuoteExecutionConditions) *paymenttypes.QuoteExecutionConditions {
|
||||||
|
if dst == nil {
|
||||||
|
return cloneExecutionConditions(src)
|
||||||
|
}
|
||||||
|
if src == nil {
|
||||||
|
return dst
|
||||||
|
}
|
||||||
|
|
||||||
|
dst.Readiness = mergedReadiness(dst.Readiness, src.Readiness)
|
||||||
|
dst.BatchingEligible = dst.BatchingEligible && src.BatchingEligible
|
||||||
|
dst.PrefundingRequired = dst.PrefundingRequired || src.PrefundingRequired
|
||||||
|
dst.PrefundingCostIncluded = dst.PrefundingCostIncluded || src.PrefundingCostIncluded
|
||||||
|
dst.LiquidityCheckRequiredAtExecution = dst.LiquidityCheckRequiredAtExecution || src.LiquidityCheckRequiredAtExecution
|
||||||
|
if dst.LatencyHint == "" {
|
||||||
|
dst.LatencyHint = src.LatencyHint
|
||||||
|
} else if srcHint := strings.TrimSpace(src.LatencyHint); srcHint != "" && !strings.EqualFold(dst.LatencyHint, srcHint) {
|
||||||
|
dst.LatencyHint = "mixed"
|
||||||
|
}
|
||||||
|
dst.Assumptions = mergeAssumptions(dst.Assumptions, src.Assumptions)
|
||||||
|
|
||||||
|
return dst
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergedReadiness(a, b paymenttypes.QuoteExecutionReadiness) paymenttypes.QuoteExecutionReadiness {
|
||||||
|
scoreA := readinessScore(a)
|
||||||
|
scoreB := readinessScore(b)
|
||||||
|
if scoreA <= scoreB {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func readinessScore(v paymenttypes.QuoteExecutionReadiness) int {
|
||||||
|
switch v {
|
||||||
|
case paymenttypes.QuoteExecutionReadinessIndicative:
|
||||||
|
return 0
|
||||||
|
case paymenttypes.QuoteExecutionReadinessLiquidityObtainable:
|
||||||
|
return 1
|
||||||
|
case paymenttypes.QuoteExecutionReadinessLiquidityReady:
|
||||||
|
return 2
|
||||||
|
default:
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeAssumptions(dst, src []string) []string {
|
||||||
|
if len(dst) == 0 && len(src) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
seen := make(map[string]struct{}, len(dst)+len(src))
|
||||||
|
out := make([]string, 0, len(dst)+len(src))
|
||||||
|
for _, item := range dst {
|
||||||
|
key := strings.TrimSpace(item)
|
||||||
|
if key == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, exists := seen[key]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
out = append(out, key)
|
||||||
|
}
|
||||||
|
for _, item := range src {
|
||||||
|
key := strings.TrimSpace(item)
|
||||||
|
if key == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, exists := seen[key]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
out = append(out, key)
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeNetworkFee(dst, src *paymenttypes.NetworkFeeEstimate) (*paymenttypes.NetworkFeeEstimate, error) {
|
||||||
|
if dst == nil {
|
||||||
|
return cloneNetworkFee(src), nil
|
||||||
|
}
|
||||||
|
if src == nil {
|
||||||
|
return dst, nil
|
||||||
|
}
|
||||||
|
sum, err := mergeMoney(dst.NetworkFee, src.NetworkFee, "quote_snapshot.network_fee.network_fee")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
dst.NetworkFee = sum
|
||||||
|
if dst.EstimationContext == "" {
|
||||||
|
dst.EstimationContext = strings.TrimSpace(src.EstimationContext)
|
||||||
|
} else if ctx := strings.TrimSpace(src.EstimationContext); ctx != "" && !strings.EqualFold(dst.EstimationContext, ctx) {
|
||||||
|
dst.EstimationContext = "mixed"
|
||||||
|
}
|
||||||
|
return dst, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeFeeLines(dst, src []*paymenttypes.FeeLine) ([]*paymenttypes.FeeLine, error) {
|
||||||
|
if len(dst) == 0 {
|
||||||
|
return cloneFeeLines(src), nil
|
||||||
|
}
|
||||||
|
if len(src) == 0 {
|
||||||
|
return dst, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
out := cloneFeeLines(dst)
|
||||||
|
indexByKey := make(map[string]int, len(out))
|
||||||
|
for i, line := range out {
|
||||||
|
if line == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
indexByKey[feeLineKey(line)] = i
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, line := range src {
|
||||||
|
if line == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := feeLineKey(line)
|
||||||
|
if idx, exists := indexByKey[key]; exists {
|
||||||
|
sum, err := mergeMoney(out[idx].Money, line.Money, "quote_snapshot.fee_lines["+key+"]")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out[idx].Money = sum
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cloned := cloneFeeLine(line)
|
||||||
|
indexByKey[key] = len(out)
|
||||||
|
out = append(out, cloned)
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeFeeRules(dst, src []*paymenttypes.AppliedRule) []*paymenttypes.AppliedRule {
|
||||||
|
if len(dst) == 0 {
|
||||||
|
return cloneFeeRules(src)
|
||||||
|
}
|
||||||
|
if len(src) == 0 {
|
||||||
|
return dst
|
||||||
|
}
|
||||||
|
|
||||||
|
out := cloneFeeRules(dst)
|
||||||
|
seen := make(map[string]struct{}, len(out))
|
||||||
|
for _, rule := range out {
|
||||||
|
if rule == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[feeRuleKey(rule)] = struct{}{}
|
||||||
|
}
|
||||||
|
for _, rule := range src {
|
||||||
|
if rule == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := feeRuleKey(rule)
|
||||||
|
if _, exists := seen[key]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
out = append(out, cloneFeeRule(rule))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package opagg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/tech/sendico/payments/storage/model"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Aggregator compacts compatible quote items into recipient-level execution groups.
|
||||||
|
type Aggregator interface {
|
||||||
|
Aggregate(in Input) (*Output, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Input contains quote/intents selected for one execution request scope.
|
||||||
|
type Input struct {
|
||||||
|
Items []Item
|
||||||
|
}
|
||||||
|
|
||||||
|
// Item is one quote-intent pair candidate for aggregation.
|
||||||
|
type Item 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output is the aggregation result.
|
||||||
|
type Output struct {
|
||||||
|
Groups []Group
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dependencies configures operation aggregator integrations.
|
||||||
|
type Dependencies struct {
|
||||||
|
Logger mlogger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(deps ...Dependencies) Aggregator {
|
||||||
|
var dep Dependencies
|
||||||
|
if len(deps) > 0 {
|
||||||
|
dep = deps[0]
|
||||||
|
}
|
||||||
|
return &svc{logger: dep.Logger.Named("opagg")}
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
package opagg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/payments/storage/model"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
keySep = "\x1f"
|
||||||
|
|
||||||
|
attrAggregatedByRecipient = "orchestrator.v2.aggregated_by_recipient"
|
||||||
|
attrAggregatedItems = "orchestrator.v2.aggregated_items"
|
||||||
|
)
|
||||||
|
|
||||||
|
type svc struct {
|
||||||
|
logger mlogger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
type groupAccumulator struct {
|
||||||
|
recipientKey string
|
||||||
|
intentRefs []string
|
||||||
|
intent model.PaymentIntent
|
||||||
|
quote *model.PaymentQuoteSnapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *svc) Aggregate(in Input) (out *Output, err error) {
|
||||||
|
logger := s.logger
|
||||||
|
logger.Debug("Starting Aggregate", zap.Int("items_count", len(in.Items)))
|
||||||
|
defer func(start time.Time) {
|
||||||
|
fields := []zap.Field{zap.Int64("duration_ms", time.Since(start).Milliseconds())}
|
||||||
|
if out != nil {
|
||||||
|
fields = append(fields, zap.Int("groups_count", len(out.Groups)))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("Failed to aggregate", append(fields, zap.Error(err))...)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger.Debug("Completed Aggregate", fields...)
|
||||||
|
}(time.Now())
|
||||||
|
|
||||||
|
if len(in.Items) == 0 {
|
||||||
|
return nil, merrors.InvalidArgument("items are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
groups := make(map[string]*groupAccumulator, len(in.Items))
|
||||||
|
order := make([]string, 0, len(in.Items))
|
||||||
|
|
||||||
|
for i := range in.Items {
|
||||||
|
item := in.Items[i]
|
||||||
|
if err := validateItem(item); err != nil {
|
||||||
|
return nil, merrors.InvalidArgument("items[" + itoa(i) + "]: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
key, recipientKey, err := groupingKey(item)
|
||||||
|
if err != nil {
|
||||||
|
return nil, merrors.InvalidArgument("items[" + itoa(i) + "]: " + err.Error())
|
||||||
|
}
|
||||||
|
if !isBatchingEligible(item.QuoteSnapshot) {
|
||||||
|
key = key + keySep + "non_batching=" + itoa(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
intentRef := firstNonEmpty(
|
||||||
|
strings.TrimSpace(item.IntentRef),
|
||||||
|
strings.TrimSpace(item.IntentSnapshot.Ref),
|
||||||
|
"intent-"+itoa(i+1),
|
||||||
|
)
|
||||||
|
|
||||||
|
acc, exists := groups[key]
|
||||||
|
if !exists {
|
||||||
|
intentSnapshot, cloneErr := cloneIntentSnapshot(item.IntentSnapshot)
|
||||||
|
if cloneErr != nil {
|
||||||
|
return nil, cloneErr
|
||||||
|
}
|
||||||
|
quoteSnapshot, cloneErr := cloneQuoteSnapshot(item.QuoteSnapshot)
|
||||||
|
if cloneErr != nil {
|
||||||
|
return nil, cloneErr
|
||||||
|
}
|
||||||
|
if quoteSnapshot == nil {
|
||||||
|
return nil, merrors.InvalidArgument("items[" + itoa(i) + "].quote_snapshot is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
groups[key] = &groupAccumulator{
|
||||||
|
recipientKey: recipientKey,
|
||||||
|
intentRefs: []string{intentRef},
|
||||||
|
intent: intentSnapshot,
|
||||||
|
quote: quoteSnapshot,
|
||||||
|
}
|
||||||
|
order = append(order, key)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := mergeIntentSnapshot(&acc.intent, item.IntentSnapshot); err != nil {
|
||||||
|
return nil, merrors.InvalidArgument("items[" + itoa(i) + "]: " + err.Error())
|
||||||
|
}
|
||||||
|
if err := mergeQuoteSnapshot(acc.quote, item.QuoteSnapshot); err != nil {
|
||||||
|
return nil, merrors.InvalidArgument("items[" + itoa(i) + "]: " + err.Error())
|
||||||
|
}
|
||||||
|
acc.intentRefs = append(acc.intentRefs, intentRef)
|
||||||
|
}
|
||||||
|
|
||||||
|
out = &Output{
|
||||||
|
Groups: make([]Group, 0, len(order)),
|
||||||
|
}
|
||||||
|
for _, key := range order {
|
||||||
|
acc := groups[key]
|
||||||
|
if acc == nil || acc.quote == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
finalIntent, err := cloneIntentSnapshot(acc.intent)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
finalQuote, err := cloneQuoteSnapshot(acc.quote)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(acc.intentRefs) > 1 {
|
||||||
|
if finalIntent.Attributes == nil {
|
||||||
|
finalIntent.Attributes = map[string]string{}
|
||||||
|
}
|
||||||
|
finalIntent.Attributes[attrAggregatedByRecipient] = "true"
|
||||||
|
finalIntent.Attributes[attrAggregatedItems] = strconv.Itoa(len(acc.intentRefs))
|
||||||
|
}
|
||||||
|
|
||||||
|
out.Groups = append(out.Groups, Group{
|
||||||
|
RecipientKey: acc.recipientKey,
|
||||||
|
IntentRefs: cloneStringSlice(acc.intentRefs),
|
||||||
|
IntentSnapshot: finalIntent,
|
||||||
|
QuoteSnapshot: finalQuote,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(out.Groups) == 0 {
|
||||||
|
return nil, merrors.InvalidArgument("aggregation produced no groups")
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateItem(item Item) error {
|
||||||
|
if isEmptyIntentSnapshot(item.IntentSnapshot) {
|
||||||
|
return merrors.InvalidArgument("intent_snapshot is required")
|
||||||
|
}
|
||||||
|
if item.QuoteSnapshot == nil {
|
||||||
|
return merrors.InvalidArgument("quote_snapshot is required")
|
||||||
|
}
|
||||||
|
if item.IntentSnapshot.Amount == nil {
|
||||||
|
return merrors.InvalidArgument("intent_snapshot.amount is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(item.IntentSnapshot.Amount.Currency) == "" {
|
||||||
|
return merrors.InvalidArgument("intent_snapshot.amount.currency is required")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
|
||||||
|
quote := item.QuoteSnapshot
|
||||||
|
key := strings.Join([]string{
|
||||||
|
"kind=" + strings.ToLower(strings.TrimSpace(string(item.IntentSnapshot.Kind))),
|
||||||
|
"source=" + sourceKey,
|
||||||
|
"recipient=" + recipientKey,
|
||||||
|
"settlement_mode=" + strings.ToLower(strings.TrimSpace(string(item.IntentSnapshot.SettlementMode))),
|
||||||
|
"settlement_currency=" + normalizeCurrency(item.IntentSnapshot.SettlementCurrency),
|
||||||
|
"debit_currency=" + moneyCurrency(quote.DebitAmount),
|
||||||
|
"settlement_amount_currency=" + moneyCurrency(quote.ExpectedSettlementAmount),
|
||||||
|
"route=" + routeSignature(quote.Route),
|
||||||
|
"fx=" + fxQuoteSignature(quote.FXQuote),
|
||||||
|
}, keySep)
|
||||||
|
|
||||||
|
return key, recipientKey, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isBatchingEligible(quote *model.PaymentQuoteSnapshot) bool {
|
||||||
|
if quote == nil || quote.ExecutionConditions == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return quote.ExecutionConditions.BatchingEligible
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package ostate
|
||||||
|
|
||||||
|
import "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||||
|
|
||||||
|
var aggregateTransitions = map[agg.State]map[agg.State]struct{}{
|
||||||
|
agg.StateUnspecified: {
|
||||||
|
agg.StateCreated: {},
|
||||||
|
},
|
||||||
|
agg.StateCreated: {
|
||||||
|
agg.StateExecuting: {},
|
||||||
|
agg.StateFailed: {},
|
||||||
|
},
|
||||||
|
agg.StateExecuting: {
|
||||||
|
agg.StateNeedsAttention: {},
|
||||||
|
agg.StateSettled: {},
|
||||||
|
agg.StateFailed: {},
|
||||||
|
},
|
||||||
|
agg.StateNeedsAttention: {
|
||||||
|
agg.StateExecuting: {},
|
||||||
|
agg.StateSettled: {},
|
||||||
|
agg.StateFailed: {},
|
||||||
|
},
|
||||||
|
agg.StateSettled: {},
|
||||||
|
agg.StateFailed: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
var aggregateTerminalStates = map[agg.State]struct{}{
|
||||||
|
agg.StateSettled: {},
|
||||||
|
agg.StateFailed: {},
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package ostate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAggregateTransitionMatrix(t *testing.T) {
|
||||||
|
sm := New()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
from agg.State
|
||||||
|
to agg.State
|
||||||
|
wantOK bool
|
||||||
|
wantErr error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "unspecified to created",
|
||||||
|
from: agg.StateUnspecified,
|
||||||
|
to: agg.StateCreated,
|
||||||
|
wantOK: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "created to executing",
|
||||||
|
from: agg.StateCreated,
|
||||||
|
to: agg.StateExecuting,
|
||||||
|
wantOK: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "executing to needs attention",
|
||||||
|
from: agg.StateExecuting,
|
||||||
|
to: agg.StateNeedsAttention,
|
||||||
|
wantOK: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "executing to settled",
|
||||||
|
from: agg.StateExecuting,
|
||||||
|
to: agg.StateSettled,
|
||||||
|
wantOK: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "needs attention back to executing",
|
||||||
|
from: agg.StateNeedsAttention,
|
||||||
|
to: agg.StateExecuting,
|
||||||
|
wantOK: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "idempotent self transition",
|
||||||
|
from: agg.StateFailed,
|
||||||
|
to: agg.StateFailed,
|
||||||
|
wantOK: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "created to settled denied",
|
||||||
|
from: agg.StateCreated,
|
||||||
|
to: agg.StateSettled,
|
||||||
|
wantOK: false,
|
||||||
|
wantErr: ErrAggregateTransitionNotAllowed,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "settled to executing denied",
|
||||||
|
from: agg.StateSettled,
|
||||||
|
to: agg.StateExecuting,
|
||||||
|
wantOK: false,
|
||||||
|
wantErr: ErrAggregateTransitionNotAllowed,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown from state",
|
||||||
|
from: agg.State("paused"),
|
||||||
|
to: agg.StateExecuting,
|
||||||
|
wantOK: false,
|
||||||
|
wantErr: ErrUnknownAggregateState,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown to state",
|
||||||
|
from: agg.StateExecuting,
|
||||||
|
to: agg.State("paused"),
|
||||||
|
wantOK: false,
|
||||||
|
wantErr: ErrUnknownAggregateState,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := sm.CanTransitionAggregate(tt.from, tt.to); got != tt.wantOK {
|
||||||
|
t.Fatalf("CanTransitionAggregate mismatch: got=%v want=%v", got, tt.wantOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := sm.EnsureAggregateTransition(tt.from, tt.to)
|
||||||
|
if tt.wantOK {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EnsureAggregateTransition returned error: %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, tt.wantErr) {
|
||||||
|
t.Fatalf("expected error %v, got %v", tt.wantErr, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAggregateTerminalStates(t *testing.T) {
|
||||||
|
sm := New()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
state agg.State
|
||||||
|
expect bool
|
||||||
|
}{
|
||||||
|
{state: agg.StateUnspecified, expect: false},
|
||||||
|
{state: agg.StateCreated, expect: false},
|
||||||
|
{state: agg.StateExecuting, expect: false},
|
||||||
|
{state: agg.StateNeedsAttention, expect: false},
|
||||||
|
{state: agg.StateSettled, expect: true},
|
||||||
|
{state: agg.StateFailed, expect: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
if got := sm.IsAggregateTerminal(tt.state); got != tt.expect {
|
||||||
|
t.Fatalf("IsAggregateTerminal(%q) mismatch: got=%v want=%v", tt.state, got, tt.expect)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package ostate
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrUnknownAggregateState = errors.New("unknown aggregate state")
|
||||||
|
ErrAggregateTransitionNotAllowed = errors.New("aggregate transition not allowed")
|
||||||
|
ErrUnknownStepState = errors.New("unknown step state")
|
||||||
|
ErrStepTransitionNotAllowed = errors.New("step transition not allowed")
|
||||||
|
)
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package ostate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StateMachine is the single source of truth for orchestration-v2 state transitions.
|
||||||
|
type StateMachine interface {
|
||||||
|
CanTransitionAggregate(from, to agg.State) bool
|
||||||
|
CanTransitionStep(from, to agg.StepState) bool
|
||||||
|
EnsureAggregateTransition(from, to agg.State) error
|
||||||
|
EnsureStepTransition(from, to agg.StepState) error
|
||||||
|
IsAggregateTerminal(state agg.State) bool
|
||||||
|
IsStepTerminal(state agg.StepState) bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dependencies configures state-machine integrations.
|
||||||
|
type Dependencies struct {
|
||||||
|
Logger mlogger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(deps ...Dependencies) StateMachine {
|
||||||
|
var dep Dependencies
|
||||||
|
if len(deps) > 0 {
|
||||||
|
dep = deps[0]
|
||||||
|
}
|
||||||
|
return &svc{logger: dep.Logger.Named("ostate")}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package ostate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xerr"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
type svc struct {
|
||||||
|
logger mlogger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *svc) CanTransitionAggregate(from, to agg.State) bool {
|
||||||
|
return canTransitionAggregate(from, to)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *svc) CanTransitionStep(from, to agg.StepState) bool {
|
||||||
|
return canTransitionStep(from, to)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *svc) EnsureAggregateTransition(from, to agg.State) error {
|
||||||
|
logger := s.logger
|
||||||
|
logger.Debug("Starting Ensure aggregate transition",
|
||||||
|
zap.String("from", string(from)),
|
||||||
|
zap.String("to", string(to)),
|
||||||
|
)
|
||||||
|
if !isKnownAggregateState(from) {
|
||||||
|
err := xerr.Wrapf(ErrUnknownAggregateState, "%q", from)
|
||||||
|
logger.Warn("Failed to ensure aggregate transition", zap.Error(err))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !isKnownAggregateState(to) {
|
||||||
|
err := xerr.Wrapf(ErrUnknownAggregateState, "%q", to)
|
||||||
|
logger.Warn("Failed to ensure aggregate transition", zap.Error(err))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if canTransitionAggregate(from, to) {
|
||||||
|
logger.Debug("Completed Ensure aggregate transition", zap.Bool("allowed", true))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
err := xerr.Wrapf(ErrAggregateTransitionNotAllowed, "%s -> %s", from, to)
|
||||||
|
logger.Warn("Failed to ensure aggregate transition", zap.Error(err))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *svc) EnsureStepTransition(from, to agg.StepState) error {
|
||||||
|
logger := s.logger
|
||||||
|
logger.Debug("Starting Ensure step transition",
|
||||||
|
zap.String("from", string(from)),
|
||||||
|
zap.String("to", string(to)),
|
||||||
|
)
|
||||||
|
if !isKnownStepState(from) {
|
||||||
|
err := xerr.Wrapf(ErrUnknownStepState, "%q", from)
|
||||||
|
logger.Warn("Failed to ensure step transition", zap.Error(err))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !isKnownStepState(to) {
|
||||||
|
err := xerr.Wrapf(ErrUnknownStepState, "%q", to)
|
||||||
|
logger.Warn("Failed to ensure step transition", zap.Error(err))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if canTransitionStep(from, to) {
|
||||||
|
logger.Debug("Completed Ensure step transition", zap.Bool("allowed", true))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
err := xerr.Wrapf(ErrStepTransitionNotAllowed, "%s -> %s", from, to)
|
||||||
|
logger.Warn("Failed to ensure step transition", zap.Error(err))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *svc) IsAggregateTerminal(state agg.State) bool {
|
||||||
|
_, ok := aggregateTerminalStates[state]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *svc) IsStepTerminal(state agg.StepState) bool {
|
||||||
|
_, ok := stepTerminalStates[state]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func canTransitionAggregate(from, to agg.State) bool {
|
||||||
|
if from == to {
|
||||||
|
return isKnownAggregateState(from)
|
||||||
|
}
|
||||||
|
allowed, ok := aggregateTransitions[from]
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, ok = allowed[to]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func canTransitionStep(from, to agg.StepState) bool {
|
||||||
|
if from == to {
|
||||||
|
return isKnownStepState(from)
|
||||||
|
}
|
||||||
|
allowed, ok := stepTransitions[from]
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, ok = allowed[to]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func isKnownAggregateState(state agg.State) bool {
|
||||||
|
_, ok := aggregateTransitions[state]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func isKnownStepState(state agg.StepState) bool {
|
||||||
|
_, ok := stepTransitions[state]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package ostate
|
||||||
|
|
||||||
|
import "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||||
|
|
||||||
|
var stepTransitions = map[agg.StepState]map[agg.StepState]struct{}{
|
||||||
|
agg.StepStateUnspecified: {
|
||||||
|
agg.StepStatePending: {},
|
||||||
|
},
|
||||||
|
agg.StepStatePending: {
|
||||||
|
agg.StepStateRunning: {},
|
||||||
|
agg.StepStateFailed: {},
|
||||||
|
agg.StepStateNeedsAttention: {},
|
||||||
|
agg.StepStateSkipped: {},
|
||||||
|
},
|
||||||
|
agg.StepStateRunning: {
|
||||||
|
agg.StepStateCompleted: {},
|
||||||
|
agg.StepStateFailed: {},
|
||||||
|
agg.StepStateNeedsAttention: {},
|
||||||
|
},
|
||||||
|
agg.StepStateCompleted: {},
|
||||||
|
agg.StepStateFailed: {
|
||||||
|
agg.StepStateRunning: {},
|
||||||
|
agg.StepStateNeedsAttention: {},
|
||||||
|
},
|
||||||
|
agg.StepStateNeedsAttention: {
|
||||||
|
agg.StepStateRunning: {},
|
||||||
|
agg.StepStateFailed: {},
|
||||||
|
agg.StepStateCompleted: {},
|
||||||
|
agg.StepStateSkipped: {},
|
||||||
|
},
|
||||||
|
agg.StepStateSkipped: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
var stepTerminalStates = map[agg.StepState]struct{}{
|
||||||
|
agg.StepStateCompleted: {},
|
||||||
|
agg.StepStateSkipped: {},
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package ostate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStepTransitionMatrix(t *testing.T) {
|
||||||
|
sm := New()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
from agg.StepState
|
||||||
|
to agg.StepState
|
||||||
|
wantOK bool
|
||||||
|
wantErr error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "unspecified to pending",
|
||||||
|
from: agg.StepStateUnspecified,
|
||||||
|
to: agg.StepStatePending,
|
||||||
|
wantOK: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "pending to running",
|
||||||
|
from: agg.StepStatePending,
|
||||||
|
to: agg.StepStateRunning,
|
||||||
|
wantOK: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "running to completed",
|
||||||
|
from: agg.StepStateRunning,
|
||||||
|
to: agg.StepStateCompleted,
|
||||||
|
wantOK: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "failed to running retry",
|
||||||
|
from: agg.StepStateFailed,
|
||||||
|
to: agg.StepStateRunning,
|
||||||
|
wantOK: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "needs attention to completed",
|
||||||
|
from: agg.StepStateNeedsAttention,
|
||||||
|
to: agg.StepStateCompleted,
|
||||||
|
wantOK: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "idempotent self transition",
|
||||||
|
from: agg.StepStateSkipped,
|
||||||
|
to: agg.StepStateSkipped,
|
||||||
|
wantOK: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "pending to completed denied",
|
||||||
|
from: agg.StepStatePending,
|
||||||
|
to: agg.StepStateCompleted,
|
||||||
|
wantOK: false,
|
||||||
|
wantErr: ErrStepTransitionNotAllowed,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "completed to running denied",
|
||||||
|
from: agg.StepStateCompleted,
|
||||||
|
to: agg.StepStateRunning,
|
||||||
|
wantOK: false,
|
||||||
|
wantErr: ErrStepTransitionNotAllowed,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown from state",
|
||||||
|
from: agg.StepState("waiting"),
|
||||||
|
to: agg.StepStateRunning,
|
||||||
|
wantOK: false,
|
||||||
|
wantErr: ErrUnknownStepState,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown to state",
|
||||||
|
from: agg.StepStateRunning,
|
||||||
|
to: agg.StepState("waiting"),
|
||||||
|
wantOK: false,
|
||||||
|
wantErr: ErrUnknownStepState,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := sm.CanTransitionStep(tt.from, tt.to); got != tt.wantOK {
|
||||||
|
t.Fatalf("CanTransitionStep mismatch: got=%v want=%v", got, tt.wantOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := sm.EnsureStepTransition(tt.from, tt.to)
|
||||||
|
if tt.wantOK {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EnsureStepTransition returned error: %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, tt.wantErr) {
|
||||||
|
t.Fatalf("expected error %v, got %v", tt.wantErr, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStepTerminalStates(t *testing.T) {
|
||||||
|
sm := New()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
state agg.StepState
|
||||||
|
expect bool
|
||||||
|
}{
|
||||||
|
{state: agg.StepStateUnspecified, expect: false},
|
||||||
|
{state: agg.StepStatePending, expect: false},
|
||||||
|
{state: agg.StepStateRunning, expect: false},
|
||||||
|
{state: agg.StepStateNeedsAttention, expect: false},
|
||||||
|
{state: agg.StepStateFailed, expect: false},
|
||||||
|
{state: agg.StepStateCompleted, expect: true},
|
||||||
|
{state: agg.StepStateSkipped, expect: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
if got := sm.IsStepTerminal(tt.state); got != tt.expect {
|
||||||
|
t.Fatalf("IsStepTerminal(%q) mismatch: got=%v want=%v", tt.state, got, tt.expect)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package pquery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||||
|
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/prepo"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Service provides read models for orchestration-v2 payments.
|
||||||
|
type Service interface {
|
||||||
|
GetPayment(ctx context.Context, in GetPaymentInput) (*agg.Payment, error)
|
||||||
|
ListPayments(ctx context.Context, in ListPaymentsInput) (*ListPaymentsOutput, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPaymentInput scopes one payment lookup.
|
||||||
|
type GetPaymentInput struct {
|
||||||
|
OrganizationRef bson.ObjectID
|
||||||
|
PaymentRef string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPaymentsInput scopes cursor-based listing.
|
||||||
|
type ListPaymentsInput struct {
|
||||||
|
OrganizationRef bson.ObjectID
|
||||||
|
States []agg.State
|
||||||
|
QuotationRef string
|
||||||
|
CreatedFrom *time.Time
|
||||||
|
CreatedTo *time.Time
|
||||||
|
Cursor *prepo.ListCursor
|
||||||
|
Limit int32
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPaymentsOutput is one list page.
|
||||||
|
type ListPaymentsOutput struct {
|
||||||
|
Items []*agg.Payment
|
||||||
|
NextCursor *prepo.ListCursor
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dependencies defines query service dependencies.
|
||||||
|
type Dependencies struct {
|
||||||
|
Repository prepo.Repository
|
||||||
|
Logger mlogger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(deps Dependencies) (Service, error) {
|
||||||
|
return newService(deps)
|
||||||
|
}
|
||||||
@@ -0,0 +1,425 @@
|
|||||||
|
package pquery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/prepo"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultLimit int32 = 50
|
||||||
|
maxLimit int32 = 200
|
||||||
|
)
|
||||||
|
|
||||||
|
var allStates = []agg.State{
|
||||||
|
agg.StateCreated,
|
||||||
|
agg.StateExecuting,
|
||||||
|
agg.StateNeedsAttention,
|
||||||
|
agg.StateSettled,
|
||||||
|
agg.StateFailed,
|
||||||
|
}
|
||||||
|
|
||||||
|
type svc struct {
|
||||||
|
logger mlogger.Logger
|
||||||
|
repo prepo.Repository
|
||||||
|
}
|
||||||
|
|
||||||
|
type normalizedInput struct {
|
||||||
|
organizationRef bson.ObjectID
|
||||||
|
quotationRef string
|
||||||
|
states []agg.State
|
||||||
|
createdFrom *time.Time
|
||||||
|
createdTo *time.Time
|
||||||
|
cursor *prepo.ListCursor
|
||||||
|
limit int32
|
||||||
|
}
|
||||||
|
|
||||||
|
func newService(deps Dependencies) (Service, error) {
|
||||||
|
if deps.Repository == nil {
|
||||||
|
return nil, merrors.InvalidArgument("payment repository v2 is required")
|
||||||
|
}
|
||||||
|
return &svc{
|
||||||
|
logger: deps.Logger.Named("pquery"),
|
||||||
|
repo: deps.Repository,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *svc) GetPayment(ctx context.Context, in GetPaymentInput) (payment *agg.Payment, err error) {
|
||||||
|
logger := s.logger
|
||||||
|
logger.Debug("Starting Get payment",
|
||||||
|
zap.String("organization_ref", in.OrganizationRef.Hex()),
|
||||||
|
zap.String("payment_ref", strings.TrimSpace(in.PaymentRef)),
|
||||||
|
)
|
||||||
|
defer func(start time.Time) {
|
||||||
|
fields := []zap.Field{zap.Int64("duration_ms", time.Since(start).Milliseconds())}
|
||||||
|
if payment != nil {
|
||||||
|
fields = append(fields,
|
||||||
|
zap.String("state", string(payment.State)),
|
||||||
|
zap.Uint64("version", payment.Version),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("Failed to get payment", append(fields, zap.Error(err))...)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger.Debug("Completed Get payment", fields...)
|
||||||
|
}(time.Now())
|
||||||
|
|
||||||
|
if in.OrganizationRef.IsZero() {
|
||||||
|
return nil, merrors.InvalidArgument("organization_ref is required")
|
||||||
|
}
|
||||||
|
paymentRef := strings.TrimSpace(in.PaymentRef)
|
||||||
|
if paymentRef == "" {
|
||||||
|
return nil, merrors.InvalidArgument("payment_ref is required")
|
||||||
|
}
|
||||||
|
payment, err = s.repo.GetByPaymentRef(ctx, in.OrganizationRef, paymentRef)
|
||||||
|
return payment, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *svc) ListPayments(ctx context.Context, in ListPaymentsInput) (out *ListPaymentsOutput, err error) {
|
||||||
|
logger := s.logger
|
||||||
|
logger.Debug("Starting List payments",
|
||||||
|
zap.String("organization_ref", in.OrganizationRef.Hex()),
|
||||||
|
zap.String("quotation_ref", strings.TrimSpace(in.QuotationRef)),
|
||||||
|
zap.Int("states_count", len(in.States)),
|
||||||
|
zap.Int32("limit", in.Limit),
|
||||||
|
)
|
||||||
|
defer func(start time.Time) {
|
||||||
|
fields := []zap.Field{zap.Int64("duration_ms", time.Since(start).Milliseconds())}
|
||||||
|
if out != nil {
|
||||||
|
fields = append(fields, zap.Int("items_count", len(out.Items)))
|
||||||
|
if out.NextCursor != nil {
|
||||||
|
fields = append(fields,
|
||||||
|
zap.String("next_cursor_id", out.NextCursor.ID.Hex()),
|
||||||
|
zap.Time("next_cursor_created_at", out.NextCursor.CreatedAt),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("Failed to list payments", append(fields, zap.Error(err))...)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger.Debug("Completed List payments", fields...)
|
||||||
|
}(time.Now())
|
||||||
|
|
||||||
|
norm, err := normalizeInput(in)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if norm.quotationRef != "" {
|
||||||
|
out, err = s.listByQuotationRef(ctx, norm)
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
out, err = s.listByStates(ctx, norm)
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeInput(in ListPaymentsInput) (*normalizedInput, error) {
|
||||||
|
if in.OrganizationRef.IsZero() {
|
||||||
|
return nil, merrors.InvalidArgument("organization_ref is required")
|
||||||
|
}
|
||||||
|
if in.CreatedFrom != nil && in.CreatedTo != nil {
|
||||||
|
from := in.CreatedFrom.UTC()
|
||||||
|
to := in.CreatedTo.UTC()
|
||||||
|
if !from.Before(to) {
|
||||||
|
return nil, merrors.InvalidArgument("created_from must be before created_to")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
states, err := normalizeStates(in.States)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var createdFrom *time.Time
|
||||||
|
if in.CreatedFrom != nil {
|
||||||
|
from := in.CreatedFrom.UTC()
|
||||||
|
createdFrom = &from
|
||||||
|
}
|
||||||
|
var createdTo *time.Time
|
||||||
|
if in.CreatedTo != nil {
|
||||||
|
to := in.CreatedTo.UTC()
|
||||||
|
createdTo = &to
|
||||||
|
}
|
||||||
|
|
||||||
|
return &normalizedInput{
|
||||||
|
organizationRef: in.OrganizationRef,
|
||||||
|
quotationRef: strings.TrimSpace(in.QuotationRef),
|
||||||
|
states: states,
|
||||||
|
createdFrom: createdFrom,
|
||||||
|
createdTo: createdTo,
|
||||||
|
cursor: in.Cursor,
|
||||||
|
limit: sanitizeLimit(in.Limit),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeStates(src []agg.State) ([]agg.State, error) {
|
||||||
|
if len(src) == 0 {
|
||||||
|
return append([]agg.State(nil), allStates...), nil
|
||||||
|
}
|
||||||
|
out := make([]agg.State, 0, len(src))
|
||||||
|
seen := map[agg.State]struct{}{}
|
||||||
|
for i := range src {
|
||||||
|
state, ok := normalizeState(src[i])
|
||||||
|
if !ok {
|
||||||
|
return nil, merrors.InvalidArgument("states contains invalid value")
|
||||||
|
}
|
||||||
|
if _, exists := seen[state]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[state] = struct{}{}
|
||||||
|
out = append(out, state)
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return append([]agg.State(nil), allStates...), nil
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeState(state agg.State) (agg.State, bool) {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(string(state))) {
|
||||||
|
case string(agg.StateCreated):
|
||||||
|
return agg.StateCreated, true
|
||||||
|
case string(agg.StateExecuting):
|
||||||
|
return agg.StateExecuting, true
|
||||||
|
case string(agg.StateNeedsAttention):
|
||||||
|
return agg.StateNeedsAttention, true
|
||||||
|
case string(agg.StateSettled):
|
||||||
|
return agg.StateSettled, true
|
||||||
|
case string(agg.StateFailed):
|
||||||
|
return agg.StateFailed, true
|
||||||
|
default:
|
||||||
|
return agg.StateUnspecified, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeLimit(limit int32) int32 {
|
||||||
|
if limit <= 0 {
|
||||||
|
return defaultLimit
|
||||||
|
}
|
||||||
|
if limit > maxLimit {
|
||||||
|
return maxLimit
|
||||||
|
}
|
||||||
|
return limit
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *svc) listByQuotationRef(ctx context.Context, in *normalizedInput) (*ListPaymentsOutput, error) {
|
||||||
|
cursor := in.cursor
|
||||||
|
out := make([]*agg.Payment, 0, in.limit)
|
||||||
|
|
||||||
|
for len(out) < int(in.limit) {
|
||||||
|
page, err := s.repo.ListByQuotationRef(ctx, prepo.ListByQuotationRefInput{
|
||||||
|
OrganizationRef: in.organizationRef,
|
||||||
|
QuotationRef: in.quotationRef,
|
||||||
|
Limit: in.limit,
|
||||||
|
Cursor: cursor,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if page == nil || len(page.Items) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range page.Items {
|
||||||
|
item := page.Items[i]
|
||||||
|
if !matchesFilters(item, in) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, item)
|
||||||
|
if len(out) == int(in.limit) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(out) == int(in.limit) || page.NextCursor == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
cursor = page.NextCursor
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildOutput(out, in.limit), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *svc) listByStates(ctx context.Context, in *normalizedInput) (*ListPaymentsOutput, error) {
|
||||||
|
cursor := in.cursor
|
||||||
|
out := make([]*agg.Payment, 0, in.limit)
|
||||||
|
|
||||||
|
for len(out) < int(in.limit) {
|
||||||
|
merged, next, err := s.fetchStatesPage(ctx, in, cursor)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(merged) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range merged {
|
||||||
|
if !matchesFilters(merged[i], in) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, merged[i])
|
||||||
|
if len(out) == int(in.limit) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(out) == int(in.limit) || next == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
cursor = next
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildOutput(out, in.limit), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *svc) fetchStatesPage(
|
||||||
|
ctx context.Context,
|
||||||
|
in *normalizedInput,
|
||||||
|
cursor *prepo.ListCursor,
|
||||||
|
) ([]*agg.Payment, *prepo.ListCursor, error) {
|
||||||
|
batch := in.limit
|
||||||
|
if batch < 20 {
|
||||||
|
batch = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
merged := make([]*agg.Payment, 0, len(in.states)*int(batch))
|
||||||
|
var next *prepo.ListCursor
|
||||||
|
for i := range in.states {
|
||||||
|
page, err := s.repo.ListByState(ctx, prepo.ListByStateInput{
|
||||||
|
OrganizationRef: in.organizationRef,
|
||||||
|
State: in.states[i],
|
||||||
|
Limit: batch,
|
||||||
|
Cursor: cursor,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if page == nil || len(page.Items) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
merged = append(merged, page.Items...)
|
||||||
|
if next == nil || cursorLess(page.NextCursor, next) {
|
||||||
|
next = page.NextCursor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(merged) == 0 {
|
||||||
|
return nil, nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
sortPaymentsDesc(merged)
|
||||||
|
merged = dedupeByPaymentRef(merged)
|
||||||
|
if len(merged) == 0 {
|
||||||
|
return nil, next, nil
|
||||||
|
}
|
||||||
|
oldest := cursorFromPayment(merged[len(merged)-1])
|
||||||
|
if cursorLess(oldest, next) {
|
||||||
|
next = oldest
|
||||||
|
}
|
||||||
|
return merged, next, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cursorFromPayment(payment *agg.Payment) *prepo.ListCursor {
|
||||||
|
if payment == nil || payment.ID.IsZero() || payment.CreatedAt.IsZero() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &prepo.ListCursor{
|
||||||
|
CreatedAt: payment.CreatedAt.UTC(),
|
||||||
|
ID: payment.ID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cursorLess(left *prepo.ListCursor, right *prepo.ListCursor) bool {
|
||||||
|
if left == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if right == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if left.CreatedAt.Before(right.CreatedAt) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if left.CreatedAt.After(right.CreatedAt) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return bytes.Compare(left.ID[:], right.ID[:]) < 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildOutput(items []*agg.Payment, limit int32) *ListPaymentsOutput {
|
||||||
|
if len(items) == 0 {
|
||||||
|
return &ListPaymentsOutput{}
|
||||||
|
}
|
||||||
|
if int32(len(items)) > limit {
|
||||||
|
items = items[:limit]
|
||||||
|
}
|
||||||
|
var nextCursor *prepo.ListCursor
|
||||||
|
if int32(len(items)) == limit {
|
||||||
|
nextCursor = cursorFromPayment(items[len(items)-1])
|
||||||
|
}
|
||||||
|
return &ListPaymentsOutput{
|
||||||
|
Items: items,
|
||||||
|
NextCursor: nextCursor,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchesFilters(payment *agg.Payment, in *normalizedInput) bool {
|
||||||
|
if payment == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if in.quotationRef != "" && !strings.EqualFold(strings.TrimSpace(payment.QuotationRef), in.quotationRef) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if in.createdFrom != nil && payment.CreatedAt.Before(*in.createdFrom) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if in.createdTo != nil && !payment.CreatedAt.Before(*in.createdTo) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return containsState(in.states, payment.State)
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsState(states []agg.State, state agg.State) bool {
|
||||||
|
for i := range states {
|
||||||
|
if states[i] == state {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortPaymentsDesc(items []*agg.Payment) {
|
||||||
|
sort.Slice(items, func(i, j int) bool {
|
||||||
|
left := items[i]
|
||||||
|
right := items[j]
|
||||||
|
if !left.CreatedAt.Equal(right.CreatedAt) {
|
||||||
|
return left.CreatedAt.After(right.CreatedAt)
|
||||||
|
}
|
||||||
|
return bytes.Compare(left.ID[:], right.ID[:]) > 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func dedupeByPaymentRef(items []*agg.Payment) []*agg.Payment {
|
||||||
|
if len(items) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]*agg.Payment, 0, len(items))
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
for i := range items {
|
||||||
|
ref := strings.TrimSpace(items[i].PaymentRef)
|
||||||
|
if ref == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[ref]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[ref] = struct{}{}
|
||||||
|
out = append(out, items[i])
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package prepo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||||
|
"github.com/tech/sendico/payments/storage/model"
|
||||||
|
"github.com/tech/sendico/pkg/db/storable"
|
||||||
|
pm "github.com/tech/sendico/pkg/model"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
)
|
||||||
|
|
||||||
|
const paymentsV2Collection = "payments_v2"
|
||||||
|
|
||||||
|
type paymentDocument struct {
|
||||||
|
storable.Base `bson:",inline"`
|
||||||
|
pm.OrganizationBoundBase `bson:",inline"`
|
||||||
|
PaymentRef string `bson:"paymentRef"`
|
||||||
|
IdempotencyKey string `bson:"idempotencyKey"`
|
||||||
|
QuotationRef string `bson:"quotationRef"`
|
||||||
|
ClientPaymentRef string `bson:"clientPaymentRef,omitempty"`
|
||||||
|
IntentSnapshot model.PaymentIntent `bson:"intentSnapshot"`
|
||||||
|
QuoteSnapshot *model.PaymentQuoteSnapshot `bson:"quoteSnapshot"`
|
||||||
|
State agg.State `bson:"state"`
|
||||||
|
Version uint64 `bson:"version"`
|
||||||
|
StepExecutions []agg.StepExecution `bson:"stepExecutions,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*paymentDocument) Collection() string {
|
||||||
|
return paymentsV2Collection
|
||||||
|
}
|
||||||
|
|
||||||
|
func toDocument(payment *agg.Payment) (*paymentDocument, error) {
|
||||||
|
if payment == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
doc := &paymentDocument{
|
||||||
|
Base: payment.Base,
|
||||||
|
OrganizationBoundBase: payment.OrganizationBoundBase,
|
||||||
|
PaymentRef: strings.TrimSpace(payment.PaymentRef),
|
||||||
|
IdempotencyKey: strings.TrimSpace(payment.IdempotencyKey),
|
||||||
|
QuotationRef: strings.TrimSpace(payment.QuotationRef),
|
||||||
|
ClientPaymentRef: strings.TrimSpace(payment.ClientPaymentRef),
|
||||||
|
IntentSnapshot: payment.IntentSnapshot,
|
||||||
|
QuoteSnapshot: payment.QuoteSnapshot,
|
||||||
|
State: payment.State,
|
||||||
|
Version: payment.Version,
|
||||||
|
StepExecutions: cloneStepExecutions(payment.StepExecutions),
|
||||||
|
}
|
||||||
|
return cloneDocument(doc)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fromDocument(doc *paymentDocument) (*agg.Payment, error) {
|
||||||
|
if doc == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
cloned, err := cloneDocument(doc)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &agg.Payment{
|
||||||
|
Base: cloned.Base,
|
||||||
|
OrganizationBoundBase: cloned.OrganizationBoundBase,
|
||||||
|
PaymentRef: cloned.PaymentRef,
|
||||||
|
IdempotencyKey: cloned.IdempotencyKey,
|
||||||
|
QuotationRef: cloned.QuotationRef,
|
||||||
|
ClientPaymentRef: cloned.ClientPaymentRef,
|
||||||
|
IntentSnapshot: cloned.IntentSnapshot,
|
||||||
|
QuoteSnapshot: cloned.QuoteSnapshot,
|
||||||
|
State: cloned.State,
|
||||||
|
Version: cloned.Version,
|
||||||
|
StepExecutions: cloneStepExecutions(cloned.StepExecutions),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneDocument(doc *paymentDocument) (*paymentDocument, error) {
|
||||||
|
if doc == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
data, err := bson.Marshal(doc)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := &paymentDocument{}
|
||||||
|
if err := bson.Unmarshal(data, out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneStepExecutions(src []agg.StepExecution) []agg.StepExecution {
|
||||||
|
if len(src) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]agg.StepExecution, 0, len(src))
|
||||||
|
for i := range src {
|
||||||
|
step := src[i]
|
||||||
|
step.StepRef = strings.TrimSpace(step.StepRef)
|
||||||
|
step.StepCode = strings.TrimSpace(step.StepCode)
|
||||||
|
step.FailureCode = strings.TrimSpace(step.FailureCode)
|
||||||
|
step.FailureMsg = strings.TrimSpace(step.FailureMsg)
|
||||||
|
if step.Attempt == 0 {
|
||||||
|
step.Attempt = 1
|
||||||
|
}
|
||||||
|
step.ExternalRefs = cloneExternalRefs(step.ExternalRefs)
|
||||||
|
step.StartedAt = cloneTime(step.StartedAt)
|
||||||
|
step.CompletedAt = cloneTime(step.CompletedAt)
|
||||||
|
out = append(out, step)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package prepo
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrPaymentNotFound = errors.New("payment repository v2: payment not found")
|
||||||
|
ErrDuplicatePayment = errors.New("payment repository v2: duplicate payment")
|
||||||
|
ErrVersionConflict = errors.New("payment repository v2: version conflict")
|
||||||
|
)
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package prepo
|
||||||
|
|
||||||
|
import (
|
||||||
|
ri "github.com/tech/sendico/pkg/db/repository/index"
|
||||||
|
)
|
||||||
|
|
||||||
|
type indexDefinition = ri.Definition
|
||||||
|
|
||||||
|
func requiredIndexes() []*indexDefinition {
|
||||||
|
return []*indexDefinition{
|
||||||
|
{
|
||||||
|
Keys: []ri.Key{
|
||||||
|
{Field: "organizationRef", Sort: ri.Asc},
|
||||||
|
{Field: "paymentRef", Sort: ri.Asc},
|
||||||
|
},
|
||||||
|
Unique: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Keys: []ri.Key{
|
||||||
|
{Field: "organizationRef", Sort: ri.Asc},
|
||||||
|
{Field: "idempotencyKey", Sort: ri.Asc},
|
||||||
|
},
|
||||||
|
Unique: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Keys: []ri.Key{
|
||||||
|
{Field: "organizationRef", Sort: ri.Asc},
|
||||||
|
{Field: "quotationRef", Sort: ri.Asc},
|
||||||
|
{Field: "createdAt", Sort: ri.Desc},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Keys: []ri.Key{
|
||||||
|
{Field: "organizationRef", Sort: ri.Asc},
|
||||||
|
{Field: "state", Sort: ri.Asc},
|
||||||
|
{Field: "createdAt", Sort: ri.Desc},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package prepo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Repository persists orchestration-v2 payment aggregates.
|
||||||
|
type Repository interface {
|
||||||
|
Create(ctx context.Context, payment *agg.Payment) error
|
||||||
|
UpdateCAS(ctx context.Context, payment *agg.Payment, expectedVersion uint64) error
|
||||||
|
GetByPaymentRef(ctx context.Context, orgRef bson.ObjectID, paymentRef string) (*agg.Payment, error)
|
||||||
|
GetByIdempotencyKey(ctx context.Context, orgRef bson.ObjectID, idempotencyKey string) (*agg.Payment, error)
|
||||||
|
ListByQuotationRef(ctx context.Context, in ListByQuotationRefInput) (*ListOutput, error)
|
||||||
|
ListByState(ctx context.Context, in ListByStateInput) (*ListOutput, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListCursor is a stable pagination cursor sorted by created_at desc then id desc.
|
||||||
|
type ListCursor struct {
|
||||||
|
CreatedAt time.Time
|
||||||
|
ID bson.ObjectID
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListOutput is a page of payment aggregates.
|
||||||
|
type ListOutput struct {
|
||||||
|
Items []*agg.Payment
|
||||||
|
NextCursor *ListCursor
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListByQuotationRefInput defines listing scope by quotation_ref.
|
||||||
|
type ListByQuotationRefInput struct {
|
||||||
|
OrganizationRef bson.ObjectID
|
||||||
|
QuotationRef string
|
||||||
|
Limit int32
|
||||||
|
Cursor *ListCursor
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListByStateInput defines listing scope by aggregate state.
|
||||||
|
type ListByStateInput struct {
|
||||||
|
OrganizationRef bson.ObjectID
|
||||||
|
State agg.State
|
||||||
|
Limit int32
|
||||||
|
Cursor *ListCursor
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dependencies configures repository integrations.
|
||||||
|
type Dependencies struct {
|
||||||
|
Logger mlogger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMongo constructs a Mongo-backed payment repository-v2.
|
||||||
|
func NewMongo(collection *mongo.Collection, deps ...Dependencies) (Repository, error) {
|
||||||
|
var dep Dependencies
|
||||||
|
if len(deps) > 0 {
|
||||||
|
dep = deps[0]
|
||||||
|
}
|
||||||
|
return newWithStoreLogger(newMongoStore(collection), dep.Logger)
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
package prepo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||||
|
)
|
||||||
|
|
||||||
|
type mongoStore struct {
|
||||||
|
collection *mongo.Collection
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMongoStore(collection *mongo.Collection) paymentStore {
|
||||||
|
return &mongoStore{
|
||||||
|
collection: collection,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *mongoStore) EnsureIndexes(defs []*indexDefinition) error {
|
||||||
|
if s.collection == nil {
|
||||||
|
return merrors.InvalidArgument("payment repository v2: mongo collection is required")
|
||||||
|
}
|
||||||
|
if len(defs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
models := make([]mongo.IndexModel, 0, len(defs))
|
||||||
|
for i := range defs {
|
||||||
|
def := defs[i]
|
||||||
|
if def == nil || len(def.Keys) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
keys := bson.D{}
|
||||||
|
for j := range def.Keys {
|
||||||
|
key := def.Keys[j]
|
||||||
|
name := strings.TrimSpace(key.Field)
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch key.Type {
|
||||||
|
case "":
|
||||||
|
keys = append(keys, bson.E{Key: name, Value: int32(key.Sort)})
|
||||||
|
default:
|
||||||
|
keys = append(keys, bson.E{Key: name, Value: string(key.Type)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(keys) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
opt := options.Index()
|
||||||
|
if def.Name != "" {
|
||||||
|
opt.SetName(def.Name)
|
||||||
|
}
|
||||||
|
if def.Unique {
|
||||||
|
opt.SetUnique(true)
|
||||||
|
}
|
||||||
|
if def.Sparse {
|
||||||
|
opt.SetSparse(true)
|
||||||
|
}
|
||||||
|
if def.TTL != nil {
|
||||||
|
opt.SetExpireAfterSeconds(int32(*def.TTL))
|
||||||
|
}
|
||||||
|
if def.PartialFilter != nil {
|
||||||
|
opt.SetPartialFilterExpression(def.PartialFilter.BuildQuery())
|
||||||
|
}
|
||||||
|
models = append(models, mongo.IndexModel{
|
||||||
|
Keys: keys,
|
||||||
|
Options: opt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if len(models) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err := s.collection.Indexes().CreateMany(context.Background(), models)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *mongoStore) Create(ctx context.Context, doc *paymentDocument) error {
|
||||||
|
if s.collection == nil {
|
||||||
|
return merrors.InvalidArgument("payment repository v2: mongo collection is required")
|
||||||
|
}
|
||||||
|
if doc == nil {
|
||||||
|
return merrors.InvalidArgument("payment repository v2: payment document is required")
|
||||||
|
}
|
||||||
|
_, err := s.collection.InsertOne(ctx, doc)
|
||||||
|
if mongo.IsDuplicateKeyError(err) {
|
||||||
|
return ErrDuplicatePayment
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *mongoStore) UpdateCAS(ctx context.Context, doc *paymentDocument, expectedVersion uint64) (bool, error) {
|
||||||
|
if s.collection == nil {
|
||||||
|
return false, merrors.InvalidArgument("payment repository v2: mongo collection is required")
|
||||||
|
}
|
||||||
|
if doc == nil {
|
||||||
|
return false, merrors.InvalidArgument("payment repository v2: payment document is required")
|
||||||
|
}
|
||||||
|
filter := bson.D{
|
||||||
|
{Key: "_id", Value: doc.ID},
|
||||||
|
{Key: "organizationRef", Value: doc.OrganizationRef},
|
||||||
|
{Key: "version", Value: expectedVersion},
|
||||||
|
}
|
||||||
|
result, err := s.collection.ReplaceOne(ctx, filter, doc)
|
||||||
|
if mongo.IsDuplicateKeyError(err) {
|
||||||
|
return false, ErrDuplicatePayment
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return result.MatchedCount > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *mongoStore) GetByPaymentRef(ctx context.Context, orgRef bson.ObjectID, paymentRef string) (*paymentDocument, error) {
|
||||||
|
return s.findOne(ctx, bson.D{
|
||||||
|
{Key: "organizationRef", Value: orgRef},
|
||||||
|
{Key: "paymentRef", Value: paymentRef},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *mongoStore) GetByIdempotencyKey(ctx context.Context, orgRef bson.ObjectID, idempotencyKey string) (*paymentDocument, error) {
|
||||||
|
return s.findOne(ctx, bson.D{
|
||||||
|
{Key: "organizationRef", Value: orgRef},
|
||||||
|
{Key: "idempotencyKey", Value: idempotencyKey},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *mongoStore) GetByID(ctx context.Context, orgRef bson.ObjectID, id bson.ObjectID) (*paymentDocument, error) {
|
||||||
|
return s.findOne(ctx, bson.D{
|
||||||
|
{Key: "_id", Value: id},
|
||||||
|
{Key: "organizationRef", Value: orgRef},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *mongoStore) ListByQuotationRef(ctx context.Context, orgRef bson.ObjectID, quotationRef string, cursor *listCursor, limit int64) ([]*paymentDocument, error) {
|
||||||
|
filter := bson.D{
|
||||||
|
{Key: "organizationRef", Value: orgRef},
|
||||||
|
{Key: "quotationRef", Value: quotationRef},
|
||||||
|
}
|
||||||
|
return s.list(ctx, filter, cursor, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *mongoStore) ListByState(ctx context.Context, orgRef bson.ObjectID, state agg.State, cursor *listCursor, limit int64) ([]*paymentDocument, error) {
|
||||||
|
filter := bson.D{
|
||||||
|
{Key: "organizationRef", Value: orgRef},
|
||||||
|
{Key: "state", Value: state},
|
||||||
|
}
|
||||||
|
return s.list(ctx, filter, cursor, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *mongoStore) findOne(ctx context.Context, filter bson.D) (*paymentDocument, error) {
|
||||||
|
if s.collection == nil {
|
||||||
|
return nil, merrors.InvalidArgument("payment repository v2: mongo collection is required")
|
||||||
|
}
|
||||||
|
doc := &paymentDocument{}
|
||||||
|
err := s.collection.FindOne(ctx, filter).Decode(doc)
|
||||||
|
if err == nil {
|
||||||
|
return doc, nil
|
||||||
|
}
|
||||||
|
if err == mongo.ErrNoDocuments {
|
||||||
|
return nil, ErrPaymentNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *mongoStore) list(ctx context.Context, filter bson.D, cursor *listCursor, limit int64) ([]*paymentDocument, error) {
|
||||||
|
if s.collection == nil {
|
||||||
|
return nil, merrors.InvalidArgument("payment repository v2: mongo collection is required")
|
||||||
|
}
|
||||||
|
if cursor != nil {
|
||||||
|
filter = append(filter, bson.E{
|
||||||
|
Key: "$or",
|
||||||
|
Value: bson.A{
|
||||||
|
bson.D{{Key: "createdAt", Value: bson.D{{Key: "$lt", Value: cursor.CreatedAt}}}},
|
||||||
|
bson.D{
|
||||||
|
{Key: "createdAt", Value: cursor.CreatedAt},
|
||||||
|
{Key: "_id", Value: bson.D{{Key: "$lt", Value: cursor.ID}}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
opt := options.Find().
|
||||||
|
SetSort(bson.D{
|
||||||
|
{Key: "createdAt", Value: -1},
|
||||||
|
{Key: "_id", Value: -1},
|
||||||
|
}).
|
||||||
|
SetLimit(limit)
|
||||||
|
cur, err := s.collection.Find(ctx, filter, opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer cur.Close(ctx)
|
||||||
|
|
||||||
|
items := make([]*paymentDocument, 0)
|
||||||
|
for cur.Next(ctx) {
|
||||||
|
doc := &paymentDocument{}
|
||||||
|
if err := cur.Decode(doc); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, doc)
|
||||||
|
}
|
||||||
|
if err := cur.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ paymentStore = (*mongoStore)(nil)
|
||||||
@@ -0,0 +1,548 @@
|
|||||||
|
package prepo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultListLimit int64 = 50
|
||||||
|
maxListLimit int64 = 200
|
||||||
|
)
|
||||||
|
|
||||||
|
type listCursor struct {
|
||||||
|
CreatedAt time.Time
|
||||||
|
ID bson.ObjectID
|
||||||
|
}
|
||||||
|
|
||||||
|
type paymentStore interface {
|
||||||
|
EnsureIndexes(defs []*indexDefinition) error
|
||||||
|
Create(ctx context.Context, doc *paymentDocument) error
|
||||||
|
UpdateCAS(ctx context.Context, doc *paymentDocument, expectedVersion uint64) (bool, error)
|
||||||
|
GetByPaymentRef(ctx context.Context, orgRef bson.ObjectID, paymentRef string) (*paymentDocument, error)
|
||||||
|
GetByIdempotencyKey(ctx context.Context, orgRef bson.ObjectID, idempotencyKey string) (*paymentDocument, error)
|
||||||
|
GetByID(ctx context.Context, orgRef bson.ObjectID, id bson.ObjectID) (*paymentDocument, error)
|
||||||
|
ListByQuotationRef(ctx context.Context, orgRef bson.ObjectID, quotationRef string, cursor *listCursor, limit int64) ([]*paymentDocument, error)
|
||||||
|
ListByState(ctx context.Context, orgRef bson.ObjectID, state agg.State, cursor *listCursor, limit int64) ([]*paymentDocument, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type svc struct {
|
||||||
|
logger mlogger.Logger
|
||||||
|
store paymentStore
|
||||||
|
now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func newWithStore(store paymentStore) (Repository, error) {
|
||||||
|
return newWithStoreLogger(store, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newWithStoreLogger(store paymentStore, logger mlogger.Logger) (Repository, error) {
|
||||||
|
if store == nil {
|
||||||
|
return nil, merrors.InvalidArgument("payment repository v2: store is required")
|
||||||
|
}
|
||||||
|
if err := store.EnsureIndexes(requiredIndexes()); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &svc{
|
||||||
|
logger: logger.Named("prepo"),
|
||||||
|
store: store,
|
||||||
|
now: func() time.Time {
|
||||||
|
return time.Now().UTC()
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *svc) Create(ctx context.Context, payment *agg.Payment) (err error) {
|
||||||
|
logger := s.logger
|
||||||
|
paymentRef := ""
|
||||||
|
if payment != nil {
|
||||||
|
paymentRef = strings.TrimSpace(payment.PaymentRef)
|
||||||
|
}
|
||||||
|
logger.Debug("Starting Create", zap.String("payment_ref", paymentRef))
|
||||||
|
defer func(start time.Time) {
|
||||||
|
fields := []zap.Field{
|
||||||
|
zap.Int64("duration_ms", time.Since(start).Milliseconds()),
|
||||||
|
zap.String("payment_ref", paymentRef),
|
||||||
|
}
|
||||||
|
if payment != nil {
|
||||||
|
fields = append(fields,
|
||||||
|
zap.String("state", string(payment.State)),
|
||||||
|
zap.Uint64("version", payment.Version),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("Failed to create", append(fields, zap.Error(err))...)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger.Debug("Completed Create", fields...)
|
||||||
|
}(time.Now())
|
||||||
|
|
||||||
|
doc, err := prepareCreate(payment, s.now().UTC())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.store.Create(ctx, doc); err != nil {
|
||||||
|
if isDuplicate(err) {
|
||||||
|
return ErrDuplicatePayment
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out, err := fromDocument(doc)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*payment = *out
|
||||||
|
paymentRef = strings.TrimSpace(payment.PaymentRef)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *svc) UpdateCAS(ctx context.Context, payment *agg.Payment, expectedVersion uint64) (err error) {
|
||||||
|
logger := s.logger
|
||||||
|
paymentRef := ""
|
||||||
|
if payment != nil {
|
||||||
|
paymentRef = strings.TrimSpace(payment.PaymentRef)
|
||||||
|
}
|
||||||
|
logger.Debug("Starting Update cas",
|
||||||
|
zap.String("payment_ref", paymentRef),
|
||||||
|
zap.Uint64("expected_version", expectedVersion),
|
||||||
|
)
|
||||||
|
defer func(start time.Time) {
|
||||||
|
fields := []zap.Field{
|
||||||
|
zap.Int64("duration_ms", time.Since(start).Milliseconds()),
|
||||||
|
zap.String("payment_ref", paymentRef),
|
||||||
|
zap.Uint64("expected_version", expectedVersion),
|
||||||
|
}
|
||||||
|
if payment != nil {
|
||||||
|
fields = append(fields,
|
||||||
|
zap.String("state", string(payment.State)),
|
||||||
|
zap.Uint64("version", payment.Version),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("Failed to update cas", append(fields, zap.Error(err))...)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger.Debug("Completed Update cas", fields...)
|
||||||
|
}(time.Now())
|
||||||
|
|
||||||
|
doc, err := prepareUpdate(payment, expectedVersion, s.now().UTC())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := s.store.UpdateCAS(ctx, doc, expectedVersion)
|
||||||
|
if err != nil {
|
||||||
|
if isDuplicate(err) {
|
||||||
|
return ErrDuplicatePayment
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !updated {
|
||||||
|
if _, findErr := s.store.GetByID(ctx, doc.OrganizationRef, doc.ID); findErr != nil {
|
||||||
|
if errors.Is(findErr, ErrPaymentNotFound) {
|
||||||
|
return ErrPaymentNotFound
|
||||||
|
}
|
||||||
|
return findErr
|
||||||
|
}
|
||||||
|
return ErrVersionConflict
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := fromDocument(doc)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*payment = *out
|
||||||
|
paymentRef = strings.TrimSpace(payment.PaymentRef)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *svc) GetByPaymentRef(ctx context.Context, orgRef bson.ObjectID, paymentRef string) (payment *agg.Payment, err error) {
|
||||||
|
logger := s.logger
|
||||||
|
requestPaymentRef := strings.TrimSpace(paymentRef)
|
||||||
|
logger.Debug("Starting Get by payment ref",
|
||||||
|
zap.String("organization_ref", orgRef.Hex()),
|
||||||
|
zap.String("payment_ref", requestPaymentRef),
|
||||||
|
)
|
||||||
|
defer func(start time.Time) {
|
||||||
|
fields := []zap.Field{
|
||||||
|
zap.Int64("duration_ms", time.Since(start).Milliseconds()),
|
||||||
|
zap.String("organization_ref", orgRef.Hex()),
|
||||||
|
zap.String("payment_ref", requestPaymentRef),
|
||||||
|
}
|
||||||
|
if payment != nil {
|
||||||
|
fields = append(fields,
|
||||||
|
zap.String("state", string(payment.State)),
|
||||||
|
zap.Uint64("version", payment.Version),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("Failed to get by payment ref", append(fields, zap.Error(err))...)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger.Debug("Completed Get by payment ref", fields...)
|
||||||
|
}(time.Now())
|
||||||
|
|
||||||
|
if orgRef.IsZero() {
|
||||||
|
return nil, merrors.InvalidArgument("organization_ref is required")
|
||||||
|
}
|
||||||
|
paymentRef = strings.TrimSpace(paymentRef)
|
||||||
|
if paymentRef == "" {
|
||||||
|
return nil, merrors.InvalidArgument("payment_ref is required")
|
||||||
|
}
|
||||||
|
doc, err := s.store.GetByPaymentRef(ctx, orgRef, paymentRef)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
payment, err = fromDocument(doc)
|
||||||
|
return payment, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *svc) GetByIdempotencyKey(ctx context.Context, orgRef bson.ObjectID, idempotencyKey string) (payment *agg.Payment, err error) {
|
||||||
|
logger := s.logger
|
||||||
|
hasKey := strings.TrimSpace(idempotencyKey) != ""
|
||||||
|
logger.Debug("Starting Get by idempotency key",
|
||||||
|
zap.String("organization_ref", orgRef.Hex()),
|
||||||
|
zap.Bool("has_idempotency_key", hasKey),
|
||||||
|
)
|
||||||
|
defer func(start time.Time) {
|
||||||
|
fields := []zap.Field{
|
||||||
|
zap.Int64("duration_ms", time.Since(start).Milliseconds()),
|
||||||
|
zap.String("organization_ref", orgRef.Hex()),
|
||||||
|
zap.Bool("has_idempotency_key", hasKey),
|
||||||
|
}
|
||||||
|
if payment != nil {
|
||||||
|
fields = append(fields,
|
||||||
|
zap.String("payment_ref", strings.TrimSpace(payment.PaymentRef)),
|
||||||
|
zap.String("state", string(payment.State)),
|
||||||
|
zap.Uint64("version", payment.Version),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("Failed to get by idempotency key", append(fields, zap.Error(err))...)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger.Debug("Completed Get by idempotency key", fields...)
|
||||||
|
}(time.Now())
|
||||||
|
|
||||||
|
if orgRef.IsZero() {
|
||||||
|
return nil, merrors.InvalidArgument("organization_ref is required")
|
||||||
|
}
|
||||||
|
idempotencyKey = strings.TrimSpace(idempotencyKey)
|
||||||
|
if idempotencyKey == "" {
|
||||||
|
return nil, merrors.InvalidArgument("idempotency_key is required")
|
||||||
|
}
|
||||||
|
doc, err := s.store.GetByIdempotencyKey(ctx, orgRef, idempotencyKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
payment, err = fromDocument(doc)
|
||||||
|
return payment, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *svc) ListByQuotationRef(ctx context.Context, in ListByQuotationRefInput) (out *ListOutput, err error) {
|
||||||
|
logger := s.logger
|
||||||
|
logger.Debug("Starting List by quotation ref",
|
||||||
|
zap.String("organization_ref", in.OrganizationRef.Hex()),
|
||||||
|
zap.String("quotation_ref", strings.TrimSpace(in.QuotationRef)),
|
||||||
|
zap.Int32("limit", in.Limit),
|
||||||
|
)
|
||||||
|
defer func(start time.Time) {
|
||||||
|
fields := []zap.Field{zap.Int64("duration_ms", time.Since(start).Milliseconds())}
|
||||||
|
if out != nil {
|
||||||
|
fields = append(fields, zap.Int("items_count", len(out.Items)))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("Failed to list by quotation ref", append(fields, zap.Error(err))...)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger.Debug("Completed List by quotation ref", fields...)
|
||||||
|
}(time.Now())
|
||||||
|
|
||||||
|
if in.OrganizationRef.IsZero() {
|
||||||
|
return nil, merrors.InvalidArgument("organization_ref is required")
|
||||||
|
}
|
||||||
|
in.QuotationRef = strings.TrimSpace(in.QuotationRef)
|
||||||
|
if in.QuotationRef == "" {
|
||||||
|
return nil, merrors.InvalidArgument("quotation_ref is required")
|
||||||
|
}
|
||||||
|
cursor, err := normalizeCursor(in.Cursor)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out, err = s.list(ctx, listQuery{
|
||||||
|
limit: sanitizeLimit(in.Limit),
|
||||||
|
run: func(limit int64) ([]*paymentDocument, error) {
|
||||||
|
return s.store.ListByQuotationRef(ctx, in.OrganizationRef, in.QuotationRef, cursor, limit)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *svc) ListByState(ctx context.Context, in ListByStateInput) (out *ListOutput, err error) {
|
||||||
|
logger := s.logger
|
||||||
|
logger.Debug("Starting List by state",
|
||||||
|
zap.String("organization_ref", in.OrganizationRef.Hex()),
|
||||||
|
zap.String("state", string(in.State)),
|
||||||
|
zap.Int32("limit", in.Limit),
|
||||||
|
)
|
||||||
|
defer func(start time.Time) {
|
||||||
|
fields := []zap.Field{zap.Int64("duration_ms", time.Since(start).Milliseconds())}
|
||||||
|
if out != nil {
|
||||||
|
fields = append(fields, zap.Int("items_count", len(out.Items)))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("Failed to list by state", append(fields, zap.Error(err))...)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger.Debug("Completed List by state", fields...)
|
||||||
|
}(time.Now())
|
||||||
|
|
||||||
|
if in.OrganizationRef.IsZero() {
|
||||||
|
return nil, merrors.InvalidArgument("organization_ref is required")
|
||||||
|
}
|
||||||
|
state, ok := normalizeAggregateState(in.State)
|
||||||
|
if !ok {
|
||||||
|
return nil, merrors.InvalidArgument("state is invalid")
|
||||||
|
}
|
||||||
|
cursor, err := normalizeCursor(in.Cursor)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out, err = s.list(ctx, listQuery{
|
||||||
|
limit: sanitizeLimit(in.Limit),
|
||||||
|
run: func(limit int64) ([]*paymentDocument, error) {
|
||||||
|
return s.store.ListByState(ctx, in.OrganizationRef, state, cursor, limit)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
type listQuery struct {
|
||||||
|
limit int64
|
||||||
|
run func(limit int64) ([]*paymentDocument, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *svc) list(_ context.Context, query listQuery) (*ListOutput, error) {
|
||||||
|
fetchLimit := query.limit + 1
|
||||||
|
docs, err := query.run(fetchLimit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(docs) == 0 {
|
||||||
|
return &ListOutput{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
nextCursor := (*ListCursor)(nil)
|
||||||
|
if int64(len(docs)) == fetchLimit {
|
||||||
|
docs = docs[:len(docs)-1]
|
||||||
|
last := docs[len(docs)-1]
|
||||||
|
nextCursor = &ListCursor{
|
||||||
|
CreatedAt: last.CreatedAt.UTC(),
|
||||||
|
ID: last.ID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]*agg.Payment, 0, len(docs))
|
||||||
|
for i := range docs {
|
||||||
|
entity, convErr := fromDocument(docs[i])
|
||||||
|
if convErr != nil {
|
||||||
|
return nil, convErr
|
||||||
|
}
|
||||||
|
items = append(items, entity)
|
||||||
|
}
|
||||||
|
return &ListOutput{
|
||||||
|
Items: items,
|
||||||
|
NextCursor: nextCursor,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareCreate(payment *agg.Payment, now time.Time) (*paymentDocument, error) {
|
||||||
|
doc, err := normalizePayment(payment, false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if doc.ID.IsZero() {
|
||||||
|
doc.ID = bson.NewObjectID()
|
||||||
|
}
|
||||||
|
if doc.PaymentRef == "" {
|
||||||
|
doc.PaymentRef = doc.ID.Hex()
|
||||||
|
}
|
||||||
|
if doc.CreatedAt.IsZero() {
|
||||||
|
doc.CreatedAt = now
|
||||||
|
}
|
||||||
|
doc.UpdatedAt = now
|
||||||
|
if doc.Version == 0 {
|
||||||
|
doc.Version = 1
|
||||||
|
}
|
||||||
|
return doc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareUpdate(payment *agg.Payment, expectedVersion uint64, now time.Time) (*paymentDocument, error) {
|
||||||
|
if expectedVersion == 0 {
|
||||||
|
return nil, merrors.InvalidArgument("expected_version is required")
|
||||||
|
}
|
||||||
|
doc, err := normalizePayment(payment, true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if doc.ID.IsZero() {
|
||||||
|
return nil, merrors.InvalidArgument("payment id is required")
|
||||||
|
}
|
||||||
|
if doc.CreatedAt.IsZero() {
|
||||||
|
return nil, merrors.InvalidArgument("payment.created_at is required")
|
||||||
|
}
|
||||||
|
nextVersion := expectedVersion + 1
|
||||||
|
if doc.Version != 0 && doc.Version != expectedVersion && doc.Version != nextVersion {
|
||||||
|
return nil, merrors.InvalidArgument("payment.version must equal expected_version or expected_version + 1")
|
||||||
|
}
|
||||||
|
doc.Version = nextVersion
|
||||||
|
doc.UpdatedAt = now
|
||||||
|
return doc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizePayment(payment *agg.Payment, requirePaymentRef bool) (*paymentDocument, error) {
|
||||||
|
doc, err := toDocument(payment)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if doc == nil {
|
||||||
|
return nil, merrors.InvalidArgument("payment is required")
|
||||||
|
}
|
||||||
|
doc.PaymentRef = strings.TrimSpace(doc.PaymentRef)
|
||||||
|
doc.IdempotencyKey = strings.TrimSpace(doc.IdempotencyKey)
|
||||||
|
doc.QuotationRef = strings.TrimSpace(doc.QuotationRef)
|
||||||
|
doc.ClientPaymentRef = strings.TrimSpace(doc.ClientPaymentRef)
|
||||||
|
|
||||||
|
if doc.OrganizationRef.IsZero() {
|
||||||
|
return nil, merrors.InvalidArgument("organization_ref is required")
|
||||||
|
}
|
||||||
|
if requirePaymentRef && doc.PaymentRef == "" {
|
||||||
|
return nil, merrors.InvalidArgument("payment_ref is required")
|
||||||
|
}
|
||||||
|
if doc.IdempotencyKey == "" {
|
||||||
|
return nil, merrors.InvalidArgument("idempotency_key is required")
|
||||||
|
}
|
||||||
|
if doc.QuotationRef == "" {
|
||||||
|
return nil, merrors.InvalidArgument("quotation_ref is required")
|
||||||
|
}
|
||||||
|
if doc.QuoteSnapshot == nil {
|
||||||
|
return nil, merrors.InvalidArgument("quote_snapshot is required")
|
||||||
|
}
|
||||||
|
state, ok := normalizeAggregateState(doc.State)
|
||||||
|
if !ok {
|
||||||
|
return nil, merrors.InvalidArgument("state is invalid")
|
||||||
|
}
|
||||||
|
doc.State = state
|
||||||
|
|
||||||
|
for i := range doc.StepExecutions {
|
||||||
|
step := &doc.StepExecutions[i]
|
||||||
|
step.StepRef = strings.TrimSpace(step.StepRef)
|
||||||
|
step.StepCode = strings.TrimSpace(step.StepCode)
|
||||||
|
step.FailureCode = strings.TrimSpace(step.FailureCode)
|
||||||
|
step.FailureMsg = strings.TrimSpace(step.FailureMsg)
|
||||||
|
if step.StepRef == "" {
|
||||||
|
return nil, merrors.InvalidArgument("step_executions[" + itoa(i) + "].step_ref is required")
|
||||||
|
}
|
||||||
|
if step.StepCode == "" {
|
||||||
|
step.StepCode = step.StepRef
|
||||||
|
}
|
||||||
|
if step.Attempt == 0 {
|
||||||
|
step.Attempt = 1
|
||||||
|
}
|
||||||
|
ss, ok := normalizeStepState(step.State)
|
||||||
|
if !ok {
|
||||||
|
return nil, merrors.InvalidArgument("step_executions[" + itoa(i) + "].state is invalid")
|
||||||
|
}
|
||||||
|
step.State = ss
|
||||||
|
}
|
||||||
|
return doc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeAggregateState(state agg.State) (agg.State, bool) {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(string(state))) {
|
||||||
|
case string(agg.StateCreated):
|
||||||
|
return agg.StateCreated, true
|
||||||
|
case string(agg.StateExecuting):
|
||||||
|
return agg.StateExecuting, true
|
||||||
|
case string(agg.StateNeedsAttention):
|
||||||
|
return agg.StateNeedsAttention, true
|
||||||
|
case string(agg.StateSettled):
|
||||||
|
return agg.StateSettled, true
|
||||||
|
case string(agg.StateFailed):
|
||||||
|
return agg.StateFailed, true
|
||||||
|
default:
|
||||||
|
return agg.StateUnspecified, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeStepState(state agg.StepState) (agg.StepState, bool) {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(string(state))) {
|
||||||
|
case string(agg.StepStatePending):
|
||||||
|
return agg.StepStatePending, true
|
||||||
|
case string(agg.StepStateRunning):
|
||||||
|
return agg.StepStateRunning, true
|
||||||
|
case string(agg.StepStateCompleted):
|
||||||
|
return agg.StepStateCompleted, true
|
||||||
|
case string(agg.StepStateFailed):
|
||||||
|
return agg.StepStateFailed, true
|
||||||
|
case string(agg.StepStateNeedsAttention):
|
||||||
|
return agg.StepStateNeedsAttention, true
|
||||||
|
case string(agg.StepStateSkipped):
|
||||||
|
return agg.StepStateSkipped, true
|
||||||
|
default:
|
||||||
|
return agg.StepStateUnspecified, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeCursor(cursor *ListCursor) (*listCursor, error) {
|
||||||
|
if cursor == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if cursor.ID.IsZero() {
|
||||||
|
return nil, merrors.InvalidArgument("cursor.id is required")
|
||||||
|
}
|
||||||
|
if cursor.CreatedAt.IsZero() {
|
||||||
|
return nil, merrors.InvalidArgument("cursor.created_at is required")
|
||||||
|
}
|
||||||
|
return &listCursor{
|
||||||
|
CreatedAt: cursor.CreatedAt.UTC(),
|
||||||
|
ID: cursor.ID,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeLimit(limit int32) int64 {
|
||||||
|
if limit <= 0 {
|
||||||
|
return defaultListLimit
|
||||||
|
}
|
||||||
|
if limit > int32(maxListLimit) {
|
||||||
|
return maxListLimit
|
||||||
|
}
|
||||||
|
return int64(limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isDuplicate(err error) bool {
|
||||||
|
return errors.Is(err, ErrDuplicatePayment)
|
||||||
|
}
|
||||||
|
|
||||||
|
func itoa(v int) string {
|
||||||
|
if v == 0 {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
var buf [20]byte
|
||||||
|
i := len(buf)
|
||||||
|
for v > 0 {
|
||||||
|
i--
|
||||||
|
buf[i] = byte('0' + v%10)
|
||||||
|
v /= 10
|
||||||
|
}
|
||||||
|
return string(buf[i:])
|
||||||
|
}
|
||||||
@@ -0,0 +1,479 @@
|
|||||||
|
package prepo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"sort"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||||
|
"github.com/tech/sendico/payments/storage/model"
|
||||||
|
"github.com/tech/sendico/pkg/db/storable"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
pm "github.com/tech/sendico/pkg/model"
|
||||||
|
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewWithStore_EnsuresRequiredIndexes(t *testing.T) {
|
||||||
|
store := newFakeStore()
|
||||||
|
_, err := newWithStore(store)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newWithStore returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(store.indexes) != 4 {
|
||||||
|
t.Fatalf("index count mismatch: got=%d want=4", len(store.indexes))
|
||||||
|
}
|
||||||
|
|
||||||
|
assertIndex(t, store.indexes[0], []string{"organizationRef", "paymentRef"}, true)
|
||||||
|
assertIndex(t, store.indexes[1], []string{"organizationRef", "idempotencyKey"}, true)
|
||||||
|
assertIndex(t, store.indexes[2], []string{"organizationRef", "quotationRef", "createdAt"}, false)
|
||||||
|
assertIndex(t, store.indexes[3], []string{"organizationRef", "state", "createdAt"}, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateAndGet(t *testing.T) {
|
||||||
|
now := time.Date(2026, time.January, 12, 10, 0, 0, 0, time.UTC)
|
||||||
|
store := newFakeStore()
|
||||||
|
repo, err := newWithStore(store)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newWithStore returned error: %v", err)
|
||||||
|
}
|
||||||
|
repo.(*svc).now = func() time.Time { return now }
|
||||||
|
|
||||||
|
org := bson.NewObjectID()
|
||||||
|
payment := &agg.Payment{
|
||||||
|
OrganizationBoundBase: modelOrg(org),
|
||||||
|
IdempotencyKey: "idem-1",
|
||||||
|
QuotationRef: "quote-1",
|
||||||
|
State: agg.StateCreated,
|
||||||
|
IntentSnapshot: model.PaymentIntent{Kind: model.PaymentKindPayout, Amount: testMoney()},
|
||||||
|
QuoteSnapshot: &model.PaymentQuoteSnapshot{QuoteRef: "quote-1"},
|
||||||
|
StepExecutions: []agg.StepExecution{
|
||||||
|
{StepRef: "s1", StepCode: "step-1", State: agg.StepStatePending, Attempt: 1},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := repo.Create(context.Background(), payment); err != nil {
|
||||||
|
t.Fatalf("Create returned error: %v", err)
|
||||||
|
}
|
||||||
|
if payment.ID.IsZero() {
|
||||||
|
t.Fatal("expected generated id")
|
||||||
|
}
|
||||||
|
if payment.PaymentRef == "" {
|
||||||
|
t.Fatal("expected generated payment_ref")
|
||||||
|
}
|
||||||
|
if payment.Version != 1 {
|
||||||
|
t.Fatalf("version mismatch: got=%d want=1", payment.Version)
|
||||||
|
}
|
||||||
|
if !payment.CreatedAt.Equal(now) || !payment.UpdatedAt.Equal(now) {
|
||||||
|
t.Fatalf("timestamps mismatch: created=%v updated=%v", payment.CreatedAt, payment.UpdatedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
gotByRef, err := repo.GetByPaymentRef(context.Background(), org, payment.PaymentRef)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetByPaymentRef returned error: %v", err)
|
||||||
|
}
|
||||||
|
if gotByRef.PaymentRef != payment.PaymentRef {
|
||||||
|
t.Fatalf("payment_ref mismatch: got=%q want=%q", gotByRef.PaymentRef, payment.PaymentRef)
|
||||||
|
}
|
||||||
|
|
||||||
|
gotByIdem, err := repo.GetByIdempotencyKey(context.Background(), org, payment.IdempotencyKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetByIdempotencyKey returned error: %v", err)
|
||||||
|
}
|
||||||
|
if gotByIdem.PaymentRef != payment.PaymentRef {
|
||||||
|
t.Fatalf("idempotency lookup mismatch: got=%q want=%q", gotByIdem.PaymentRef, payment.PaymentRef)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreate_Duplicate(t *testing.T) {
|
||||||
|
store := newFakeStore()
|
||||||
|
repo, err := newWithStore(store)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newWithStore returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
org := bson.NewObjectID()
|
||||||
|
first := newPaymentFixture(org, "idem-1", "quote-1", "pay-1", agg.StateCreated, time.Now())
|
||||||
|
second := newPaymentFixture(org, "idem-1", "quote-1", "pay-2", agg.StateCreated, time.Now())
|
||||||
|
|
||||||
|
if err := repo.Create(context.Background(), first); err != nil {
|
||||||
|
t.Fatalf("Create(first) returned error: %v", err)
|
||||||
|
}
|
||||||
|
err = repo.Create(context.Background(), second)
|
||||||
|
if !errors.Is(err, ErrDuplicatePayment) {
|
||||||
|
t.Fatalf("expected ErrDuplicatePayment, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateCAS(t *testing.T) {
|
||||||
|
now := time.Date(2026, time.January, 12, 10, 0, 0, 0, time.UTC)
|
||||||
|
later := now.Add(2 * time.Minute)
|
||||||
|
|
||||||
|
store := newFakeStore()
|
||||||
|
repoIface, err := newWithStore(store)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newWithStore returned error: %v", err)
|
||||||
|
}
|
||||||
|
repo := repoIface.(*svc)
|
||||||
|
repo.now = func() time.Time { return now }
|
||||||
|
|
||||||
|
org := bson.NewObjectID()
|
||||||
|
payment := newPaymentFixture(org, "idem-1", "quote-1", "pay-1", agg.StateExecuting, now)
|
||||||
|
if err := repo.Create(context.Background(), payment); err != nil {
|
||||||
|
t.Fatalf("Create returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
repo.now = func() time.Time { return later }
|
||||||
|
payment.State = agg.StateNeedsAttention
|
||||||
|
payment.StepExecutions[0].State = agg.StepStateNeedsAttention
|
||||||
|
if err := repo.UpdateCAS(context.Background(), payment, 1); err != nil {
|
||||||
|
t.Fatalf("UpdateCAS returned error: %v", err)
|
||||||
|
}
|
||||||
|
if payment.Version != 2 {
|
||||||
|
t.Fatalf("version mismatch: got=%d want=2", payment.Version)
|
||||||
|
}
|
||||||
|
if !payment.UpdatedAt.Equal(later) {
|
||||||
|
t.Fatalf("updated_at mismatch: got=%v want=%v", payment.UpdatedAt, later)
|
||||||
|
}
|
||||||
|
|
||||||
|
// stale version update
|
||||||
|
payment.State = agg.StateExecuting
|
||||||
|
err = repo.UpdateCAS(context.Background(), payment, 1)
|
||||||
|
if !errors.Is(err, ErrVersionConflict) {
|
||||||
|
t.Fatalf("expected ErrVersionConflict, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// missing payment update
|
||||||
|
missing := newPaymentFixture(org, "idem-x", "quote-x", "pay-x", agg.StateExecuting, now)
|
||||||
|
missing.ID = bson.NewObjectID()
|
||||||
|
missing.Version = 2
|
||||||
|
err = repo.UpdateCAS(context.Background(), missing, 1)
|
||||||
|
if !errors.Is(err, ErrPaymentNotFound) {
|
||||||
|
t.Fatalf("expected ErrPaymentNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListByQuotationRefAndState(t *testing.T) {
|
||||||
|
store := newFakeStore()
|
||||||
|
repo, err := newWithStore(store)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newWithStore returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
org := bson.NewObjectID()
|
||||||
|
base := time.Date(2026, time.January, 12, 10, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
p1 := newPaymentFixture(org, "idem-1", "quote-1", "pay-1", agg.StateExecuting, base.Add(3*time.Minute))
|
||||||
|
p2 := newPaymentFixture(org, "idem-2", "quote-1", "pay-2", agg.StateExecuting, base.Add(2*time.Minute))
|
||||||
|
p3 := newPaymentFixture(org, "idem-3", "quote-1", "pay-3", agg.StateSettled, base.Add(1*time.Minute))
|
||||||
|
p4 := newPaymentFixture(org, "idem-4", "quote-2", "pay-4", agg.StateExecuting, base.Add(4*time.Minute))
|
||||||
|
|
||||||
|
for _, p := range []*agg.Payment{p1, p2, p3, p4} {
|
||||||
|
if err := repo.Create(context.Background(), p); err != nil {
|
||||||
|
t.Fatalf("Create returned error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
page1, err := repo.ListByQuotationRef(context.Background(), ListByQuotationRefInput{
|
||||||
|
OrganizationRef: org,
|
||||||
|
QuotationRef: "quote-1",
|
||||||
|
Limit: 2,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListByQuotationRef(page1) returned error: %v", err)
|
||||||
|
}
|
||||||
|
if len(page1.Items) != 2 {
|
||||||
|
t.Fatalf("page1 size mismatch: got=%d want=2", len(page1.Items))
|
||||||
|
}
|
||||||
|
if page1.Items[0].PaymentRef != p1.PaymentRef || page1.Items[1].PaymentRef != p2.PaymentRef {
|
||||||
|
t.Fatalf("page1 order mismatch: got=%q,%q", page1.Items[0].PaymentRef, page1.Items[1].PaymentRef)
|
||||||
|
}
|
||||||
|
if page1.NextCursor == nil {
|
||||||
|
t.Fatal("expected next cursor")
|
||||||
|
}
|
||||||
|
|
||||||
|
page2, err := repo.ListByQuotationRef(context.Background(), ListByQuotationRefInput{
|
||||||
|
OrganizationRef: org,
|
||||||
|
QuotationRef: "quote-1",
|
||||||
|
Limit: 2,
|
||||||
|
Cursor: page1.NextCursor,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListByQuotationRef(page2) returned error: %v", err)
|
||||||
|
}
|
||||||
|
if len(page2.Items) != 1 || page2.Items[0].PaymentRef != p3.PaymentRef {
|
||||||
|
t.Fatalf("page2 mismatch")
|
||||||
|
}
|
||||||
|
if page2.NextCursor != nil {
|
||||||
|
t.Fatalf("expected nil next cursor")
|
||||||
|
}
|
||||||
|
|
||||||
|
statePage, err := repo.ListByState(context.Background(), ListByStateInput{
|
||||||
|
OrganizationRef: org,
|
||||||
|
State: agg.StateExecuting,
|
||||||
|
Limit: 10,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListByState returned error: %v", err)
|
||||||
|
}
|
||||||
|
if len(statePage.Items) != 3 {
|
||||||
|
t.Fatalf("state page size mismatch: got=%d want=3", len(statePage.Items))
|
||||||
|
}
|
||||||
|
if statePage.Items[0].PaymentRef != p4.PaymentRef {
|
||||||
|
t.Fatalf("state order mismatch: first=%q want=%q", statePage.Items[0].PaymentRef, p4.PaymentRef)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidationErrors(t *testing.T) {
|
||||||
|
store := newFakeStore()
|
||||||
|
repo, err := newWithStore(store)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newWithStore returned error: %v", err)
|
||||||
|
}
|
||||||
|
org := bson.NewObjectID()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
run func() error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "create missing payment",
|
||||||
|
run: func() error {
|
||||||
|
return repo.Create(context.Background(), nil)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "create invalid state",
|
||||||
|
run: func() error {
|
||||||
|
p := newPaymentFixture(org, "idem-1", "quote-1", "pay-1", agg.State("bad"), time.Now())
|
||||||
|
return repo.Create(context.Background(), p)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "update expected version missing",
|
||||||
|
run: func() error {
|
||||||
|
p := newPaymentFixture(org, "idem-1", "quote-1", "pay-1", agg.StateExecuting, time.Now())
|
||||||
|
p.ID = bson.NewObjectID()
|
||||||
|
return repo.UpdateCAS(context.Background(), p, 0)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "list state invalid",
|
||||||
|
run: func() error {
|
||||||
|
_, err := repo.ListByState(context.Background(), ListByStateInput{
|
||||||
|
OrganizationRef: org,
|
||||||
|
State: agg.State("bad"),
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "list cursor invalid",
|
||||||
|
run: func() error {
|
||||||
|
_, err := repo.ListByQuotationRef(context.Background(), ListByQuotationRefInput{
|
||||||
|
OrganizationRef: org,
|
||||||
|
QuotationRef: "quote-1",
|
||||||
|
Cursor: &ListCursor{CreatedAt: time.Now()},
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if err := tt.run(); !errors.Is(err, merrors.ErrInvalidArg) {
|
||||||
|
t.Fatalf("expected invalid argument error, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeStore struct {
|
||||||
|
indexes []*indexDefinition
|
||||||
|
docs map[bson.ObjectID]*paymentDocument
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakeStore() *fakeStore {
|
||||||
|
return &fakeStore{
|
||||||
|
docs: map[bson.ObjectID]*paymentDocument{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) EnsureIndexes(defs []*indexDefinition) error {
|
||||||
|
f.indexes = defs
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) Create(_ context.Context, doc *paymentDocument) error {
|
||||||
|
cloned, err := cloneDocument(doc)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, existing := range f.docs {
|
||||||
|
if existing.OrganizationRef == cloned.OrganizationRef && existing.PaymentRef == cloned.PaymentRef {
|
||||||
|
return ErrDuplicatePayment
|
||||||
|
}
|
||||||
|
if existing.OrganizationRef == cloned.OrganizationRef && existing.IdempotencyKey == cloned.IdempotencyKey {
|
||||||
|
return ErrDuplicatePayment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.docs[cloned.ID] = cloned
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) UpdateCAS(_ context.Context, doc *paymentDocument, expectedVersion uint64) (bool, error) {
|
||||||
|
existing := f.docs[doc.ID]
|
||||||
|
if existing == nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if existing.OrganizationRef != doc.OrganizationRef {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if existing.Version != expectedVersion {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
cloned, err := cloneDocument(doc)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
f.docs[doc.ID] = cloned
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) GetByPaymentRef(_ context.Context, orgRef bson.ObjectID, paymentRef string) (*paymentDocument, error) {
|
||||||
|
for _, doc := range f.docs {
|
||||||
|
if doc.OrganizationRef == orgRef && doc.PaymentRef == paymentRef {
|
||||||
|
return cloneDocument(doc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, ErrPaymentNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) GetByIdempotencyKey(_ context.Context, orgRef bson.ObjectID, idempotencyKey string) (*paymentDocument, error) {
|
||||||
|
for _, doc := range f.docs {
|
||||||
|
if doc.OrganizationRef == orgRef && doc.IdempotencyKey == idempotencyKey {
|
||||||
|
return cloneDocument(doc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, ErrPaymentNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) GetByID(_ context.Context, orgRef bson.ObjectID, id bson.ObjectID) (*paymentDocument, error) {
|
||||||
|
doc := f.docs[id]
|
||||||
|
if doc == nil || doc.OrganizationRef != orgRef {
|
||||||
|
return nil, ErrPaymentNotFound
|
||||||
|
}
|
||||||
|
return cloneDocument(doc)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) ListByQuotationRef(_ context.Context, orgRef bson.ObjectID, quotationRef string, cursor *listCursor, limit int64) ([]*paymentDocument, error) {
|
||||||
|
return f.list(func(doc *paymentDocument) bool {
|
||||||
|
return doc.OrganizationRef == orgRef && doc.QuotationRef == quotationRef
|
||||||
|
}, cursor, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) ListByState(_ context.Context, orgRef bson.ObjectID, state agg.State, cursor *listCursor, limit int64) ([]*paymentDocument, error) {
|
||||||
|
return f.list(func(doc *paymentDocument) bool {
|
||||||
|
return doc.OrganizationRef == orgRef && doc.State == state
|
||||||
|
}, cursor, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) list(match func(*paymentDocument) bool, cursor *listCursor, limit int64) ([]*paymentDocument, error) {
|
||||||
|
items := make([]*paymentDocument, 0)
|
||||||
|
for _, doc := range f.docs {
|
||||||
|
if !match(doc) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if cursor != nil {
|
||||||
|
if !isBeforeCursor(doc, *cursor) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cloned, err := cloneDocument(doc)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, cloned)
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(items, func(i, j int) bool {
|
||||||
|
left := items[i]
|
||||||
|
right := items[j]
|
||||||
|
if !left.CreatedAt.Equal(right.CreatedAt) {
|
||||||
|
return left.CreatedAt.After(right.CreatedAt)
|
||||||
|
}
|
||||||
|
return bytes.Compare(left.ID[:], right.ID[:]) > 0
|
||||||
|
})
|
||||||
|
|
||||||
|
if int64(len(items)) > limit {
|
||||||
|
items = items[:limit]
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isBeforeCursor(doc *paymentDocument, cursor listCursor) bool {
|
||||||
|
if doc.CreatedAt.Before(cursor.CreatedAt) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if doc.CreatedAt.After(cursor.CreatedAt) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return bytes.Compare(doc.ID[:], cursor.ID[:]) < 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertIndex(t *testing.T, def *indexDefinition, fields []string, unique bool) {
|
||||||
|
t.Helper()
|
||||||
|
if def == nil {
|
||||||
|
t.Fatal("expected index definition")
|
||||||
|
}
|
||||||
|
if def.Unique != unique {
|
||||||
|
t.Fatalf("index unique mismatch: got=%v want=%v", def.Unique, unique)
|
||||||
|
}
|
||||||
|
if len(def.Keys) != len(fields) {
|
||||||
|
t.Fatalf("index key count mismatch: got=%d want=%d", len(def.Keys), len(fields))
|
||||||
|
}
|
||||||
|
for i := range fields {
|
||||||
|
if def.Keys[i].Field != fields[i] {
|
||||||
|
t.Fatalf("index key[%d] mismatch: got=%q want=%q", i, def.Keys[i].Field, fields[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPaymentFixture(org bson.ObjectID, idem, quote, paymentRef string, state agg.State, createdAt time.Time) *agg.Payment {
|
||||||
|
return &agg.Payment{
|
||||||
|
Base: modelBase(createdAt),
|
||||||
|
OrganizationBoundBase: modelOrg(org),
|
||||||
|
PaymentRef: paymentRef,
|
||||||
|
IdempotencyKey: idem,
|
||||||
|
QuotationRef: quote,
|
||||||
|
ClientPaymentRef: "client-" + paymentRef,
|
||||||
|
IntentSnapshot: model.PaymentIntent{Kind: model.PaymentKindPayout, Amount: testMoney()},
|
||||||
|
QuoteSnapshot: &model.PaymentQuoteSnapshot{QuoteRef: quote},
|
||||||
|
State: state,
|
||||||
|
Version: 1,
|
||||||
|
StepExecutions: []agg.StepExecution{
|
||||||
|
{StepRef: "s1", StepCode: "step-1", State: agg.StepStatePending, Attempt: 1},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func modelOrg(org bson.ObjectID) pm.OrganizationBoundBase {
|
||||||
|
return pm.OrganizationBoundBase{OrganizationRef: org}
|
||||||
|
}
|
||||||
|
|
||||||
|
func modelBase(createdAt time.Time) storable.Base {
|
||||||
|
return storable.Base{
|
||||||
|
ID: bson.NewObjectID(),
|
||||||
|
CreatedAt: createdAt.UTC(),
|
||||||
|
UpdatedAt: createdAt.UTC(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMoney() *paymenttypes.Money {
|
||||||
|
return &paymenttypes.Money{Amount: "10", Currency: "USDT"}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package prmap
|
||||||
|
|
||||||
|
import "github.com/tech/sendico/pkg/merrors"
|
||||||
|
|
||||||
|
func invalidMissing(field string) error {
|
||||||
|
return merrors.InvalidArgument(field + " is required")
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package prmap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
|
)
|
||||||
|
|
||||||
|
func tsOrNil(value time.Time) *timestamppb.Timestamp {
|
||||||
|
if value.IsZero() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return timestamppb.New(value.UTC())
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmpty(values ...string) string {
|
||||||
|
for _, value := range values {
|
||||||
|
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func itoa(v int) string {
|
||||||
|
if v == 0 {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
var buf [20]byte
|
||||||
|
i := len(buf)
|
||||||
|
for v > 0 {
|
||||||
|
i--
|
||||||
|
buf[i] = byte('0' + v%10)
|
||||||
|
v /= 10
|
||||||
|
}
|
||||||
|
return string(buf[i:])
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
package prmap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/payments/storage/model"
|
||||||
|
pkgmodel "github.com/tech/sendico/pkg/model"
|
||||||
|
paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1"
|
||||||
|
endpointv1 "github.com/tech/sendico/pkg/proto/payments/endpoint/v1"
|
||||||
|
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ledgerMethodData struct {
|
||||||
|
LedgerAccountRef string `bson:"ledgerAccountRef"`
|
||||||
|
ContraLedgerAccountRef string `bson:"contraLedgerAccountRef,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapIntentSnapshot(src model.PaymentIntent) (*quotationv2.QuoteIntent, error) {
|
||||||
|
source, err := mapIntentEndpoint(src.Source, "intent_snapshot.source")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
destination, err := mapIntentEndpoint(src.Destination, "intent_snapshot.destination")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
settlementMode := settlementModeToProto(src.SettlementMode)
|
||||||
|
return "ationv2.QuoteIntent{
|
||||||
|
Source: source,
|
||||||
|
Destination: destination,
|
||||||
|
Amount: moneyToProto(src.Amount),
|
||||||
|
SettlementMode: settlementMode,
|
||||||
|
FeeTreatment: feeTreatmentForSettlementMode(settlementMode),
|
||||||
|
SettlementCurrency: strings.ToUpper(strings.TrimSpace(src.SettlementCurrency)),
|
||||||
|
Comment: strings.TrimSpace(src.Attributes["comment"]),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapIntentEndpoint(src model.PaymentEndpoint, field string) (*endpointv1.PaymentEndpoint, error) {
|
||||||
|
switch src.Type {
|
||||||
|
case model.EndpointTypeManagedWallet:
|
||||||
|
if src.ManagedWallet == nil {
|
||||||
|
return nil, invalidMissing(field + ".managed_wallet")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(src.ManagedWallet.ManagedWalletRef) == "" {
|
||||||
|
return nil, invalidMissing(field + ".managed_wallet.managed_wallet_ref")
|
||||||
|
}
|
||||||
|
return endpointWithMethod(endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_WALLET, walletData(src.ManagedWallet))
|
||||||
|
case model.EndpointTypeExternalChain:
|
||||||
|
if src.ExternalChain == nil {
|
||||||
|
return nil, invalidMissing(field + ".external_chain")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(src.ExternalChain.Address) == "" {
|
||||||
|
return nil, invalidMissing(field + ".external_chain.address")
|
||||||
|
}
|
||||||
|
return endpointWithMethod(endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CRYPTO_ADDRESS, externalChainData(src.ExternalChain))
|
||||||
|
case model.EndpointTypeCard:
|
||||||
|
if src.Card == nil {
|
||||||
|
return nil, invalidMissing(field + ".card")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(src.Card.Token) == "" && strings.TrimSpace(src.Card.Pan) == "" {
|
||||||
|
return nil, invalidMissing(field + ".card.pan_or_token")
|
||||||
|
}
|
||||||
|
return endpointWithCard(src.Card)
|
||||||
|
case model.EndpointTypeLedger:
|
||||||
|
if src.Ledger == nil {
|
||||||
|
return nil, invalidMissing(field + ".ledger")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(src.Ledger.LedgerAccountRef) == "" {
|
||||||
|
return nil, invalidMissing(field + ".ledger.ledger_account_ref")
|
||||||
|
}
|
||||||
|
return endpointWithMethod(endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_LEDGER, ledgerData(src.Ledger))
|
||||||
|
default:
|
||||||
|
return nil, invalidMissing(field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func endpointWithCard(card *model.CardEndpoint) (*endpointv1.PaymentEndpoint, error) {
|
||||||
|
token := strings.TrimSpace(card.Token)
|
||||||
|
if token != "" {
|
||||||
|
return endpointWithMethod(endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CARD_TOKEN, tokenCardData(card))
|
||||||
|
}
|
||||||
|
return endpointWithMethod(endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CARD, rawCardData(card))
|
||||||
|
}
|
||||||
|
|
||||||
|
func endpointWithMethod(
|
||||||
|
methodType endpointv1.PaymentMethodType,
|
||||||
|
methodData any,
|
||||||
|
) (*endpointv1.PaymentEndpoint, error) {
|
||||||
|
data, err := bson.Marshal(methodData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &endpointv1.PaymentEndpoint{
|
||||||
|
Source: &endpointv1.PaymentEndpoint_PaymentMethod{
|
||||||
|
PaymentMethod: &endpointv1.PaymentMethod{
|
||||||
|
Type: methodType,
|
||||||
|
Data: data,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func walletData(src *model.ManagedWalletEndpoint) pkgmodel.WalletPaymentData {
|
||||||
|
walletID := ""
|
||||||
|
if src != nil {
|
||||||
|
walletID = strings.TrimSpace(src.ManagedWalletRef)
|
||||||
|
}
|
||||||
|
return pkgmodel.WalletPaymentData{
|
||||||
|
WalletID: walletID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func externalChainData(src *model.ExternalChainEndpoint) pkgmodel.CryptoAddressPaymentData {
|
||||||
|
currency := pkgmodel.Currency("")
|
||||||
|
network := ""
|
||||||
|
address := ""
|
||||||
|
if src != nil {
|
||||||
|
if src.Asset != nil {
|
||||||
|
currency = pkgmodel.Currency(strings.ToUpper(strings.TrimSpace(src.Asset.TokenSymbol)))
|
||||||
|
network = strings.TrimSpace(src.Asset.Chain)
|
||||||
|
}
|
||||||
|
address = strings.TrimSpace(src.Address)
|
||||||
|
}
|
||||||
|
data := pkgmodel.CryptoAddressPaymentData{
|
||||||
|
Currency: currency,
|
||||||
|
Network: network,
|
||||||
|
Address: address,
|
||||||
|
}
|
||||||
|
if src != nil && strings.TrimSpace(src.Memo) != "" {
|
||||||
|
memo := strings.TrimSpace(src.Memo)
|
||||||
|
data.DestinationTag = &memo
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
func rawCardData(src *model.CardEndpoint) pkgmodel.CardPaymentData {
|
||||||
|
if src == nil {
|
||||||
|
return pkgmodel.CardPaymentData{}
|
||||||
|
}
|
||||||
|
return pkgmodel.CardPaymentData{
|
||||||
|
Pan: strings.TrimSpace(src.Pan),
|
||||||
|
FirstName: strings.TrimSpace(src.Cardholder),
|
||||||
|
LastName: strings.TrimSpace(src.CardholderSurname),
|
||||||
|
ExpMonth: uintToString(src.ExpMonth),
|
||||||
|
ExpYear: uintToString(src.ExpYear),
|
||||||
|
Country: strings.TrimSpace(src.Country),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func tokenCardData(src *model.CardEndpoint) pkgmodel.TokenPaymentData {
|
||||||
|
if src == nil {
|
||||||
|
return pkgmodel.TokenPaymentData{}
|
||||||
|
}
|
||||||
|
return pkgmodel.TokenPaymentData{
|
||||||
|
Token: strings.TrimSpace(src.Token),
|
||||||
|
Last4: strings.TrimSpace(src.MaskedPan),
|
||||||
|
ExpMonth: uintToString(src.ExpMonth),
|
||||||
|
ExpYear: uintToString(src.ExpYear),
|
||||||
|
CardholderName: strings.TrimSpace(src.Cardholder),
|
||||||
|
Country: strings.TrimSpace(src.Country),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ledgerData(src *model.LedgerEndpoint) ledgerMethodData {
|
||||||
|
if src == nil {
|
||||||
|
return ledgerMethodData{}
|
||||||
|
}
|
||||||
|
return ledgerMethodData{
|
||||||
|
LedgerAccountRef: strings.TrimSpace(src.LedgerAccountRef),
|
||||||
|
ContraLedgerAccountRef: strings.TrimSpace(src.ContraLedgerAccountRef),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func settlementModeToProto(mode model.SettlementMode) paymentv1.SettlementMode {
|
||||||
|
switch mode {
|
||||||
|
case model.SettlementModeFixReceived:
|
||||||
|
return paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED
|
||||||
|
case model.SettlementModeFixSource:
|
||||||
|
return paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE
|
||||||
|
default:
|
||||||
|
return paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func feeTreatmentForSettlementMode(mode paymentv1.SettlementMode) quotationv2.FeeTreatment {
|
||||||
|
switch mode {
|
||||||
|
case paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED:
|
||||||
|
return quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION
|
||||||
|
default:
|
||||||
|
return quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func uintToString(value uint32) string {
|
||||||
|
if value == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strconv.FormatUint(uint64(value), 10)
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package prmap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
)
|
||||||
|
|
||||||
|
func validateMapInput(in MapInput) error {
|
||||||
|
if in.Payment == nil {
|
||||||
|
return merrors.InvalidArgument("payment is required")
|
||||||
|
}
|
||||||
|
return validatePaymentInvariants(in.Payment)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validatePaymentInvariants(payment *agg.Payment) error {
|
||||||
|
if payment == nil {
|
||||||
|
return merrors.InvalidArgument("payment is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(payment.PaymentRef) == "" {
|
||||||
|
return merrors.InvalidArgument("payment.payment_ref is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(payment.QuotationRef) == "" {
|
||||||
|
return merrors.InvalidArgument("payment.quotation_ref is required")
|
||||||
|
}
|
||||||
|
if payment.IntentSnapshot.Amount == nil {
|
||||||
|
return merrors.InvalidArgument("payment.intent_snapshot.amount is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(payment.IntentSnapshot.SettlementCurrency) == "" {
|
||||||
|
return merrors.InvalidArgument("payment.intent_snapshot.settlement_currency is required")
|
||||||
|
}
|
||||||
|
if payment.QuoteSnapshot == nil {
|
||||||
|
return merrors.InvalidArgument("payment.quote_snapshot is required")
|
||||||
|
}
|
||||||
|
if payment.QuoteSnapshot.DebitAmount == nil {
|
||||||
|
return merrors.InvalidArgument("payment.quote_snapshot.debit_amount is required")
|
||||||
|
}
|
||||||
|
if _, ok := normalizeAggregateState(payment.State); !ok {
|
||||||
|
return merrors.InvalidArgument("payment.state is invalid")
|
||||||
|
}
|
||||||
|
if payment.Version == 0 {
|
||||||
|
return merrors.InvalidArgument("payment.version is required")
|
||||||
|
}
|
||||||
|
if payment.CreatedAt.IsZero() {
|
||||||
|
return merrors.InvalidArgument("payment.created_at is required")
|
||||||
|
}
|
||||||
|
if payment.UpdatedAt.IsZero() {
|
||||||
|
return merrors.InvalidArgument("payment.updated_at is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range payment.StepExecutions {
|
||||||
|
if err := validateStepInvariants(payment.StepExecutions[i], i); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateStepInvariants(step agg.StepExecution, index int) error {
|
||||||
|
if strings.TrimSpace(step.StepRef) == "" {
|
||||||
|
return merrors.InvalidArgument("payment.step_executions[" + itoa(index) + "].step_ref is required")
|
||||||
|
}
|
||||||
|
if _, ok := normalizeStepState(step.State); !ok {
|
||||||
|
return merrors.InvalidArgument("payment.step_executions[" + itoa(index) + "].state is invalid")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package prmap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Mapper transforms orchestration-v2 runtime aggregate snapshots into API responses.
|
||||||
|
type Mapper interface {
|
||||||
|
Map(in MapInput) (*MapOutput, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MapInput is the mapper payload.
|
||||||
|
type MapInput struct {
|
||||||
|
Payment *agg.Payment
|
||||||
|
}
|
||||||
|
|
||||||
|
// MapOutput is the mapper result.
|
||||||
|
type MapOutput struct {
|
||||||
|
Payment *orchestrationv2.Payment
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dependencies configures payment mapper integrations.
|
||||||
|
type Dependencies struct {
|
||||||
|
Logger mlogger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(deps ...Dependencies) Mapper {
|
||||||
|
var dep Dependencies
|
||||||
|
if len(deps) > 0 {
|
||||||
|
dep = deps[0]
|
||||||
|
}
|
||||||
|
return &svc{logger: dep.Logger.Named("prmap")}
|
||||||
|
}
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
package prmap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/payments/storage/model"
|
||||||
|
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||||
|
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||||
|
accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1"
|
||||||
|
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
||||||
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
|
paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1"
|
||||||
|
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
|
||||||
|
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
|
||||||
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
|
)
|
||||||
|
|
||||||
|
func mapQuoteSnapshot(
|
||||||
|
src *model.PaymentQuoteSnapshot,
|
||||||
|
fallbackQuoteRef string,
|
||||||
|
intentRef string,
|
||||||
|
) *quotationv2.PaymentQuote {
|
||||||
|
if src == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
resolvedSettlementMode := resolvedSettlementModeFromSnapshot(src)
|
||||||
|
fxQuote := fxQuoteToProto(src.FXQuote)
|
||||||
|
|
||||||
|
return "ationv2.PaymentQuote{
|
||||||
|
State: quotationv2.QuoteState_QUOTE_STATE_EXECUTABLE,
|
||||||
|
BlockReason: quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED,
|
||||||
|
TransferPrincipalAmount: moneyToProto(src.DebitAmount),
|
||||||
|
DestinationAmount: moneyToProto(src.ExpectedSettlementAmount),
|
||||||
|
FeeLines: feeLinesToProto(src.FeeLines),
|
||||||
|
FeeRules: feeRulesToProto(src.FeeRules),
|
||||||
|
FxQuote: fxQuote,
|
||||||
|
QuoteRef: firstNonEmpty(src.QuoteRef, fallbackQuoteRef),
|
||||||
|
ExpiresAt: quoteExpiryToProto(fxQuote),
|
||||||
|
PricedAt: quotePricedAtToProto(fxQuote),
|
||||||
|
Route: routeToProto(src.Route),
|
||||||
|
ExecutionConditions: executionConditionsToProto(src.ExecutionConditions),
|
||||||
|
PayerTotalDebitAmount: moneyToProto(src.TotalCost),
|
||||||
|
ResolvedSettlementMode: resolvedSettlementMode,
|
||||||
|
ResolvedFeeTreatment: feeTreatmentForSettlementMode(resolvedSettlementMode),
|
||||||
|
IntentRef: strings.TrimSpace(intentRef),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func moneyToProto(src *paymenttypes.Money) *moneyv1.Money {
|
||||||
|
if src == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &moneyv1.Money{
|
||||||
|
Amount: strings.TrimSpace(src.GetAmount()),
|
||||||
|
Currency: strings.ToUpper(strings.TrimSpace(src.GetCurrency())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fxQuoteToProto(src *paymenttypes.FXQuote) *oraclev1.Quote {
|
||||||
|
if src == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := &oraclev1.Quote{
|
||||||
|
QuoteRef: strings.TrimSpace(src.QuoteRef),
|
||||||
|
Side: fxSideToProto(src.GetSide()),
|
||||||
|
Price: &moneyv1.Decimal{Value: strings.TrimSpace(src.GetPrice().GetValue())},
|
||||||
|
BaseAmount: moneyToProto(src.GetBaseAmount()),
|
||||||
|
QuoteAmount: moneyToProto(src.GetQuoteAmount()),
|
||||||
|
ExpiresAtUnixMs: src.GetExpiresAtUnixMs(),
|
||||||
|
Provider: strings.TrimSpace(src.GetProvider()),
|
||||||
|
RateRef: strings.TrimSpace(src.GetRateRef()),
|
||||||
|
Firm: src.GetFirm(),
|
||||||
|
}
|
||||||
|
if pair := src.GetPair(); pair != nil {
|
||||||
|
out.Pair = &fxv1.CurrencyPair{
|
||||||
|
Base: strings.ToUpper(strings.TrimSpace(pair.GetBase())),
|
||||||
|
Quote: strings.ToUpper(strings.TrimSpace(pair.GetQuote())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if src.GetPricedAtUnixMs() > 0 {
|
||||||
|
out.PricedAt = tsOrNil(time.UnixMilli(src.GetPricedAtUnixMs()))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func routeToProto(src *paymenttypes.QuoteRouteSpecification) *quotationv2.RouteSpecification {
|
||||||
|
if src == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := "ationv2.RouteSpecification{
|
||||||
|
Rail: strings.TrimSpace(src.Rail),
|
||||||
|
Provider: strings.TrimSpace(src.Provider),
|
||||||
|
PayoutMethod: strings.TrimSpace(src.PayoutMethod),
|
||||||
|
Network: strings.TrimSpace(src.Network),
|
||||||
|
RouteRef: strings.TrimSpace(src.RouteRef),
|
||||||
|
PricingProfileRef: strings.TrimSpace(src.PricingProfileRef),
|
||||||
|
Settlement: routeSettlementToProto(src.Settlement),
|
||||||
|
}
|
||||||
|
if len(src.Hops) > 0 {
|
||||||
|
out.Hops = make([]*quotationv2.RouteHop, 0, len(src.Hops))
|
||||||
|
for _, hop := range src.Hops {
|
||||||
|
if hop == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out.Hops = append(out.Hops, "ationv2.RouteHop{
|
||||||
|
Index: hop.Index,
|
||||||
|
Rail: strings.TrimSpace(hop.Rail),
|
||||||
|
Gateway: strings.TrimSpace(hop.Gateway),
|
||||||
|
InstanceId: strings.TrimSpace(hop.InstanceID),
|
||||||
|
Network: strings.TrimSpace(hop.Network),
|
||||||
|
Role: routeHopRoleToProto(hop.Role),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func routeSettlementToProto(src *paymenttypes.QuoteRouteSettlement) *quotationv2.RouteSettlement {
|
||||||
|
if src == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := "ationv2.RouteSettlement{
|
||||||
|
Model: strings.TrimSpace(src.Model),
|
||||||
|
}
|
||||||
|
if src.Asset != nil {
|
||||||
|
out.Asset = &paymentv1.ChainAsset{
|
||||||
|
Key: &paymentv1.ChainAssetKey{
|
||||||
|
Chain: strings.ToUpper(strings.TrimSpace(src.Asset.Chain)),
|
||||||
|
TokenSymbol: strings.ToUpper(strings.TrimSpace(src.Asset.TokenSymbol)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if contract := strings.TrimSpace(src.Asset.ContractAddress); contract != "" {
|
||||||
|
out.Asset.ContractAddress = &contract
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if out.Asset == nil && out.Model == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func executionConditionsToProto(src *paymenttypes.QuoteExecutionConditions) *quotationv2.ExecutionConditions {
|
||||||
|
if src == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return "ationv2.ExecutionConditions{
|
||||||
|
Readiness: readinessToProto(src.Readiness),
|
||||||
|
BatchingEligible: src.BatchingEligible,
|
||||||
|
PrefundingRequired: src.PrefundingRequired,
|
||||||
|
PrefundingCostIncluded: src.PrefundingCostIncluded,
|
||||||
|
LiquidityCheckRequiredAtExecution: src.LiquidityCheckRequiredAtExecution,
|
||||||
|
LatencyHint: strings.TrimSpace(src.LatencyHint),
|
||||||
|
Assumptions: cloneAssumptions(src.Assumptions),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func feeLinesToProto(lines []*paymenttypes.FeeLine) []*feesv1.DerivedPostingLine {
|
||||||
|
if len(lines) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]*feesv1.DerivedPostingLine, 0, len(lines))
|
||||||
|
for _, line := range lines {
|
||||||
|
if line == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, &feesv1.DerivedPostingLine{
|
||||||
|
LedgerAccountRef: strings.TrimSpace(line.GetLedgerAccountRef()),
|
||||||
|
Money: moneyToProto(line.GetMoney()),
|
||||||
|
LineType: postingLineTypeToProto(line.GetLineType()),
|
||||||
|
Side: entrySideToProto(line.GetSide()),
|
||||||
|
Meta: cloneStringMap(line.Meta),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func feeRulesToProto(rules []*paymenttypes.AppliedRule) []*feesv1.AppliedRule {
|
||||||
|
if len(rules) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]*feesv1.AppliedRule, 0, len(rules))
|
||||||
|
for _, rule := range rules {
|
||||||
|
if rule == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, &feesv1.AppliedRule{
|
||||||
|
RuleId: strings.TrimSpace(rule.RuleID),
|
||||||
|
RuleVersion: strings.TrimSpace(rule.RuleVersion),
|
||||||
|
Formula: strings.TrimSpace(rule.Formula),
|
||||||
|
Rounding: roundingModeToProto(rule.Rounding),
|
||||||
|
TaxCode: strings.TrimSpace(rule.TaxCode),
|
||||||
|
TaxRate: strings.TrimSpace(rule.TaxRate),
|
||||||
|
Parameters: cloneStringMap(rule.Parameters),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolvedSettlementModeFromSnapshot(snapshot *model.PaymentQuoteSnapshot) paymentv1.SettlementMode {
|
||||||
|
if snapshot == nil || snapshot.Route == nil || snapshot.Route.Settlement == nil {
|
||||||
|
return paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE
|
||||||
|
}
|
||||||
|
switch strings.ToUpper(strings.TrimSpace(snapshot.Route.Settlement.Model)) {
|
||||||
|
case "FIX_RECEIVED", "SETTLEMENT_FIX_RECEIVED", "SETTLEMENT_MODE_FIX_RECEIVED":
|
||||||
|
return paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED
|
||||||
|
default:
|
||||||
|
return paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func quoteExpiryToProto(src *oraclev1.Quote) *timestamppb.Timestamp {
|
||||||
|
if src == nil || src.GetExpiresAtUnixMs() <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return tsOrNil(time.UnixMilli(src.GetExpiresAtUnixMs()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func quotePricedAtToProto(src *oraclev1.Quote) *timestamppb.Timestamp {
|
||||||
|
if src == nil || src.GetPricedAt() == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return timestamppb.New(src.GetPricedAt().AsTime().UTC())
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneAssumptions(src []string) []string {
|
||||||
|
if len(src) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(src))
|
||||||
|
for _, item := range src {
|
||||||
|
if trimmed := strings.TrimSpace(item); trimmed != "" {
|
||||||
|
out = append(out, trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneStringMap(src map[string]string) map[string]string {
|
||||||
|
if len(src) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make(map[string]string, len(src))
|
||||||
|
for k, v := range src {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func fxSideToProto(side paymenttypes.FXSide) fxv1.Side {
|
||||||
|
switch side {
|
||||||
|
case paymenttypes.FXSideBuyBaseSellQuote:
|
||||||
|
return fxv1.Side_BUY_BASE_SELL_QUOTE
|
||||||
|
case paymenttypes.FXSideSellBaseBuyQuote:
|
||||||
|
return fxv1.Side_SELL_BASE_BUY_QUOTE
|
||||||
|
default:
|
||||||
|
return fxv1.Side_SIDE_UNSPECIFIED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func routeHopRoleToProto(role paymenttypes.QuoteRouteHopRole) quotationv2.RouteHopRole {
|
||||||
|
switch role {
|
||||||
|
case paymenttypes.QuoteRouteHopRoleSource:
|
||||||
|
return quotationv2.RouteHopRole_ROUTE_HOP_ROLE_SOURCE
|
||||||
|
case paymenttypes.QuoteRouteHopRoleTransit:
|
||||||
|
return quotationv2.RouteHopRole_ROUTE_HOP_ROLE_TRANSIT
|
||||||
|
case paymenttypes.QuoteRouteHopRoleDestination:
|
||||||
|
return quotationv2.RouteHopRole_ROUTE_HOP_ROLE_DESTINATION
|
||||||
|
default:
|
||||||
|
return quotationv2.RouteHopRole_ROUTE_HOP_ROLE_UNSPECIFIED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readinessToProto(readiness paymenttypes.QuoteExecutionReadiness) quotationv2.QuoteExecutionReadiness {
|
||||||
|
switch readiness {
|
||||||
|
case paymenttypes.QuoteExecutionReadinessLiquidityReady:
|
||||||
|
return quotationv2.QuoteExecutionReadiness_QUOTE_EXECUTION_READINESS_LIQUIDITY_READY
|
||||||
|
case paymenttypes.QuoteExecutionReadinessLiquidityObtainable:
|
||||||
|
return quotationv2.QuoteExecutionReadiness_QUOTE_EXECUTION_READINESS_LIQUIDITY_OBTAINABLE
|
||||||
|
case paymenttypes.QuoteExecutionReadinessIndicative:
|
||||||
|
return quotationv2.QuoteExecutionReadiness_QUOTE_EXECUTION_READINESS_INDICATIVE
|
||||||
|
default:
|
||||||
|
return quotationv2.QuoteExecutionReadiness_QUOTE_EXECUTION_READINESS_UNSPECIFIED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func entrySideToProto(side paymenttypes.EntrySide) accountingv1.EntrySide {
|
||||||
|
switch side {
|
||||||
|
case paymenttypes.EntrySideDebit:
|
||||||
|
return accountingv1.EntrySide_ENTRY_SIDE_DEBIT
|
||||||
|
case paymenttypes.EntrySideCredit:
|
||||||
|
return accountingv1.EntrySide_ENTRY_SIDE_CREDIT
|
||||||
|
default:
|
||||||
|
return accountingv1.EntrySide_ENTRY_SIDE_UNSPECIFIED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func postingLineTypeToProto(lineType paymenttypes.PostingLineType) accountingv1.PostingLineType {
|
||||||
|
switch lineType {
|
||||||
|
case paymenttypes.PostingLineTypeFee:
|
||||||
|
return accountingv1.PostingLineType_POSTING_LINE_FEE
|
||||||
|
case paymenttypes.PostingLineTypeTax:
|
||||||
|
return accountingv1.PostingLineType_POSTING_LINE_TAX
|
||||||
|
case paymenttypes.PostingLineTypeSpread:
|
||||||
|
return accountingv1.PostingLineType_POSTING_LINE_SPREAD
|
||||||
|
case paymenttypes.PostingLineTypeReversal:
|
||||||
|
return accountingv1.PostingLineType_POSTING_LINE_REVERSAL
|
||||||
|
default:
|
||||||
|
return accountingv1.PostingLineType_POSTING_LINE_TYPE_UNSPECIFIED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func roundingModeToProto(mode paymenttypes.RoundingMode) moneyv1.RoundingMode {
|
||||||
|
switch mode {
|
||||||
|
case paymenttypes.RoundingModeHalfEven:
|
||||||
|
return moneyv1.RoundingMode_ROUND_HALF_EVEN
|
||||||
|
case paymenttypes.RoundingModeHalfUp:
|
||||||
|
return moneyv1.RoundingMode_ROUND_HALF_UP
|
||||||
|
case paymenttypes.RoundingModeDown:
|
||||||
|
return moneyv1.RoundingMode_ROUND_DOWN
|
||||||
|
default:
|
||||||
|
return moneyv1.RoundingMode_ROUNDING_MODE_UNSPECIFIED
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user