payment quotation v2 + payment orchestration v2 draft

This commit is contained in:
Stephan D
2026-02-24 13:01:35 +01:00
parent 0646f55189
commit 6444813f38
289 changed files with 17005 additions and 16065 deletions

View File

@@ -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.)

View File

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

View File

@@ -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=

View File

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

View File

@@ -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=

View File

@@ -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

View File

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

View File

@@ -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=

View File

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

View File

@@ -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=

View File

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

View File

@@ -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

View File

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

View File

@@ -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=

View File

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

View File

@@ -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=

View File

@@ -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,

View File

@@ -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

View File

@@ -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=

View File

@@ -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.

View File

@@ -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() {

View File

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

View File

@@ -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=

View File

@@ -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(),
} }

View File

@@ -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

View File

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

View File

@@ -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=

View File

@@ -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,
} }

View File

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

View File

@@ -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=

View File

@@ -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,

View File

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

View File

@@ -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=

View File

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

View File

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

View File

@@ -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=

View File

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

View File

@@ -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=

View File

@@ -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 {

View File

@@ -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() {

View File

@@ -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 {

View File

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

View File

@@ -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=

View File

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

View File

@@ -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]

View File

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

View File

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

View File

@@ -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()
}, },

View File

@@ -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) {

View File

@@ -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")
)

View File

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

View File

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

View File

@@ -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,
}
}

View File

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

View File

@@ -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"
}
}

View File

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

View File

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

View File

@@ -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 {

View File

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

View File

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

View File

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

View File

@@ -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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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++
}

View File

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

View File

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

View File

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

View File

@@ -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:])
}

View File

@@ -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),
}, ":")
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: {},
}

View File

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

View File

@@ -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")
)

View File

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

View File

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

View File

@@ -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: {},
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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")
)

View File

@@ -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},
},
},
}
}

View File

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

View File

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

View File

@@ -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:])
}

View File

@@ -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"}
}

View File

@@ -0,0 +1,7 @@
package prmap
import "github.com/tech/sendico/pkg/merrors"
func invalidMissing(field string) error {
return merrors.InvalidArgument(field + " is required")
}

View File

@@ -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:])
}

View File

@@ -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 &quotationv2.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)
}

View File

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

View File

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

View File

@@ -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 &quotationv2.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 := &quotationv2.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, &quotationv2.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 := &quotationv2.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 &quotationv2.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