diff --git a/Makefile b/Makefile index 845aaca6..9841257c 100644 --- a/Makefile +++ b/Makefile @@ -85,7 +85,7 @@ init: @echo "$(GREEN)Verifying .env.dev...$(NC)" @cat .env.dev | grep -q "MONGO_USER=" || (echo "$(YELLOW)Error: .env.dev is incomplete$(NC)" && exit 1) @echo "$(GREEN)Running proto generation...$(NC)" - @./generate_protos.sh + @./ci/scripts/proto/generate.sh @echo "$(GREEN)Building Docker images...$(NC)" @$(COMPOSE) build @echo "$(GREEN)✅ Initialization complete!$(NC)" @@ -97,7 +97,7 @@ init: # Build all images build: @echo "$(GREEN)Building all service images...$(NC)" - @./generate_protos.sh + @./ci/scripts/proto/generate.sh @$(COMPOSE) build # Start all services @@ -154,7 +154,7 @@ generate: generate-api generate-frontend # Generate protobuf code generate-api: @echo "$(GREEN)Generating protobuf code...$(NC)" - @./generate_protos.sh + @./ci/scripts/proto/generate.sh @echo "$(GREEN)✅ Protobuf generation complete$(NC)" # Generate Flutter code (json_serializable, etc.) diff --git a/api/billing/documents/go.mod b/api/billing/documents/go.mod index e201776c..18d3f1da 100644 --- a/api/billing/documents/go.mod +++ b/api/billing/documents/go.mod @@ -5,10 +5,10 @@ go 1.25.7 replace github.com/tech/sendico/pkg => ../../pkg require ( - github.com/aws/aws-sdk-go-v2 v1.41.1 - github.com/aws/aws-sdk-go-v2/config v1.32.9 - github.com/aws/aws-sdk-go-v2/credentials v1.19.9 - github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0 + github.com/aws/aws-sdk-go-v2 v1.41.2 + github.com/aws/aws-sdk-go-v2/config v1.32.10 + github.com/aws/aws-sdk-go-v2/credentials v1.19.10 + github.com/aws/aws-sdk-go-v2/service/s3 v1.96.1 github.com/jung-kurt/gofpdf v1.16.2 github.com/prometheus/client_golang v1.23.2 github.com/shopspring/decimal v1.4.0 @@ -20,21 +20,21 @@ require ( ) require ( - github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 // 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.18 // 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.18 // 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/service/internal/accept-encoding v1.13.4 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 // 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/s3shared v1.19.17 // indirect - github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.30.10 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect - github.com/aws/smithy-go v1.24.0 // 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.5 // 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.18 // 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.6 // 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.15 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.41.7 // indirect + github.com/aws/smithy-go v1.24.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bmatcuk/doublestar/v4 v4.10.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/mitchellh/mapstructure v1.5.0 // 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/nuid v1.0.1 // 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/sys v0.41.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 ) diff --git a/api/billing/documents/go.sum b/api/billing/documents/go.sum index 25283cb1..0fd9262f 100644 --- a/api/billing/documents/go.sum +++ b/api/billing/documents/go.sum @@ -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/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= 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.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= -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.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4= -github.com/aws/aws-sdk-go-v2/config v1.32.9 h1:ktda/mtAydeObvJXlHzyGpK1xcsLaP16zfUPDGoW90A= -github.com/aws/aws-sdk-go-v2/config v1.32.9/go.mod h1:U+fCQ+9QKsLW786BCfEjYRj34VVTbPdsLP3CHSYXMOI= -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.9/go.mod h1:+J44MBhmfVY/lETFiKI+klz0Vym2aCmIjqgClMmW82w= -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.17/go.mod h1:tyw7BOl5bBe/oqvoIeECFJjMdzXoa/dfVz3QQ5lgHGA= -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.17/go.mod h1:5M5CI3D12dNOtH3/mk6minaRwI2/37ifCURZISxA/IQ= -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.17/go.mod h1:EhG22vHRrvF8oXSTYStZhJc1aUgKtnJe+aOiFEV90cM= +github.com/aws/aws-sdk-go-v2 v1.41.2 h1:LuT2rzqNQsauaGkPK/7813XxcZ3o3yePY0Iy891T2ls= +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.5 h1:zWFmPmgw4sveAYi1mRqG+E/g0461cJ5M4bJ8/nc6d3Q= +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.10 h1:9DMthfO6XWZYLfzZglAgW5Fyou2nRI5CuV44sTedKBI= +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.10 h1:EEhmEUFCE1Yhl7vDhNOI5OCL/iKMdkkYFTRpZXNw7m8= +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.18 h1:Ii4s+Sq3yDfaMLpjrJsqD6SmG/Wq/P5L/hw2qa78UAY= +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.18 h1:F43zk1vemYIqPAwhjTjYIz0irU2EY7sOb/F5eJ3HuyM= +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.18 h1:xCeWVjj0ki0l3nruoyP2slHsGArMxeiiaoPN5QZH6YQ= +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/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.17/go.mod h1:CO+WeGmIdj/MlPel2KwID9Gt7CNq4M65HUfBW97liM0= -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.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow= -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.8/go.mod h1:FsTpJtvC4U1fyDXk7c71XoDv3HlRm8V3NiYLeYLh5YE= -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.17/go.mod h1:F2xxQ9TZz5gDWsclCtPQscGpP0VUOc8RqgFM3vDENmU= -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.17/go.mod h1:dcW24lbU0CzHusTE8LLHhRLI42ejmINN8Lcr22bwh/g= -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.0/go.mod h1:5jggDlZ2CLQhwJBiZJb4vfk4f0GxWdEDruWKEJ1xOdo= -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.5/go.mod h1:k029+U8SY30/3/ras4G/Fnv/b88N4mAfliNn08Dem4M= -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.10/go.mod h1:yifAsgBxgJWn3ggx70A3urX2AN49Y5sJTD1UQFlfqBw= -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.14/go.mod h1:sTGThjphYE4Ohw8vJiRStAcu3rbjtXRsdNB0TvZ5wwo= -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.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ= -github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= -github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= +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.18/go.mod h1:CCXwUKAJdoWr6/NcxZ+zsiPr6oH/Q5aTooRGYieAyj4= +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.5/go.mod h1:AZLZf2fMaahW5s/wMRciu1sYbdsikT/UHwbUjOdEVTc= +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.9/go.mod h1:Kzm5e6OmNH8VMkgK9t+ry5jEih4Y8whqs+1hrkxim1I= +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.18/go.mod h1:XhwkgGG6bHSd00nO/mexWTcTjgd6PjuvWQMqSn2UaEk= +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.18/go.mod h1:hWe9b4f+djUQGmyiGEeOnZv69dtMSgpDRIvNMvuvzvY= +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.1/go.mod h1:071TH4M3botFLWDbzQLfBR7tXYi7Fs2RsXSiH7nlUlY= +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.6/go.mod h1:hXzcHLARD7GeWnifd8j9RWqtfIgxj4/cAtIVIK7hg8g= +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.11/go.mod h1:0DO9B5EUJQlIDif+XJRWCljZRKsAFKh3gpFz7UnDtOo= +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.15/go.mod h1:lyRQKED9xWfgkYC/wmmYfv7iVIM68Z5OQ88ZdcV1QbU= +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.7/go.mod h1:sks5UWBhEuWYDPdwlnRFn1w7xWdH29Jcpe+/PJQefEs= +github.com/aws/smithy-go v1.24.1 h1:VbyeNfmYkWoxMVpGUAbQumkODcYmfMRfZ8yQiH30SK0= +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/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 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/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/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U= -github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= +github.com/nats-io/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE= +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/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs= 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= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= -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 h1:51Wupg8spF+5FC6D+iMKbOddFjMckETnNnEiZ+HX37s= +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/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/billing/fees/go.mod b/api/billing/fees/go.mod index c76eaffa..5239fa89 100644 --- a/api/billing/fees/go.mod +++ b/api/billing/fees/go.mod @@ -32,7 +32,7 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/mapstructure v1.5.0 // 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/nuid v1.0.1 // indirect github.com/prometheus/client_golang v1.23.2 @@ -50,6 +50,6 @@ require ( golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.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 ) diff --git a/api/billing/fees/go.sum b/api/billing/fees/go.sum index a7714150..d26defcf 100644 --- a/api/billing/fees/go.sum +++ b/api/billing/fees/go.sum @@ -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/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/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U= -github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= +github.com/nats-io/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE= +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/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs= 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= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= -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 h1:51Wupg8spF+5FC6D+iMKbOddFjMckETnNnEiZ+HX37s= +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/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/billing/fees/internal/service/fees/internal/resolver/impl.go b/api/billing/fees/internal/service/fees/internal/resolver/impl.go index 881ecd2d..d490a5f3 100644 --- a/api/billing/fees/internal/service/fees/internal/resolver/impl.go +++ b/api/billing/fees/internal/service/fees/internal/resolver/impl.go @@ -9,6 +9,7 @@ import ( "github.com/tech/sendico/billing/fees/storage" "github.com/tech/sendico/billing/fees/storage/model" "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/mutil/mzap" "go.mongodb.org/mongo-driver/v2/bson" "go.uber.org/zap" @@ -22,10 +23,10 @@ type planFinder interface { type feeResolver struct { plans storage.PlansStore 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 if pf, ok := plans.(planFinder); ok { finder = pf diff --git a/api/discovery/go.mod b/api/discovery/go.mod index 9fd4a53a..92250bfc 100644 --- a/api/discovery/go.mod +++ b/api/discovery/go.mod @@ -25,7 +25,7 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/mapstructure v1.5.0 // 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/nuid v1.0.1 // 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/sys v0.41.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/protobuf v1.36.11 // indirect ) diff --git a/api/discovery/go.sum b/api/discovery/go.sum index a7714150..d26defcf 100644 --- a/api/discovery/go.sum +++ b/api/discovery/go.sum @@ -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/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/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U= -github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= +github.com/nats-io/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE= +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/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs= 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= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= -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 h1:51Wupg8spF+5FC6D+iMKbOddFjMckETnNnEiZ+HX37s= +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/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/fx/ingestor/go.mod b/api/fx/ingestor/go.mod index 1e894f9a..9cf1a260 100644 --- a/api/fx/ingestor/go.mod +++ b/api/fx/ingestor/go.mod @@ -30,7 +30,7 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/mapstructure v1.5.0 // 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/nuid v1.0.1 // 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/sys v0.41.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/protobuf v1.36.11 // indirect ) diff --git a/api/fx/ingestor/go.sum b/api/fx/ingestor/go.sum index a7714150..d26defcf 100644 --- a/api/fx/ingestor/go.sum +++ b/api/fx/ingestor/go.sum @@ -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/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/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U= -github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= +github.com/nats-io/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE= +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/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs= 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= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= -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 h1:51Wupg8spF+5FC6D+iMKbOddFjMckETnNnEiZ+HX37s= +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/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/fx/ingestor/internal/market/cbr/connector.go b/api/fx/ingestor/internal/market/cbr/connector.go index 565164c0..f3d90c70 100644 --- a/api/fx/ingestor/internal/market/cbr/connector.go +++ b/api/fx/ingestor/internal/market/cbr/connector.go @@ -415,7 +415,7 @@ type valuteMapping struct { 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)) byID := make(map[string]valuteInfo, len(items)) byNum := make(map[string]string, len(items)) diff --git a/api/fx/ingestor/internal/market/cbr/http_client.go b/api/fx/ingestor/internal/market/cbr/http_client.go index 1f5deecd..c6878b7e 100644 --- a/api/fx/ingestor/internal/market/cbr/http_client.go +++ b/api/fx/ingestor/internal/market/cbr/http_client.go @@ -5,6 +5,7 @@ import ( "net/http" "strings" + "github.com/tech/sendico/pkg/mlogger" "go.uber.org/zap" ) @@ -17,7 +18,7 @@ const ( type httpClient struct { client *http.Client headers http.Header - logger *zap.Logger + logger mlogger.Logger } type httpClientOptions struct { @@ -26,7 +27,7 @@ type httpClientOptions struct { 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 if strings.TrimSpace(userAgent) == "" { userAgent = defaultUserAgent diff --git a/api/fx/oracle/go.mod b/api/fx/oracle/go.mod index ce9b235e..c61aa700 100644 --- a/api/fx/oracle/go.mod +++ b/api/fx/oracle/go.mod @@ -31,7 +31,7 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/mapstructure v1.5.0 // 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/nuid v1.0.1 // 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/sys v0.41.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 ) diff --git a/api/fx/oracle/go.sum b/api/fx/oracle/go.sum index a7714150..d26defcf 100644 --- a/api/fx/oracle/go.sum +++ b/api/fx/oracle/go.sum @@ -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/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/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U= -github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= +github.com/nats-io/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE= +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/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs= 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= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= -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 h1:51Wupg8spF+5FC6D+iMKbOddFjMckETnNnEiZ+HX37s= +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/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/gateway/chain/go.mod b/api/gateway/chain/go.mod index 6b2dc4b4..e3848599 100644 --- a/api/gateway/chain/go.mod +++ b/api/gateway/chain/go.mod @@ -7,7 +7,7 @@ replace github.com/tech/sendico/pkg => ../../pkg replace github.com/tech/sendico/gateway/common => ../common 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/hashicorp/vault/api v1.22.0 github.com/mitchellh/mapstructure v1.5.0 @@ -61,7 +61,7 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/go-homedir v1.1.0 // 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/nuid v1.0.1 // 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/text v0.34.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 ) diff --git a/api/gateway/chain/go.sum b/api/gateway/chain/go.sum index 18c45618..47cbf45d 100644 --- a/api/gateway/chain/go.sum +++ b/api/gateway/chain/go.sum @@ -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/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/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= -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 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo= +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/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= 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/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/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U= -github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= +github.com/nats-io/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE= +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/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs= 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= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= -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 h1:51Wupg8spF+5FC6D+iMKbOddFjMckETnNnEiZ+HX37s= +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/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/gateway/chain/internal/service/gateway/service.go b/api/gateway/chain/internal/service/gateway/service.go index 9e49112f..ad157adf 100644 --- a/api/gateway/chain/internal/service/gateway/service.go +++ b/api/gateway/chain/internal/service/gateway/service.go @@ -224,8 +224,8 @@ func (s *Service) startDiscoveryAnnouncers() { announce := discovery.Announcement{ ID: discovery.StableCryptoRailGatewayID(string(network.Name)), Service: "CRYPTO_RAIL_GATEWAY", - Rail: "CRYPTO", - Operations: []string{"balance.read", "payin.crypto", "payout.crypto", "fee.send", "observe.confirm"}, + Rail: discovery.RailCrypto, + Operations: discovery.CryptoRailGatewayOperations(), Currencies: currencies, InvokeURI: s.invokeURI, Version: version, diff --git a/api/gateway/common/go.mod b/api/gateway/common/go.mod index 8be7a4a6..de3ff413 100644 --- a/api/gateway/common/go.mod +++ b/api/gateway/common/go.mod @@ -14,7 +14,7 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/klauspost/compress v1.18.4 // 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/nuid v1.0.1 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect diff --git a/api/gateway/common/go.sum b/api/gateway/common/go.sum index 61889025..7b41251b 100644 --- a/api/gateway/common/go.sum +++ b/api/gateway/common/go.sum @@ -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/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= 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.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= +github.com/nats-io/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE= +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/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs= github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= diff --git a/api/gateway/mntx/client/client.go b/api/gateway/mntx/client/client.go index 4837d793..8bf36817 100644 --- a/api/gateway/mntx/client/client.go +++ b/api/gateway/mntx/client/client.go @@ -7,6 +7,7 @@ import ( "github.com/shopspring/decimal" "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/model/account_role" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1" @@ -35,7 +36,7 @@ type gatewayClient struct { conn *grpc.ClientConn client grpcConnectorClient cfg Config - logger *zap.Logger + logger mlogger.Logger } // New dials the Monetix gateway. diff --git a/api/gateway/mntx/client/config.go b/api/gateway/mntx/client/config.go index 0701d8ab..1d2a1103 100644 --- a/api/gateway/mntx/client/config.go +++ b/api/gateway/mntx/client/config.go @@ -3,6 +3,7 @@ package client import ( "time" + "github.com/tech/sendico/pkg/mlogger" "go.uber.org/zap" ) @@ -11,7 +12,7 @@ type Config struct { Address string DialTimeout time.Duration CallTimeout time.Duration - Logger *zap.Logger + Logger mlogger.Logger } func (c *Config) setDefaults() { diff --git a/api/gateway/mntx/go.mod b/api/gateway/mntx/go.mod index d2a80454..9bff5860 100644 --- a/api/gateway/mntx/go.mod +++ b/api/gateway/mntx/go.mod @@ -32,7 +32,7 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/mapstructure v1.5.0 // 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/nuid v1.0.1 // 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/sys v0.41.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 ) diff --git a/api/gateway/mntx/go.sum b/api/gateway/mntx/go.sum index ffcdff38..7ad1510d 100644 --- a/api/gateway/mntx/go.sum +++ b/api/gateway/mntx/go.sum @@ -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/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/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U= -github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= +github.com/nats-io/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE= +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/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs= 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= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= -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 h1:51Wupg8spF+5FC6D+iMKbOddFjMckETnNnEiZ+HX37s= +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/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/gateway/mntx/internal/service/gateway/service.go b/api/gateway/mntx/internal/service/gateway/service.go index 3205e7ed..27abd0ee 100644 --- a/api/gateway/mntx/internal/service/gateway/service.go +++ b/api/gateway/mntx/internal/service/gateway/service.go @@ -152,8 +152,8 @@ func (s *Service) startDiscoveryAnnouncer() { } announce := discovery.Announcement{ Service: "CARD_PAYOUT_RAIL_GATEWAY", - Rail: "CARD_PAYOUT", - Operations: []string{"payout.card", "observe.confirm"}, + Rail: discovery.RailCardPayout, + Operations: discovery.CardPayoutRailGatewayOperations(), InvokeURI: s.invokeURI, Version: appversion.Create().Short(), } diff --git a/api/gateway/mntx/internal/service/monetix/sender.go b/api/gateway/mntx/internal/service/monetix/sender.go index f5682e7a..35f87995 100644 --- a/api/gateway/mntx/internal/service/monetix/sender.go +++ b/api/gateway/mntx/internal/service/monetix/sender.go @@ -10,6 +10,7 @@ import ( "time" "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" "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) { - if logger == nil { - return - } +func logRequestDeadline(logger mlogger.Logger, ctx context.Context, url string) { if ctx == nil { logger.Info("Monetix request context is nil", zap.String("url", url)) return diff --git a/api/gateway/tgsettle/go.mod b/api/gateway/tgsettle/go.mod index f9affff6..ac37d177 100644 --- a/api/gateway/tgsettle/go.mod +++ b/api/gateway/tgsettle/go.mod @@ -30,7 +30,7 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/mapstructure v1.5.0 // 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/nuid v1.0.1 // 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/sys v0.41.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 ) diff --git a/api/gateway/tgsettle/go.sum b/api/gateway/tgsettle/go.sum index a7714150..d26defcf 100644 --- a/api/gateway/tgsettle/go.sum +++ b/api/gateway/tgsettle/go.sum @@ -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/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/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U= -github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= +github.com/nats-io/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE= +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/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs= 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= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= -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 h1:51Wupg8spF+5FC6D+iMKbOddFjMckETnNnEiZ+HX37s= +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/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/gateway/tgsettle/internal/service/gateway/service.go b/api/gateway/tgsettle/internal/service/gateway/service.go index 5df91b26..ba58185f 100644 --- a/api/gateway/tgsettle/internal/service/gateway/service.go +++ b/api/gateway/tgsettle/internal/service/gateway/service.go @@ -526,14 +526,11 @@ func (s *Service) startAnnouncer() { if s == nil || s.producer == nil { return } - caps := []string{"telegram_confirmation", "money_persistence", "observe.confirm", "payout.fiat"} - if s.rail != "" { - caps = append(caps, "confirmations."+strings.ToLower(string(mservice.PaymentGateway))+"."+strings.ToLower(s.rail)) - } + caps := discovery.CardPayoutRailGatewayOperations() announce := discovery.Announcement{ - ID: discovery.StablePaymentGatewayID(s.rail), + ID: discovery.StablePaymentGatewayID(discovery.NormalizeRail(s.rail)), Service: string(mservice.PaymentGateway), - Rail: s.rail, + Rail: discovery.NormalizeRail(s.rail), Operations: caps, InvokeURI: s.invokeURI, } diff --git a/api/gateway/tron/go.mod b/api/gateway/tron/go.mod index 092810f6..859ab524 100644 --- a/api/gateway/tron/go.mod +++ b/api/gateway/tron/go.mod @@ -7,7 +7,7 @@ replace github.com/tech/sendico/pkg => ../../pkg replace github.com/tech/sendico/gateway/common => ../common 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/fbsobreira/gotron-sdk v0.24.1 github.com/hashicorp/vault/api v1.22.0 @@ -65,7 +65,7 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/go-homedir v1.1.0 // 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/nuid v1.0.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/text v0.34.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/rpc 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-20260223185530-2f722ef697dc // indirect ) diff --git a/api/gateway/tron/go.sum b/api/gateway/tron/go.sum index c1136877..7a144168 100644 --- a/api/gateway/tron/go.sum +++ b/api/gateway/tron/go.sum @@ -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/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/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= -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 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo= +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/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= 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/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/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U= -github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= +github.com/nats-io/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE= +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/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs= 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= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d h1:EocjzKLywydp5uZ5tJ79iP6Q0UjDnyiHkGRWxuPBP8s= -google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:48U2I+QQUYhsFrg2SY6r+nJzeOtjey7j//WBESw+qyQ= -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-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/api v0.0.0-20260223185530-2f722ef697dc h1:ULD+ToGXUIU6Pkzr1ARxdyvwfHbelw+agoFDRbLg4TU= +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-20260223185530-2f722ef697dc h1:51Wupg8spF+5FC6D+iMKbOddFjMckETnNnEiZ+HX37s= +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/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/gateway/tron/internal/service/gateway/service.go b/api/gateway/tron/internal/service/gateway/service.go index 35c7b30f..650680cf 100644 --- a/api/gateway/tron/internal/service/gateway/service.go +++ b/api/gateway/tron/internal/service/gateway/service.go @@ -229,8 +229,8 @@ func (s *Service) startDiscoveryAnnouncers() { announce := discovery.Announcement{ ID: discovery.StableCryptoRailGatewayID(network.Name.String()), Service: "CRYPTO_RAIL_GATEWAY", - Rail: "CRYPTO", - Operations: []string{"balance.read", "payin.crypto", "payout.crypto", "fee.send", "observe.confirm"}, + Rail: discovery.RailCrypto, + Operations: discovery.CryptoRailGatewayOperations(), Currencies: currencies, InvokeURI: s.invokeURI, Version: version, diff --git a/api/ledger/go.mod b/api/ledger/go.mod index 2767736a..dc77f0a6 100644 --- a/api/ledger/go.mod +++ b/api/ledger/go.mod @@ -31,7 +31,7 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/mapstructure v1.5.0 // 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/nuid v1.0.1 // 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/sys v0.41.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 ) diff --git a/api/ledger/go.sum b/api/ledger/go.sum index 8025ed84..4c6fc8f0 100644 --- a/api/ledger/go.sum +++ b/api/ledger/go.sum @@ -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/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/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U= -github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= +github.com/nats-io/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE= +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/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs= 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= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= -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 h1:51Wupg8spF+5FC6D+iMKbOddFjMckETnNnEiZ+HX37s= +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/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/ledger/internal/service/ledger/service.go b/api/ledger/internal/service/ledger/service.go index 2773fe29..a46eca22 100644 --- a/api/ledger/internal/service/ledger/service.go +++ b/api/ledger/internal/service/ledger/service.go @@ -365,7 +365,7 @@ func (s *Service) GetJournalEntry(ctx context.Context, req *ledgerv1.GetEntryReq 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 { return } diff --git a/api/notification/go.mod b/api/notification/go.mod index eda25add..de5a8c30 100644 --- a/api/notification/go.mod +++ b/api/notification/go.mod @@ -31,7 +31,7 @@ require ( github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // 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/nuid v1.0.1 // 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/sync v0.19.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/protobuf v1.36.11 // indirect ) diff --git a/api/notification/go.sum b/api/notification/go.sum index 400caa47..2a3943ad 100644 --- a/api/notification/go.sum +++ b/api/notification/go.sum @@ -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/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/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U= -github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= +github.com/nats-io/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE= +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/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs= 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= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= -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 h1:51Wupg8spF+5FC6D+iMKbOddFjMckETnNnEiZ+HX37s= +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/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/payments/methods/go.mod b/api/payments/methods/go.mod index 0be5e3b3..be3b733e 100644 --- a/api/payments/methods/go.mod +++ b/api/payments/methods/go.mod @@ -30,13 +30,14 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/mapstructure v1.5.0 // 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/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/common v0.67.5 // 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/scram v1.2.0 // 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/sys v0.41.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 ) diff --git a/api/payments/methods/go.sum b/api/payments/methods/go.sum index a7714150..4c6fc8f0 100644 --- a/api/payments/methods/go.sum +++ b/api/payments/methods/go.sum @@ -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/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/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U= -github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= +github.com/nats-io/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE= +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/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs= 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/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= 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/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 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= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= -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 h1:51Wupg8spF+5FC6D+iMKbOddFjMckETnNnEiZ+HX37s= +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/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/payments/orchestrator/client/client.go b/api/payments/orchestrator/client/client.go index 86f67755..f0536176 100644 --- a/api/payments/orchestrator/client/client.go +++ b/api/payments/orchestrator/client/client.go @@ -8,8 +8,7 @@ import ( "time" "github.com/tech/sendico/pkg/merrors" - orchestrationv1 "github.com/tech/sendico/pkg/proto/payments/orchestration/v1" - orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestration/v1" + orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2" "google.golang.org/grpc" "google.golang.org/grpc/credentials" "google.golang.org/grpc/credentials/insecure" @@ -17,33 +16,22 @@ import ( // Client exposes typed helpers around the payment orchestration and quotation gRPC APIs. type Client interface { - InitiatePayments(ctx context.Context, req *orchestratorv1.InitiatePaymentsRequest) (*orchestratorv1.InitiatePaymentsResponse, error) - InitiatePayment(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error) - CancelPayment(ctx context.Context, req *orchestratorv1.CancelPaymentRequest) (*orchestratorv1.CancelPaymentResponse, 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) + ExecutePayment(ctx context.Context, req *orchestrationv2.ExecutePaymentRequest) (*orchestrationv2.ExecutePaymentResponse, error) + GetPayment(ctx context.Context, req *orchestrationv2.GetPaymentRequest) (*orchestrationv2.GetPaymentResponse, error) + ListPayments(ctx context.Context, req *orchestrationv2.ListPaymentsRequest) (*orchestrationv2.ListPaymentsResponse, error) Close() error } type grpcOrchestratorClient interface { - InitiatePayments(ctx context.Context, in *orchestratorv1.InitiatePaymentsRequest, opts ...grpc.CallOption) (*orchestratorv1.InitiatePaymentsResponse, error) - InitiatePayment(ctx context.Context, in *orchestratorv1.InitiatePaymentRequest, opts ...grpc.CallOption) (*orchestratorv1.InitiatePaymentResponse, error) - CancelPayment(ctx context.Context, in *orchestratorv1.CancelPaymentRequest, opts ...grpc.CallOption) (*orchestratorv1.CancelPaymentResponse, 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) + ExecutePayment(ctx context.Context, in *orchestrationv2.ExecutePaymentRequest, opts ...grpc.CallOption) (*orchestrationv2.ExecutePaymentResponse, error) + GetPayment(ctx context.Context, in *orchestrationv2.GetPaymentRequest, opts ...grpc.CallOption) (*orchestrationv2.GetPaymentResponse, error) + ListPayments(ctx context.Context, in *orchestrationv2.ListPaymentsRequest, opts ...grpc.CallOption) (*orchestrationv2.ListPaymentsResponse, error) } type orchestratorClient struct { - cfg Config - conn *grpc.ClientConn - quoteConn *grpc.ClientConn - client grpcOrchestratorClient + cfg Config + conn *grpc.ClientConn + client grpcOrchestratorClient } // 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) == "" { 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...) if err != nil { 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{ - cfg: cfg, - conn: conn, - quoteConn: quoteConn, - client: orchestrationv1.NewPaymentExecutionServiceClient(conn), + cfg: cfg, + conn: conn, + client: orchestrationv2.NewPaymentOrchestratorServiceClient(conn), }, 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). 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() return &orchestratorClient{ 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 { - var firstErr error - if c.quoteConn != nil && c.quoteConn != c.conn { - if err := c.quoteConn.Close(); err != nil { - firstErr = err - } + if c == nil || c.conn == nil { + return nil } - if c.conn != nil { - if err := c.conn.Close(); err != nil && firstErr == nil { - firstErr = err - } - } - return firstErr + return c.conn.Close() } -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) 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) { - 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) { +func (c *orchestratorClient) GetPayment(ctx context.Context, req *orchestrationv2.GetPaymentRequest) (*orchestrationv2.GetPaymentResponse, error) { ctx, cancel := c.callContext(ctx) defer cancel() 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) defer cancel() 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) { timeout := c.cfg.CallTimeout if timeout <= 0 { diff --git a/api/payments/orchestrator/client/config.go b/api/payments/orchestrator/client/config.go index d818ccdf..9255d80f 100644 --- a/api/payments/orchestrator/client/config.go +++ b/api/payments/orchestrator/client/config.go @@ -4,11 +4,10 @@ import "time" // Config captures connection settings for the payment orchestrator gRPC service. type Config struct { - Address string - QuoteAddress string - DialTimeout time.Duration - CallTimeout time.Duration - Insecure bool + Address string + DialTimeout time.Duration + CallTimeout time.Duration + Insecure bool } func (c *Config) setDefaults() { diff --git a/api/payments/orchestrator/client/fake.go b/api/payments/orchestrator/client/fake.go index 8f9fb12d..9edde998 100644 --- a/api/payments/orchestrator/client/fake.go +++ b/api/payments/orchestrator/client/fake.go @@ -3,76 +3,36 @@ package client import ( "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. type Fake struct { - InitiatePaymentsFn func(ctx context.Context, req *orchestratorv1.InitiatePaymentsRequest) (*orchestratorv1.InitiatePaymentsResponse, error) - InitiatePaymentFn func(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error) - CancelPaymentFn func(ctx context.Context, req *orchestratorv1.CancelPaymentRequest) (*orchestratorv1.CancelPaymentResponse, error) - GetPaymentFn func(ctx context.Context, req *orchestratorv1.GetPaymentRequest) (*orchestratorv1.GetPaymentResponse, 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 + ExecutePaymentFn func(ctx context.Context, req *orchestrationv2.ExecutePaymentRequest) (*orchestrationv2.ExecutePaymentResponse, error) + GetPaymentFn func(ctx context.Context, req *orchestrationv2.GetPaymentRequest) (*orchestrationv2.GetPaymentResponse, error) + ListPaymentsFn func(ctx context.Context, req *orchestrationv2.ListPaymentsRequest) (*orchestrationv2.ListPaymentsResponse, error) + CloseFn func() error } -func (f *Fake) InitiatePayments(ctx context.Context, req *orchestratorv1.InitiatePaymentsRequest) (*orchestratorv1.InitiatePaymentsResponse, error) { - if f.InitiatePaymentsFn != nil { - return f.InitiatePaymentsFn(ctx, req) +func (f *Fake) ExecutePayment(ctx context.Context, req *orchestrationv2.ExecutePaymentRequest) (*orchestrationv2.ExecutePaymentResponse, error) { + if f.ExecutePaymentFn != nil { + 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) { - 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) { +func (f *Fake) GetPayment(ctx context.Context, req *orchestrationv2.GetPaymentRequest) (*orchestrationv2.GetPaymentResponse, error) { if f.GetPaymentFn != nil { 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 { return f.ListPaymentsFn(ctx, req) } - return &orchestratorv1.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 + return &orchestrationv2.ListPaymentsResponse{}, nil } func (f *Fake) Close() error { diff --git a/api/payments/orchestrator/go.mod b/api/payments/orchestrator/go.mod index 4cc03bef..be14e998 100644 --- a/api/payments/orchestrator/go.mod +++ b/api/payments/orchestrator/go.mod @@ -18,7 +18,6 @@ replace github.com/tech/sendico/payments/storage => ../storage require ( github.com/google/uuid v1.6.0 - github.com/prometheus/client_golang v1.23.2 github.com/shopspring/decimal v1.4.0 github.com/tech/sendico/fx/oracle v0.0.0-00010101000000-000000000000 github.com/tech/sendico/gateway/chain v0.0.0-00010101000000-000000000000 @@ -46,9 +45,10 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/mapstructure v1.5.0 // 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/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/common v0.67.5 // 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/sys v0.41.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 ) diff --git a/api/payments/orchestrator/go.sum b/api/payments/orchestrator/go.sum index cfb95db4..e564a3ee 100644 --- a/api/payments/orchestrator/go.sum +++ b/api/payments/orchestrator/go.sum @@ -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/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/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U= -github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= +github.com/nats-io/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE= +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/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs= 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= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= -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 h1:51Wupg8spF+5FC6D+iMKbOddFjMckETnNnEiZ+HX37s= +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/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/payments/orchestrator/internal/server/internal/dependencies.go b/api/payments/orchestrator/internal/server/internal/dependencies.go index ad919c0c..ba0b905c 100644 --- a/api/payments/orchestrator/internal/server/internal/dependencies.go +++ b/api/payments/orchestrator/internal/server/internal/dependencies.go @@ -6,7 +6,6 @@ import ( ledgerclient "github.com/tech/sendico/ledger/client" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrator" feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" - quotationv1 "github.com/tech/sendico/pkg/proto/payments/quotation/v1" ) type orchestratorDeps struct { @@ -14,7 +13,6 @@ type orchestratorDeps struct { ledgerClient ledgerclient.Client mntxClient mntxclient.Client oracleClient oracleclient.Client - quotationClient quotationv1.QuotationServiceClient gatewayInvokeResolver orchestrator.GatewayInvokeResolver } @@ -32,7 +30,6 @@ func (i *Imp) initDependencies(_ *config) *orchestratorDeps { deps.ledgerClient = &discoveryLedgerClient{resolver: i.discoveryClients} deps.oracleClient = &discoveryOracleClient{resolver: i.discoveryClients} deps.mntxClient = &discoveryMntxClient{resolver: i.discoveryClients} - deps.quotationClient = &discoveryQuotationClient{resolver: i.discoveryClients} deps.gatewayInvokeResolver = discoveryGatewayInvokeResolver{resolver: i.discoveryClients} return deps } @@ -52,9 +49,6 @@ func (i *Imp) buildServiceOptions(cfg *config, deps *orchestratorDeps) []orchest 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())) if deps.gatewayInvokeResolver != nil { opts = append(opts, orchestrator.WithGatewayInvokeResolver(deps.gatewayInvokeResolver)) diff --git a/api/payments/orchestrator/internal/server/internal/discovery_clients.go b/api/payments/orchestrator/internal/server/internal/discovery_clients.go index 0f90ae60..78e88e82 100644 --- a/api/payments/orchestrator/internal/server/internal/discovery_clients.go +++ b/api/payments/orchestrator/internal/server/internal/discovery_clients.go @@ -19,7 +19,6 @@ import ( "github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/mservice" feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" - quotationv1 "github.com/tech/sendico/pkg/proto/payments/quotation/v1" "go.uber.org/zap" "google.golang.org/grpc" "google.golang.org/grpc/credentials" @@ -33,7 +32,6 @@ var ( ledgerServiceNames = []string{"LEDGER", string(mservice.Ledger)} oracleServiceNames = []string{"FX_ORACLE", string(mservice.FXOracle)} mntxServiceNames = []string{"CARD_PAYOUT_RAIL_GATEWAY", string(mservice.MntxGateway)} - quoteServiceNames = []string{"PAYMENT_QUOTATION", "payment_quotation"} ) type discoveryEndpoint struct { @@ -55,9 +53,6 @@ type discoveryClientResolver struct { feesConn *grpc.ClientConn feesEndpoint discoveryEndpoint - quoteConn *grpc.ClientConn - quoteEndpoint discoveryEndpoint - ledgerClient ledgerclient.Client ledgerEndpoint discoveryEndpoint @@ -93,10 +88,6 @@ func (r *discoveryClientResolver) Close() { _ = r.feesConn.Close() r.feesConn = nil } - if r.quoteConn != nil { - _ = r.quoteConn.Close() - r.quoteConn = nil - } if r.ledgerClient != nil { _ = r.ledgerClient.Close() r.ledgerClient = nil @@ -137,11 +128,6 @@ func (r *discoveryClientResolver) MntxAvailable() bool { return ok } -func (r *discoveryClientResolver) QuotationAvailable() bool { - _, ok := r.findEntry("quotation", quoteServiceNames, "", "") - return ok -} - func (r *discoveryClientResolver) FeesClient(ctx context.Context) (feesv1.FeeEngineClient, error) { entry, ok := r.findEntry("fees", feesServiceNames, "", "") if !ok { @@ -173,37 +159,6 @@ func (r *discoveryClientResolver) FeesClient(ctx context.Context) (feesv1.FeeEng 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) { entry, ok := r.findEntry("ledger", ledgerServiceNames, "", "") 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) { - if r.logger == nil { - return - } r.mu.Lock() last := r.lastSelection[key] 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) { - if r.logger == nil { - return - } now := time.Now() r.mu.Lock() last := r.lastMissing[key] diff --git a/api/payments/orchestrator/internal/server/internal/discovery_wrappers.go b/api/payments/orchestrator/internal/server/internal/discovery_wrappers.go index 904b519b..625c1b85 100644 --- a/api/payments/orchestrator/internal/server/internal/discovery_wrappers.go +++ b/api/payments/orchestrator/internal/server/internal/discovery_wrappers.go @@ -13,7 +13,6 @@ import ( chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1" - quotationv1 "github.com/tech/sendico/pkg/proto/payments/quotation/v1" "google.golang.org/grpc" ) @@ -52,33 +51,6 @@ func (c *discoveryFeeClient) ValidateFeeToken(ctx context.Context, req *feesv1.V 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 { resolver *discoveryClientResolver } diff --git a/api/payments/orchestrator/internal/server/internal/serverimp.go b/api/payments/orchestrator/internal/server/internal/serverimp.go index 02916535..6dc93764 100644 --- a/api/payments/orchestrator/internal/server/internal/serverimp.go +++ b/api/payments/orchestrator/internal/server/internal/serverimp.go @@ -63,7 +63,7 @@ func (i *Imp) Start() error { 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 { return err } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/agg/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/agg/module.go index 84928662..4bae38aa 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/agg/module.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/agg/module.go @@ -5,6 +5,7 @@ import ( "github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/pkg/db/storable" + "github.com/tech/sendico/pkg/mlogger" pm "github.com/tech/sendico/pkg/model" "go.mongodb.org/mongo-driver/v2/bson" ) @@ -91,9 +92,19 @@ type Payment struct { 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{ - 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 { return bson.NewObjectID() }, diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/agg/service.go b/api/payments/orchestrator/internal/service/orchestrationv2/agg/service.go index 0a9b8c66..2011cab0 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/agg/service.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/agg/service.go @@ -7,18 +7,45 @@ import ( "github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/pkg/db/storable" "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" pm "github.com/tech/sendico/pkg/model" "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" ) const initialVersion uint64 = 1 type svc struct { - now func() time.Time - newID func() bson.ObjectID + logger mlogger.Logger + 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() { return nil, merrors.InvalidArgument("organization_id is required") } @@ -67,7 +94,7 @@ func (s *svc) Create(in Input) (*Payment, error) { now := s.now().UTC() id := s.newID() - return &Payment{ + payment = &Payment{ Base: storable.Base{ ID: id, CreatedAt: now, @@ -85,7 +112,8 @@ func (s *svc) Create(in Input) (*Payment, error) { State: StateCreated, Version: initialVersion, StepExecutions: stepExecutions, - }, nil + } + return payment, nil } func buildInitialStepTelemetry(shell []StepShell) ([]StepExecution, error) { diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/erecon/errors.go b/api/payments/orchestrator/internal/service/orchestrationv2/erecon/errors.go new file mode 100644 index 00000000..4e595335 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/erecon/errors.go @@ -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") +) diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/erecon/event.go b/api/payments/orchestrator/internal/service/orchestrationv2/erecon/event.go new file mode 100644 index 00000000..9ca22800 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/erecon/event.go @@ -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 +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/erecon/matching.go b/api/payments/orchestrator/internal/service/orchestrationv2/erecon/matching.go new file mode 100644 index 00000000..e5805c46 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/erecon/matching.go @@ -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 +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/erecon/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/erecon/module.go new file mode 100644 index 00000000..06944f35 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/erecon/module.go @@ -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, + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/erecon/reduce.go b/api/payments/orchestrator/internal/service/orchestrationv2/erecon/reduce.go new file mode 100644 index 00000000..c4834eab --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/erecon/reduce.go @@ -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 + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/erecon/service.go b/api/payments/orchestrator/internal/service/orchestrationv2/erecon/service.go new file mode 100644 index 00000000..db8086b1 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/erecon/service.go @@ -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" + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/erecon/service_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/erecon/service_test.go new file mode 100644 index 00000000..f9ba4c26 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/erecon/service_test.go @@ -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 +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/idem/fake_store_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/idem/fake_store_test.go new file mode 100644 index 00000000..85ab0086 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/idem/fake_store_test.go @@ -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) +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/idem/fingerprint.go b/api/payments/orchestrator/internal/service/orchestrationv2/idem/fingerprint.go index a12c76c6..ba7fca35 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/idem/fingerprint.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/idem/fingerprint.go @@ -4,13 +4,31 @@ import ( "crypto/sha256" "encoding/hex" "strings" + "time" "github.com/tech/sendico/pkg/merrors" + "go.uber.org/zap" ) 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)) if orgRef == "" { return "", merrors.InvalidArgument("organization_ref is required") @@ -29,7 +47,8 @@ func (s *svc) Fingerprint(in FPInput) (string, error) { "client=" + clientPaymentRef, }, hashSep) - return hashBytes([]byte(payload)), nil + fingerprint = hashBytes([]byte(payload)) + return fingerprint, nil } func hashBytes(data []byte) string { diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/idem/fingerprint_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/idem/fingerprint_test.go new file mode 100644 index 00000000..842208ac --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/idem/fingerprint_test.go @@ -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") + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/idem/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/idem/module.go index f34057c0..fd924ebb 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/idem/module.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/idem/module.go @@ -4,6 +4,7 @@ import ( "context" "github.com/tech/sendico/payments/storage/model" + "github.com/tech/sendico/pkg/mlogger" "go.mongodb.org/mongo-driver/v2/bson" ) @@ -41,6 +42,15 @@ type CreateInput struct { Reuse ReuseInput } -func New() Service { - return &svc{} +// Dependencies configures idempotency service integrations. +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")} } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/idem/service_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/idem/reuse_test.go similarity index 62% rename from api/payments/orchestrator/internal/service/orchestrationv2/idem/service_test.go rename to api/payments/orchestrator/internal/service/orchestrationv2/idem/reuse_test.go index 0ba589ed..b3d30cd6 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/idem/service_test.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/idem/reuse_test.go @@ -10,101 +10,6 @@ import ( "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) { svc := New() store := &fakeStore{ @@ -294,22 +199,3 @@ func TestCreateOrReuse_DuplicateWithoutReusableRecordReturnsDuplicate(t *testing 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) -} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/idem/service.go b/api/payments/orchestrator/internal/service/orchestrationv2/idem/service.go index da6135a2..8d6b0d1d 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/idem/service.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/idem/service.go @@ -4,21 +4,46 @@ import ( "context" "errors" "strings" + "time" "github.com/tech/sendico/payments/storage" "github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" + "go.uber.org/zap" ) const reqHashMetaKey = "_orchestrator_v2_req_hash" -type svc struct{} +type svc struct { + logger mlogger.Logger +} func (s *svc) TryReuse( ctx context.Context, store Store, 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 { return nil, false, merrors.InvalidArgument("payments store is required") } @@ -28,7 +53,7 @@ func (s *svc) TryReuse( return nil, false, err } - payment, err := store.GetByIdempotencyKey(ctx, in.OrganizationID, idempotencyKey) + payment, err = store.GetByIdempotencyKey(ctx, in.OrganizationID, idempotencyKey) if err != nil { if errors.Is(err, storage.ErrPaymentNotFound) || errors.Is(err, merrors.ErrNoData) { return nil, false, nil @@ -50,7 +75,28 @@ func (s *svc) CreateOrReuse( ctx context.Context, store Store, 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 { return nil, false, merrors.InvalidArgument("payments store is required") } @@ -64,19 +110,19 @@ func (s *svc) CreateOrReuse( } setPaymentReqHash(in.Payment, fingerprint) - if err := store.Create(ctx, in.Payment); err != nil { - if !errors.Is(err, storage.ErrDuplicatePayment) { - return nil, false, err + if createErr := store.Create(ctx, in.Payment); createErr != nil { + if !errors.Is(createErr, storage.ErrDuplicatePayment) { + return nil, false, createErr } - payment, reused, reuseErr := s.TryReuse(ctx, store, in.Reuse) - if reuseErr != nil { - return nil, false, reuseErr + payment, reused, err = s.TryReuse(ctx, store, in.Reuse) + if err != nil { + return nil, false, err } if reused { return payment, true, nil } - return nil, false, err + return nil, false, createErr } return in.Payment, false, nil diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/oobs/audit_store.go b/api/payments/orchestrator/internal/service/orchestrationv2/oobs/audit_store.go new file mode 100644 index 00000000..a88ede5f --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/oobs/audit_store.go @@ -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 +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/oobs/events.go b/api/payments/orchestrator/internal/service/orchestrationv2/oobs/events.go new file mode 100644 index 00000000..13ba6c24 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/oobs/events.go @@ -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 +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/oobs/helpers.go b/api/payments/orchestrator/internal/service/orchestrationv2/oobs/helpers.go new file mode 100644 index 00000000..8ea73510 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/oobs/helpers.go @@ -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 +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/oobs/metrics.go b/api/payments/orchestrator/internal/service/orchestrationv2/oobs/metrics.go new file mode 100644 index 00000000..97015450 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/oobs/metrics.go @@ -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) {} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/oobs/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/oobs/module.go new file mode 100644 index 00000000..9cdcdfde --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/oobs/module.go @@ -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) +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/oobs/service.go b/api/payments/orchestrator/internal/service/orchestrationv2/oobs/service.go new file mode 100644 index 00000000..9a89e9b3 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/oobs/service.go @@ -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") +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/oobs/service_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/oobs/service_test.go new file mode 100644 index 00000000..07dbaf6e --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/oobs/service_test.go @@ -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++ +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/opagg/aggregate_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/opagg/aggregate_test.go new file mode 100644 index 00000000..553a23aa --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/opagg/aggregate_test.go @@ -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) + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/opagg/clone.go b/api/payments/orchestrator/internal/service/orchestrationv2/opagg/clone.go new file mode 100644 index 00000000..dee1ed58 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/opagg/clone.go @@ -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) +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/opagg/fixtures_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/opagg/fixtures_test.go new file mode 100644 index 00000000..25ea0e7a --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/opagg/fixtures_test.go @@ -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() +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/opagg/helpers.go b/api/payments/orchestrator/internal/service/orchestrationv2/opagg/helpers.go new file mode 100644 index 00000000..db09f4c4 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/opagg/helpers.go @@ -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:]) +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/opagg/keying.go b/api/payments/orchestrator/internal/service/orchestrationv2/opagg/keying.go new file mode 100644 index 00000000..aad666aa --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/opagg/keying.go @@ -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), + }, ":") +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/opagg/merge_core.go b/api/payments/orchestrator/internal/service/orchestrationv2/opagg/merge_core.go new file mode 100644 index 00000000..4f5c1621 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/opagg/merge_core.go @@ -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)) +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/opagg/merge_quote_parts.go b/api/payments/orchestrator/internal/service/orchestrationv2/opagg/merge_quote_parts.go new file mode 100644 index 00000000..630a685f --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/opagg/merge_quote_parts.go @@ -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 +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/opagg/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/opagg/module.go new file mode 100644 index 00000000..2e05f5e8 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/opagg/module.go @@ -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")} +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/opagg/service.go b/api/payments/orchestrator/internal/service/orchestrationv2/opagg/service.go new file mode 100644 index 00000000..22264e68 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/opagg/service.go @@ -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 +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/ostate/aggregate_rules.go b/api/payments/orchestrator/internal/service/orchestrationv2/ostate/aggregate_rules.go new file mode 100644 index 00000000..651bafd2 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/ostate/aggregate_rules.go @@ -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: {}, +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/ostate/aggregate_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/ostate/aggregate_test.go new file mode 100644 index 00000000..55a8123c --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/ostate/aggregate_test.go @@ -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) + } + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/ostate/errors.go b/api/payments/orchestrator/internal/service/orchestrationv2/ostate/errors.go new file mode 100644 index 00000000..9b26eee8 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/ostate/errors.go @@ -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") +) diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/ostate/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/ostate/module.go new file mode 100644 index 00000000..07fb7d9f --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/ostate/module.go @@ -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")} +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/ostate/service.go b/api/payments/orchestrator/internal/service/orchestrationv2/ostate/service.go new file mode 100644 index 00000000..51e3d339 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/ostate/service.go @@ -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 +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/ostate/step_rules.go b/api/payments/orchestrator/internal/service/orchestrationv2/ostate/step_rules.go new file mode 100644 index 00000000..cdf5edef --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/ostate/step_rules.go @@ -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: {}, +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/ostate/step_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/ostate/step_test.go new file mode 100644 index 00000000..13979583 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/ostate/step_test.go @@ -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) + } + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/pquery/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/pquery/module.go new file mode 100644 index 00000000..ca3fa78c --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/pquery/module.go @@ -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) +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/pquery/service.go b/api/payments/orchestrator/internal/service/orchestrationv2/pquery/service.go new file mode 100644 index 00000000..246e418d --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/pquery/service.go @@ -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 +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/prepo/document.go b/api/payments/orchestrator/internal/service/orchestrationv2/prepo/document.go new file mode 100644 index 00000000..d669758b --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/prepo/document.go @@ -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 +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/prepo/errors.go b/api/payments/orchestrator/internal/service/orchestrationv2/prepo/errors.go new file mode 100644 index 00000000..0f55b63b --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/prepo/errors.go @@ -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") +) diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/prepo/indexes.go b/api/payments/orchestrator/internal/service/orchestrationv2/prepo/indexes.go new file mode 100644 index 00000000..ad9a4089 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/prepo/indexes.go @@ -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}, + }, + }, + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/prepo/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/prepo/module.go new file mode 100644 index 00000000..ad2d22b5 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/prepo/module.go @@ -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) +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/prepo/mongo_store.go b/api/payments/orchestrator/internal/service/orchestrationv2/prepo/mongo_store.go new file mode 100644 index 00000000..6f8ddefd --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/prepo/mongo_store.go @@ -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) diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/prepo/service.go b/api/payments/orchestrator/internal/service/orchestrationv2/prepo/service.go new file mode 100644 index 00000000..2711368a --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/prepo/service.go @@ -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:]) +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/prepo/service_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/prepo/service_test.go new file mode 100644 index 00000000..f048bd59 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/prepo/service_test.go @@ -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"} +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/prmap/errors.go b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/errors.go new file mode 100644 index 00000000..6d04aac1 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/errors.go @@ -0,0 +1,7 @@ +package prmap + +import "github.com/tech/sendico/pkg/merrors" + +func invalidMissing(field string) error { + return merrors.InvalidArgument(field + " is required") +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/prmap/helpers.go b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/helpers.go new file mode 100644 index 00000000..de92d133 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/helpers.go @@ -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:]) +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/prmap/intent_mapping.go b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/intent_mapping.go new file mode 100644 index 00000000..ad21f400 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/intent_mapping.go @@ -0,0 +1,203 @@ +package prmap + +import ( + "strconv" + "strings" + + "github.com/tech/sendico/payments/storage/model" + pkgmodel "github.com/tech/sendico/pkg/model" + paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1" + endpointv1 "github.com/tech/sendico/pkg/proto/payments/endpoint/v1" + quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" + "go.mongodb.org/mongo-driver/v2/bson" +) + +type ledgerMethodData struct { + LedgerAccountRef string `bson:"ledgerAccountRef"` + ContraLedgerAccountRef string `bson:"contraLedgerAccountRef,omitempty"` +} + +func mapIntentSnapshot(src model.PaymentIntent) (*quotationv2.QuoteIntent, error) { + source, err := mapIntentEndpoint(src.Source, "intent_snapshot.source") + if err != nil { + return nil, err + } + destination, err := mapIntentEndpoint(src.Destination, "intent_snapshot.destination") + if err != nil { + return nil, err + } + + settlementMode := settlementModeToProto(src.SettlementMode) + return "ationv2.QuoteIntent{ + Source: source, + Destination: destination, + Amount: moneyToProto(src.Amount), + SettlementMode: settlementMode, + FeeTreatment: feeTreatmentForSettlementMode(settlementMode), + SettlementCurrency: strings.ToUpper(strings.TrimSpace(src.SettlementCurrency)), + Comment: strings.TrimSpace(src.Attributes["comment"]), + }, nil +} + +func mapIntentEndpoint(src model.PaymentEndpoint, field string) (*endpointv1.PaymentEndpoint, error) { + switch src.Type { + case model.EndpointTypeManagedWallet: + if src.ManagedWallet == nil { + return nil, invalidMissing(field + ".managed_wallet") + } + if strings.TrimSpace(src.ManagedWallet.ManagedWalletRef) == "" { + return nil, invalidMissing(field + ".managed_wallet.managed_wallet_ref") + } + return endpointWithMethod(endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_WALLET, walletData(src.ManagedWallet)) + case model.EndpointTypeExternalChain: + if src.ExternalChain == nil { + return nil, invalidMissing(field + ".external_chain") + } + if strings.TrimSpace(src.ExternalChain.Address) == "" { + return nil, invalidMissing(field + ".external_chain.address") + } + return endpointWithMethod(endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CRYPTO_ADDRESS, externalChainData(src.ExternalChain)) + case model.EndpointTypeCard: + if src.Card == nil { + return nil, invalidMissing(field + ".card") + } + if strings.TrimSpace(src.Card.Token) == "" && strings.TrimSpace(src.Card.Pan) == "" { + return nil, invalidMissing(field + ".card.pan_or_token") + } + return endpointWithCard(src.Card) + case model.EndpointTypeLedger: + if src.Ledger == nil { + return nil, invalidMissing(field + ".ledger") + } + if strings.TrimSpace(src.Ledger.LedgerAccountRef) == "" { + return nil, invalidMissing(field + ".ledger.ledger_account_ref") + } + return endpointWithMethod(endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_LEDGER, ledgerData(src.Ledger)) + default: + return nil, invalidMissing(field) + } +} + +func endpointWithCard(card *model.CardEndpoint) (*endpointv1.PaymentEndpoint, error) { + token := strings.TrimSpace(card.Token) + if token != "" { + return endpointWithMethod(endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CARD_TOKEN, tokenCardData(card)) + } + return endpointWithMethod(endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CARD, rawCardData(card)) +} + +func endpointWithMethod( + methodType endpointv1.PaymentMethodType, + methodData any, +) (*endpointv1.PaymentEndpoint, error) { + data, err := bson.Marshal(methodData) + if err != nil { + return nil, err + } + return &endpointv1.PaymentEndpoint{ + Source: &endpointv1.PaymentEndpoint_PaymentMethod{ + PaymentMethod: &endpointv1.PaymentMethod{ + Type: methodType, + Data: data, + }, + }, + }, nil +} + +func walletData(src *model.ManagedWalletEndpoint) pkgmodel.WalletPaymentData { + walletID := "" + if src != nil { + walletID = strings.TrimSpace(src.ManagedWalletRef) + } + return pkgmodel.WalletPaymentData{ + WalletID: walletID, + } +} + +func externalChainData(src *model.ExternalChainEndpoint) pkgmodel.CryptoAddressPaymentData { + currency := pkgmodel.Currency("") + network := "" + address := "" + if src != nil { + if src.Asset != nil { + currency = pkgmodel.Currency(strings.ToUpper(strings.TrimSpace(src.Asset.TokenSymbol))) + network = strings.TrimSpace(src.Asset.Chain) + } + address = strings.TrimSpace(src.Address) + } + data := pkgmodel.CryptoAddressPaymentData{ + Currency: currency, + Network: network, + Address: address, + } + if src != nil && strings.TrimSpace(src.Memo) != "" { + memo := strings.TrimSpace(src.Memo) + data.DestinationTag = &memo + } + return data +} + +func rawCardData(src *model.CardEndpoint) pkgmodel.CardPaymentData { + if src == nil { + return pkgmodel.CardPaymentData{} + } + return pkgmodel.CardPaymentData{ + Pan: strings.TrimSpace(src.Pan), + FirstName: strings.TrimSpace(src.Cardholder), + LastName: strings.TrimSpace(src.CardholderSurname), + ExpMonth: uintToString(src.ExpMonth), + ExpYear: uintToString(src.ExpYear), + Country: strings.TrimSpace(src.Country), + } +} + +func tokenCardData(src *model.CardEndpoint) pkgmodel.TokenPaymentData { + if src == nil { + return pkgmodel.TokenPaymentData{} + } + return pkgmodel.TokenPaymentData{ + Token: strings.TrimSpace(src.Token), + Last4: strings.TrimSpace(src.MaskedPan), + ExpMonth: uintToString(src.ExpMonth), + ExpYear: uintToString(src.ExpYear), + CardholderName: strings.TrimSpace(src.Cardholder), + Country: strings.TrimSpace(src.Country), + } +} + +func ledgerData(src *model.LedgerEndpoint) ledgerMethodData { + if src == nil { + return ledgerMethodData{} + } + return ledgerMethodData{ + LedgerAccountRef: strings.TrimSpace(src.LedgerAccountRef), + ContraLedgerAccountRef: strings.TrimSpace(src.ContraLedgerAccountRef), + } +} + +func settlementModeToProto(mode model.SettlementMode) paymentv1.SettlementMode { + switch mode { + case model.SettlementModeFixReceived: + return paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED + case model.SettlementModeFixSource: + return paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE + default: + return paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE + } +} + +func feeTreatmentForSettlementMode(mode paymentv1.SettlementMode) quotationv2.FeeTreatment { + switch mode { + case paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED: + return quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION + default: + return quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE + } +} + +func uintToString(value uint32) string { + if value == 0 { + return "" + } + return strconv.FormatUint(uint64(value), 10) +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/prmap/invariants.go b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/invariants.go new file mode 100644 index 00000000..4399b715 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/invariants.go @@ -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 +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/prmap/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/module.go new file mode 100644 index 00000000..7a20c2fe --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/module.go @@ -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")} +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/prmap/quote_mapping.go b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/quote_mapping.go new file mode 100644 index 00000000..ed5aa573 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/quote_mapping.go @@ -0,0 +1,324 @@ +package prmap + +import ( + "strings" + "time" + + "github.com/tech/sendico/payments/storage/model" + paymenttypes "github.com/tech/sendico/pkg/payments/types" + feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" + accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1" + fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1" + oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" + quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" + "google.golang.org/protobuf/types/known/timestamppb" +) + +func mapQuoteSnapshot( + src *model.PaymentQuoteSnapshot, + fallbackQuoteRef string, + intentRef string, +) *quotationv2.PaymentQuote { + if src == nil { + return nil + } + resolvedSettlementMode := resolvedSettlementModeFromSnapshot(src) + fxQuote := fxQuoteToProto(src.FXQuote) + + return "ationv2.PaymentQuote{ + State: quotationv2.QuoteState_QUOTE_STATE_EXECUTABLE, + BlockReason: quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED, + TransferPrincipalAmount: moneyToProto(src.DebitAmount), + DestinationAmount: moneyToProto(src.ExpectedSettlementAmount), + FeeLines: feeLinesToProto(src.FeeLines), + FeeRules: feeRulesToProto(src.FeeRules), + FxQuote: fxQuote, + QuoteRef: firstNonEmpty(src.QuoteRef, fallbackQuoteRef), + ExpiresAt: quoteExpiryToProto(fxQuote), + PricedAt: quotePricedAtToProto(fxQuote), + Route: routeToProto(src.Route), + ExecutionConditions: executionConditionsToProto(src.ExecutionConditions), + PayerTotalDebitAmount: moneyToProto(src.TotalCost), + ResolvedSettlementMode: resolvedSettlementMode, + ResolvedFeeTreatment: feeTreatmentForSettlementMode(resolvedSettlementMode), + IntentRef: strings.TrimSpace(intentRef), + } +} + +func moneyToProto(src *paymenttypes.Money) *moneyv1.Money { + if src == nil { + return nil + } + return &moneyv1.Money{ + Amount: strings.TrimSpace(src.GetAmount()), + Currency: strings.ToUpper(strings.TrimSpace(src.GetCurrency())), + } +} + +func fxQuoteToProto(src *paymenttypes.FXQuote) *oraclev1.Quote { + if src == nil { + return nil + } + out := &oraclev1.Quote{ + QuoteRef: strings.TrimSpace(src.QuoteRef), + Side: fxSideToProto(src.GetSide()), + Price: &moneyv1.Decimal{Value: strings.TrimSpace(src.GetPrice().GetValue())}, + BaseAmount: moneyToProto(src.GetBaseAmount()), + QuoteAmount: moneyToProto(src.GetQuoteAmount()), + ExpiresAtUnixMs: src.GetExpiresAtUnixMs(), + Provider: strings.TrimSpace(src.GetProvider()), + RateRef: strings.TrimSpace(src.GetRateRef()), + Firm: src.GetFirm(), + } + if pair := src.GetPair(); pair != nil { + out.Pair = &fxv1.CurrencyPair{ + Base: strings.ToUpper(strings.TrimSpace(pair.GetBase())), + Quote: strings.ToUpper(strings.TrimSpace(pair.GetQuote())), + } + } + if src.GetPricedAtUnixMs() > 0 { + out.PricedAt = tsOrNil(time.UnixMilli(src.GetPricedAtUnixMs())) + } + return out +} + +func routeToProto(src *paymenttypes.QuoteRouteSpecification) *quotationv2.RouteSpecification { + if src == nil { + return nil + } + out := "ationv2.RouteSpecification{ + Rail: strings.TrimSpace(src.Rail), + Provider: strings.TrimSpace(src.Provider), + PayoutMethod: strings.TrimSpace(src.PayoutMethod), + Network: strings.TrimSpace(src.Network), + RouteRef: strings.TrimSpace(src.RouteRef), + PricingProfileRef: strings.TrimSpace(src.PricingProfileRef), + Settlement: routeSettlementToProto(src.Settlement), + } + if len(src.Hops) > 0 { + out.Hops = make([]*quotationv2.RouteHop, 0, len(src.Hops)) + for _, hop := range src.Hops { + if hop == nil { + continue + } + out.Hops = append(out.Hops, "ationv2.RouteHop{ + Index: hop.Index, + Rail: strings.TrimSpace(hop.Rail), + Gateway: strings.TrimSpace(hop.Gateway), + InstanceId: strings.TrimSpace(hop.InstanceID), + Network: strings.TrimSpace(hop.Network), + Role: routeHopRoleToProto(hop.Role), + }) + } + } + return out +} + +func routeSettlementToProto(src *paymenttypes.QuoteRouteSettlement) *quotationv2.RouteSettlement { + if src == nil { + return nil + } + out := "ationv2.RouteSettlement{ + Model: strings.TrimSpace(src.Model), + } + if src.Asset != nil { + out.Asset = &paymentv1.ChainAsset{ + Key: &paymentv1.ChainAssetKey{ + Chain: strings.ToUpper(strings.TrimSpace(src.Asset.Chain)), + TokenSymbol: strings.ToUpper(strings.TrimSpace(src.Asset.TokenSymbol)), + }, + } + if contract := strings.TrimSpace(src.Asset.ContractAddress); contract != "" { + out.Asset.ContractAddress = &contract + } + } + if out.Asset == nil && out.Model == "" { + return nil + } + return out +} + +func executionConditionsToProto(src *paymenttypes.QuoteExecutionConditions) *quotationv2.ExecutionConditions { + if src == nil { + return nil + } + return "ationv2.ExecutionConditions{ + Readiness: readinessToProto(src.Readiness), + BatchingEligible: src.BatchingEligible, + PrefundingRequired: src.PrefundingRequired, + PrefundingCostIncluded: src.PrefundingCostIncluded, + LiquidityCheckRequiredAtExecution: src.LiquidityCheckRequiredAtExecution, + LatencyHint: strings.TrimSpace(src.LatencyHint), + Assumptions: cloneAssumptions(src.Assumptions), + } +} + +func feeLinesToProto(lines []*paymenttypes.FeeLine) []*feesv1.DerivedPostingLine { + if len(lines) == 0 { + return nil + } + out := make([]*feesv1.DerivedPostingLine, 0, len(lines)) + for _, line := range lines { + if line == nil { + continue + } + out = append(out, &feesv1.DerivedPostingLine{ + LedgerAccountRef: strings.TrimSpace(line.GetLedgerAccountRef()), + Money: moneyToProto(line.GetMoney()), + LineType: postingLineTypeToProto(line.GetLineType()), + Side: entrySideToProto(line.GetSide()), + Meta: cloneStringMap(line.Meta), + }) + } + return out +} + +func feeRulesToProto(rules []*paymenttypes.AppliedRule) []*feesv1.AppliedRule { + if len(rules) == 0 { + return nil + } + out := make([]*feesv1.AppliedRule, 0, len(rules)) + for _, rule := range rules { + if rule == nil { + continue + } + out = append(out, &feesv1.AppliedRule{ + RuleId: strings.TrimSpace(rule.RuleID), + RuleVersion: strings.TrimSpace(rule.RuleVersion), + Formula: strings.TrimSpace(rule.Formula), + Rounding: roundingModeToProto(rule.Rounding), + TaxCode: strings.TrimSpace(rule.TaxCode), + TaxRate: strings.TrimSpace(rule.TaxRate), + Parameters: cloneStringMap(rule.Parameters), + }) + } + return out +} + +func resolvedSettlementModeFromSnapshot(snapshot *model.PaymentQuoteSnapshot) paymentv1.SettlementMode { + if snapshot == nil || snapshot.Route == nil || snapshot.Route.Settlement == nil { + return paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE + } + switch strings.ToUpper(strings.TrimSpace(snapshot.Route.Settlement.Model)) { + case "FIX_RECEIVED", "SETTLEMENT_FIX_RECEIVED", "SETTLEMENT_MODE_FIX_RECEIVED": + return paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED + default: + return paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE + } +} + +func quoteExpiryToProto(src *oraclev1.Quote) *timestamppb.Timestamp { + if src == nil || src.GetExpiresAtUnixMs() <= 0 { + return nil + } + return tsOrNil(time.UnixMilli(src.GetExpiresAtUnixMs())) +} + +func quotePricedAtToProto(src *oraclev1.Quote) *timestamppb.Timestamp { + if src == nil || src.GetPricedAt() == nil { + return nil + } + return timestamppb.New(src.GetPricedAt().AsTime().UTC()) +} + +func cloneAssumptions(src []string) []string { + if len(src) == 0 { + return nil + } + out := make([]string, 0, len(src)) + for _, item := range src { + if trimmed := strings.TrimSpace(item); trimmed != "" { + out = append(out, trimmed) + } + } + return out +} + +func cloneStringMap(src map[string]string) map[string]string { + if len(src) == 0 { + return nil + } + out := make(map[string]string, len(src)) + for k, v := range src { + out[k] = v + } + return out +} + +func fxSideToProto(side paymenttypes.FXSide) fxv1.Side { + switch side { + case paymenttypes.FXSideBuyBaseSellQuote: + return fxv1.Side_BUY_BASE_SELL_QUOTE + case paymenttypes.FXSideSellBaseBuyQuote: + return fxv1.Side_SELL_BASE_BUY_QUOTE + default: + return fxv1.Side_SIDE_UNSPECIFIED + } +} + +func routeHopRoleToProto(role paymenttypes.QuoteRouteHopRole) quotationv2.RouteHopRole { + switch role { + case paymenttypes.QuoteRouteHopRoleSource: + return quotationv2.RouteHopRole_ROUTE_HOP_ROLE_SOURCE + case paymenttypes.QuoteRouteHopRoleTransit: + return quotationv2.RouteHopRole_ROUTE_HOP_ROLE_TRANSIT + case paymenttypes.QuoteRouteHopRoleDestination: + return quotationv2.RouteHopRole_ROUTE_HOP_ROLE_DESTINATION + default: + return quotationv2.RouteHopRole_ROUTE_HOP_ROLE_UNSPECIFIED + } +} + +func readinessToProto(readiness paymenttypes.QuoteExecutionReadiness) quotationv2.QuoteExecutionReadiness { + switch readiness { + case paymenttypes.QuoteExecutionReadinessLiquidityReady: + return quotationv2.QuoteExecutionReadiness_QUOTE_EXECUTION_READINESS_LIQUIDITY_READY + case paymenttypes.QuoteExecutionReadinessLiquidityObtainable: + return quotationv2.QuoteExecutionReadiness_QUOTE_EXECUTION_READINESS_LIQUIDITY_OBTAINABLE + case paymenttypes.QuoteExecutionReadinessIndicative: + return quotationv2.QuoteExecutionReadiness_QUOTE_EXECUTION_READINESS_INDICATIVE + default: + return quotationv2.QuoteExecutionReadiness_QUOTE_EXECUTION_READINESS_UNSPECIFIED + } +} + +func entrySideToProto(side paymenttypes.EntrySide) accountingv1.EntrySide { + switch side { + case paymenttypes.EntrySideDebit: + return accountingv1.EntrySide_ENTRY_SIDE_DEBIT + case paymenttypes.EntrySideCredit: + return accountingv1.EntrySide_ENTRY_SIDE_CREDIT + default: + return accountingv1.EntrySide_ENTRY_SIDE_UNSPECIFIED + } +} + +func postingLineTypeToProto(lineType paymenttypes.PostingLineType) accountingv1.PostingLineType { + switch lineType { + case paymenttypes.PostingLineTypeFee: + return accountingv1.PostingLineType_POSTING_LINE_FEE + case paymenttypes.PostingLineTypeTax: + return accountingv1.PostingLineType_POSTING_LINE_TAX + case paymenttypes.PostingLineTypeSpread: + return accountingv1.PostingLineType_POSTING_LINE_SPREAD + case paymenttypes.PostingLineTypeReversal: + return accountingv1.PostingLineType_POSTING_LINE_REVERSAL + default: + return accountingv1.PostingLineType_POSTING_LINE_TYPE_UNSPECIFIED + } +} + +func roundingModeToProto(mode paymenttypes.RoundingMode) moneyv1.RoundingMode { + switch mode { + case paymenttypes.RoundingModeHalfEven: + return moneyv1.RoundingMode_ROUND_HALF_EVEN + case paymenttypes.RoundingModeHalfUp: + return moneyv1.RoundingMode_ROUND_HALF_UP + case paymenttypes.RoundingModeDown: + return moneyv1.RoundingMode_ROUND_DOWN + default: + return moneyv1.RoundingMode_ROUNDING_MODE_UNSPECIFIED + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/prmap/service.go b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/service.go new file mode 100644 index 00000000..02b32028 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/service.go @@ -0,0 +1,82 @@ +package prmap + +import ( + "strings" + "time" + + "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" + "go.uber.org/zap" +) + +type svc struct { + logger mlogger.Logger +} + +func (s *svc) Map(in MapInput) (out *MapOutput, err error) { + logger := s.logger + paymentRef := "" + if in.Payment != nil { + paymentRef = strings.TrimSpace(in.Payment.PaymentRef) + } + logger.Debug("Starting Map", 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 out != nil && out.Payment != nil { + fields = append(fields, + zap.String("state", out.Payment.GetState().String()), + zap.Uint64("version", out.Payment.GetVersion()), + ) + } + if err != nil { + logger.Warn("Failed to map", append(fields, zap.Error(err))...) + return + } + logger.Debug("Completed Map", fields...) + }(time.Now()) + + if err := validateMapInput(in); err != nil { + return nil, err + } + + protoPayment, err := mapPayment(in.Payment) + if err != nil { + return nil, err + } + out = &MapOutput{Payment: protoPayment} + return out, nil +} + +func mapPayment(src *agg.Payment) (*orchestrationv2.Payment, error) { + if src == nil { + return nil, nil + } + + intentSnapshot, err := mapIntentSnapshot(src.IntentSnapshot) + if err != nil { + return nil, err + } + quoteSnapshot := mapQuoteSnapshot(src.QuoteSnapshot, strings.TrimSpace(src.QuotationRef), strings.TrimSpace(src.IntentSnapshot.Ref)) + steps, err := mapStepExecutions(src.StepExecutions) + if err != nil { + return nil, err + } + + return &orchestrationv2.Payment{ + PaymentRef: strings.TrimSpace(src.PaymentRef), + QuotationRef: strings.TrimSpace(src.QuotationRef), + IntentSnapshot: intentSnapshot, + QuoteSnapshot: quoteSnapshot, + ClientPaymentRef: strings.TrimSpace(src.ClientPaymentRef), + State: mapAggregateState(src.State), + Version: src.Version, + StepExecutions: steps, + CreatedAt: tsOrNil(src.CreatedAt), + UpdatedAt: tsOrNil(src.UpdatedAt), + }, nil +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/prmap/service_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/service_test.go new file mode 100644 index 00000000..2442611a --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/service_test.go @@ -0,0 +1,373 @@ +package prmap + +import ( + "errors" + "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" + pkgmodel "github.com/tech/sendico/pkg/model" + paymenttypes "github.com/tech/sendico/pkg/payments/types" + gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1" + paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1" + endpointv1 "github.com/tech/sendico/pkg/proto/payments/endpoint/v1" + orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2" + quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" + sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func TestMap_Success(t *testing.T) { + mapper := New() + payment := newPaymentFixture() + + out, err := mapper.Map(MapInput{Payment: payment}) + if err != nil { + t.Fatalf("Map returned error: %v", err) + } + if out == nil || out.Payment == nil { + t.Fatalf("expected mapped payment") + } + + protoPayment := out.Payment + if got, want := protoPayment.GetPaymentRef(), payment.PaymentRef; got != want { + t.Fatalf("payment_ref mismatch: got=%q want=%q", got, want) + } + if got, want := protoPayment.GetState(), orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_EXECUTING; got != want { + t.Fatalf("state mismatch: got=%s want=%s", got.String(), want.String()) + } + if got, want := protoPayment.GetCreatedAt().AsTime().UTC(), payment.CreatedAt.UTC(); !got.Equal(want) { + t.Fatalf("created_at mismatch: got=%v want=%v", got, want) + } + if got, want := protoPayment.GetUpdatedAt().AsTime().UTC(), payment.UpdatedAt.UTC(); !got.Equal(want) { + t.Fatalf("updated_at mismatch: got=%v want=%v", got, want) + } + + intent := protoPayment.GetIntentSnapshot() + if intent == nil { + t.Fatalf("expected intent_snapshot") + } + if got, want := intent.GetSettlementMode(), paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE; got != want { + t.Fatalf("settlement_mode mismatch: got=%s want=%s", got.String(), want.String()) + } + if got, want := intent.GetFeeTreatment(), quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE; got != want { + t.Fatalf("fee_treatment mismatch: got=%s want=%s", got.String(), want.String()) + } + if got, want := intent.GetComment(), "invoice-7"; got != want { + t.Fatalf("comment mismatch: got=%q want=%q", got, want) + } + if source := intent.GetSource().GetPaymentMethod(); source == nil { + t.Fatalf("expected source payment_method") + } else { + if got, want := source.GetType(), endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_WALLET; got != want { + t.Fatalf("source method type mismatch: got=%s want=%s", got.String(), want.String()) + } + var wallet pkgmodel.WalletPaymentData + if err := bson.Unmarshal(source.GetData(), &wallet); err != nil { + t.Fatalf("failed to decode wallet data: %v", err) + } + if got, want := wallet.WalletID, "mw-src"; got != want { + t.Fatalf("wallet id mismatch: got=%q want=%q", got, want) + } + } + if destination := intent.GetDestination().GetPaymentMethod(); destination == nil { + t.Fatalf("expected destination payment_method") + } else if got, want := destination.GetType(), endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CARD_TOKEN; got != want { + t.Fatalf("destination method type mismatch: got=%s want=%s", got.String(), want.String()) + } + + quote := protoPayment.GetQuoteSnapshot() + if quote == nil { + t.Fatalf("expected quote_snapshot") + } + if got, want := quote.GetQuoteRef(), payment.QuotationRef; got != want { + t.Fatalf("quote_ref mismatch: got=%q want=%q", got, want) + } + if got, want := quote.GetState(), quotationv2.QuoteState_QUOTE_STATE_EXECUTABLE; got != want { + t.Fatalf("quote state mismatch: got=%s want=%s", got.String(), want.String()) + } + if got, want := quote.GetResolvedSettlementMode(), paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED; got != want { + t.Fatalf("resolved_settlement_mode mismatch: got=%s want=%s", got.String(), want.String()) + } + if got, want := quote.GetResolvedFeeTreatment(), quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION; got != want { + t.Fatalf("resolved_fee_treatment mismatch: got=%s want=%s", got.String(), want.String()) + } + if got, want := quote.GetIntentRef(), payment.IntentSnapshot.Ref; got != want { + t.Fatalf("intent_ref mismatch: got=%q want=%q", got, want) + } + + steps := protoPayment.GetStepExecutions() + if len(steps) != 2 { + t.Fatalf("step count mismatch: got=%d want=2", len(steps)) + } + if got, want := steps[0].GetAttempt(), uint32(1); got != want { + t.Fatalf("attempt normalization mismatch: got=%d want=%d", got, want) + } + if got, want := steps[1].GetFailure().GetCategory(), sharedv1.PaymentFailureCode_FAILURE_BALANCE; got != want { + t.Fatalf("failure category mismatch: got=%s want=%s", got.String(), want.String()) + } + if got, want := steps[1].GetRefs()[0].GetRail(), gatewayv1.Rail_RAIL_LEDGER; got != want { + t.Fatalf("external ref rail mismatch: got=%s want=%s", got.String(), want.String()) + } +} + +func TestMap_InvalidArguments(t *testing.T) { + mapper := New() + now := time.Date(2026, time.January, 11, 12, 0, 0, 0, time.UTC) + + tests := []struct { + name string + payment *agg.Payment + }{ + { + name: "nil payment", + }, + { + name: "missing payment ref", + payment: func() *agg.Payment { + p := newPaymentFixture() + p.PaymentRef = "" + return p + }(), + }, + { + name: "missing quote snapshot", + payment: func() *agg.Payment { + p := newPaymentFixture() + p.QuoteSnapshot = nil + return p + }(), + }, + { + name: "invalid aggregate state", + payment: func() *agg.Payment { + p := newPaymentFixture() + p.State = agg.StateUnspecified + return p + }(), + }, + { + name: "invalid step state", + payment: func() *agg.Payment { + p := newPaymentFixture() + p.StepExecutions[0].State = agg.StepStateUnspecified + return p + }(), + }, + { + name: "missing intent amount", + payment: func() *agg.Payment { + p := newPaymentFixture() + p.IntentSnapshot.Amount = nil + return p + }(), + }, + { + name: "unsupported endpoint type", + payment: func() *agg.Payment { + p := newPaymentFixture() + p.IntentSnapshot.Source.Type = model.EndpointTypeUnspecified + return p + }(), + }, + { + name: "missing timestamps", + payment: func() *agg.Payment { + p := newPaymentFixture() + p.CreatedAt = now + p.UpdatedAt = time.Time{} + return p + }(), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := mapper.Map(MapInput{Payment: tt.payment}) + if !errors.Is(err, merrors.ErrInvalidArg) { + t.Fatalf("expected invalid argument error, got %v", err) + } + }) + } +} + +func newPaymentFixture() *agg.Payment { + createdAt := time.Date(2026, time.January, 11, 12, 0, 0, 0, time.UTC) + startedAt := createdAt.Add(2 * time.Minute) + + return &agg.Payment{ + Base: storable.Base{ + ID: bson.NewObjectID(), + CreatedAt: createdAt, + UpdatedAt: createdAt.Add(5 * time.Minute), + }, + OrganizationBoundBase: pkgmodel.OrganizationBoundBase{ + OrganizationRef: bson.NewObjectID(), + }, + PaymentRef: "pay-1", + IdempotencyKey: "idem-1", + QuotationRef: "quote-1", + ClientPaymentRef: "client-1", + IntentSnapshot: model.PaymentIntent{ + Ref: "intent-1", + Source: model.PaymentEndpoint{ + Type: model.EndpointTypeManagedWallet, + ManagedWallet: &model.ManagedWalletEndpoint{ + ManagedWalletRef: "mw-src", + Asset: &paymenttypes.Asset{ + Chain: "TRON", + TokenSymbol: "USDT", + }, + }, + }, + Destination: model.PaymentEndpoint{ + Type: model.EndpointTypeCard, + Card: &model.CardEndpoint{ + Token: "tok_1", + MaskedPan: "1234", + Cardholder: "John", + ExpMonth: 12, + ExpYear: 2030, + Country: "US", + }, + }, + Amount: &paymenttypes.Money{ + Amount: "100", + Currency: "USDT", + }, + SettlementMode: model.SettlementModeFixSource, + SettlementCurrency: "USD", + Attributes: map[string]string{ + "comment": "invoice-7", + }, + }, + QuoteSnapshot: &model.PaymentQuoteSnapshot{ + DebitAmount: &paymenttypes.Money{ + Amount: "100", + Currency: "USDT", + }, + ExpectedSettlementAmount: &paymenttypes.Money{ + Amount: "95", + Currency: "USD", + }, + TotalCost: &paymenttypes.Money{ + Amount: "101", + Currency: "USDT", + }, + FeeLines: []*paymenttypes.FeeLine{ + { + LedgerAccountRef: "fees:1", + Money: &paymenttypes.Money{ + Amount: "1", + Currency: "USDT", + }, + LineType: paymenttypes.PostingLineTypeFee, + Side: paymenttypes.EntrySideDebit, + Meta: map[string]string{"bucket": "service"}, + }, + }, + FeeRules: []*paymenttypes.AppliedRule{ + { + RuleID: "rule-1", + RuleVersion: "v1", + Formula: "x*0.01", + Rounding: paymenttypes.RoundingModeHalfUp, + TaxCode: "VAT", + TaxRate: "0.10", + Parameters: map[string]string{"jurisdiction": "EU"}, + }, + }, + FXQuote: &paymenttypes.FXQuote{ + QuoteRef: "fx-1", + Pair: &paymenttypes.CurrencyPair{ + Base: "USDT", + Quote: "USD", + }, + Side: paymenttypes.FXSideSellBaseBuyQuote, + Price: &paymenttypes.Decimal{ + Value: "0.95", + }, + BaseAmount: &paymenttypes.Money{ + Amount: "100", + Currency: "USDT", + }, + QuoteAmount: &paymenttypes.Money{ + Amount: "95", + Currency: "USD", + }, + ExpiresAtUnixMs: createdAt.Add(10 * time.Minute).UnixMilli(), + PricedAtUnixMs: createdAt.Add(-1 * time.Minute).UnixMilli(), + Provider: "oracle-1", + RateRef: "rate-1", + Firm: true, + }, + Route: &paymenttypes.QuoteRouteSpecification{ + Rail: "CARD_PAYOUT", + Provider: "provider-1", + PayoutMethod: "CARD", + Network: "VISA", + RouteRef: "route-1", + PricingProfileRef: "pricing-1", + Settlement: &paymenttypes.QuoteRouteSettlement{ + Model: "FIX_RECEIVED", + Asset: &paymenttypes.Asset{ + Chain: "TRON", + TokenSymbol: "USDT", + }, + }, + Hops: []*paymenttypes.QuoteRouteHop{ + { + Index: 10, + Rail: "LEDGER", + Role: paymenttypes.QuoteRouteHopRoleSource, + }, + { + Index: 20, + Rail: "CARD_PAYOUT", + Gateway: "gw-card", + InstanceID: "card-1", + Role: paymenttypes.QuoteRouteHopRoleDestination, + }, + }, + }, + ExecutionConditions: &paymenttypes.QuoteExecutionConditions{ + Readiness: paymenttypes.QuoteExecutionReadinessLiquidityReady, + BatchingEligible: true, + PrefundingRequired: false, + PrefundingCostIncluded: true, + LiquidityCheckRequiredAtExecution: true, + LatencyHint: "fast", + Assumptions: []string{"funds_ready"}, + }, + }, + State: agg.StateExecuting, + Version: 3, + StepExecutions: []agg.StepExecution{ + { + StepRef: "s1", + StepCode: "hop.20.card_payout.send", + State: agg.StepStateRunning, + Attempt: 0, + StartedAt: &startedAt, + }, + { + StepRef: "s2", + StepCode: "edge.10_20.ledger.debit", + State: agg.StepStateFailed, + Attempt: 2, + FailureCode: "ledger_balance_low", + FailureMsg: "insufficient balance", + ExternalRefs: []agg.ExternalRef{ + { + GatewayInstanceID: "ledger-1", + Kind: "ledger_entry_ref", + Ref: "entry-1", + }, + }, + }, + }, + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/prmap/state_mapping.go b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/state_mapping.go new file mode 100644 index 00000000..96cb5ba3 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/state_mapping.go @@ -0,0 +1,120 @@ +package prmap + +import ( + "strings" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1" + orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2" + sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" +) + +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 mapAggregateState(state agg.State) orchestrationv2.OrchestrationState { + switch normalized, _ := normalizeAggregateState(state); normalized { + case agg.StateCreated: + return orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_CREATED + case agg.StateExecuting: + return orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_EXECUTING + case agg.StateNeedsAttention: + return orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_NEEDS_ATTENTION + case agg.StateSettled: + return orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_SETTLED + case agg.StateFailed: + return orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_FAILED + default: + return orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_UNSPECIFIED + } +} + +func mapStepState(state agg.StepState) orchestrationv2.StepExecutionState { + switch normalized, _ := normalizeStepState(state); normalized { + case agg.StepStatePending: + return orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_PENDING + case agg.StepStateRunning: + return orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_RUNNING + case agg.StepStateCompleted: + return orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_COMPLETED + case agg.StepStateFailed: + return orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_FAILED + case agg.StepStateNeedsAttention: + return orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_NEEDS_ATTENTION + case agg.StepStateSkipped: + return orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_SKIPPED + default: + return orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_UNSPECIFIED + } +} + +func inferFailureCategory(failureCode string) sharedv1.PaymentFailureCode { + code := strings.ToLower(strings.TrimSpace(failureCode)) + switch { + case strings.Contains(code, "balance"), strings.Contains(code, "insufficient_funds"): + return sharedv1.PaymentFailureCode_FAILURE_BALANCE + case strings.Contains(code, "ledger"): + return sharedv1.PaymentFailureCode_FAILURE_LEDGER + case strings.Contains(code, "fx"): + return sharedv1.PaymentFailureCode_FAILURE_FX + case strings.Contains(code, "chain"), strings.Contains(code, "crypto"), strings.Contains(code, "provider"), strings.Contains(code, "card"): + return sharedv1.PaymentFailureCode_FAILURE_CHAIN + case strings.Contains(code, "fee"), strings.Contains(code, "charge"): + return sharedv1.PaymentFailureCode_FAILURE_FEES + case strings.Contains(code, "policy"), strings.Contains(code, "risk"), strings.Contains(code, "compliance"): + return sharedv1.PaymentFailureCode_FAILURE_POLICY + default: + return sharedv1.PaymentFailureCode_FAILURE_UNSPECIFIED + } +} + +func inferRail(kind string, stepCode string) gatewayv1.Rail { + all := strings.ToLower(strings.TrimSpace(kind + " " + stepCode)) + switch { + case strings.Contains(all, "ledger"): + return gatewayv1.Rail_RAIL_LEDGER + case strings.Contains(all, "card_payout"), strings.Contains(all, "card"): + return gatewayv1.Rail_RAIL_CARD_PAYOUT + case strings.Contains(all, "provider_settlement"), strings.Contains(all, "provider"): + return gatewayv1.Rail_RAIL_PROVIDER_SETTLEMENT + case strings.Contains(all, "fiat_onramp"), strings.Contains(all, "onramp"): + return gatewayv1.Rail_RAIL_FIAT_ONRAMP + case strings.Contains(all, "crypto"), strings.Contains(all, "chain"), strings.Contains(all, "tx"): + return gatewayv1.Rail_RAIL_CRYPTO + default: + return gatewayv1.Rail_RAIL_UNSPECIFIED + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/prmap/step_mapping.go b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/step_mapping.go new file mode 100644 index 00000000..e9814d05 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/step_mapping.go @@ -0,0 +1,94 @@ +package prmap + +import ( + "strings" + "time" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + "github.com/tech/sendico/pkg/merrors" + orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2" +) + +func mapStepExecutions(src []agg.StepExecution) ([]*orchestrationv2.StepExecution, error) { + if len(src) == 0 { + return nil, nil + } + out := make([]*orchestrationv2.StepExecution, 0, len(src)) + for i := range src { + mapped, err := mapStepExecution(src[i], i) + if err != nil { + return nil, err + } + out = append(out, mapped) + } + return out, nil +} + +func mapStepExecution(step agg.StepExecution, index int) (*orchestrationv2.StepExecution, error) { + state, ok := normalizeStepState(step.State) + if !ok { + return nil, merrors.InvalidArgument("payment.step_executions[" + itoa(index) + "].state is invalid") + } + + attempt := step.Attempt + if attempt == 0 { + attempt = 1 + } + + return &orchestrationv2.StepExecution{ + StepRef: strings.TrimSpace(step.StepRef), + StepCode: strings.TrimSpace(step.StepCode), + State: mapStepState(state), + Attempt: attempt, + StartedAt: tsOrNil(derefTime(step.StartedAt)), + CompletedAt: tsOrNil(derefTime(step.CompletedAt)), + Failure: mapStepFailure(step, state), + Refs: mapExternalRefs(step.StepCode, step.ExternalRefs), + }, nil +} + +func mapStepFailure(step agg.StepExecution, state agg.StepState) *orchestrationv2.Failure { + if state != agg.StepStateFailed && state != agg.StepStateNeedsAttention { + return nil + } + code := strings.TrimSpace(step.FailureCode) + msg := strings.TrimSpace(step.FailureMsg) + if code == "" && msg == "" { + return nil + } + return &orchestrationv2.Failure{ + Category: inferFailureCategory(code), + Code: code, + Message: msg, + } +} + +func mapExternalRefs(stepCode string, refs []agg.ExternalRef) []*orchestrationv2.ExternalReference { + if len(refs) == 0 { + return nil + } + out := make([]*orchestrationv2.ExternalReference, 0, len(refs)) + for i := range refs { + ref := refs[i] + kind := strings.TrimSpace(ref.Kind) + value := strings.TrimSpace(ref.Ref) + gatewayInstanceID := strings.TrimSpace(ref.GatewayInstanceID) + if kind == "" && value == "" && gatewayInstanceID == "" { + continue + } + out = append(out, &orchestrationv2.ExternalReference{ + Rail: inferRail(kind, stepCode), + GatewayInstanceId: gatewayInstanceID, + Kind: kind, + Ref: value, + }) + } + return out +} + +func derefTime(value *time.Time) time.Time { + if value == nil { + return time.Time{} + } + return value.UTC() +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/aggregate_state.go b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/aggregate_state.go new file mode 100644 index 00000000..1654b174 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/aggregate_state.go @@ -0,0 +1,132 @@ +package psvc + +import ( + "context" + "strings" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/oobs" + "go.uber.org/zap" +) + +func (s *svc) recomputeAggregateState(ctx context.Context, payment *agg.Payment) (bool, error) { + logger := s.logger + if payment == nil { + return false, nil + } + current := payment.State + target := s.deriveAggregateTarget(payment) + next, changed, err := s.transitionAggregateState(current, target) + if err != nil { + return false, err + } + if !changed { + return false, nil + } + payment.State = next + logger.Debug("psvc.payment_state_changed", + zap.String("payment_ref", strings.TrimSpace(payment.PaymentRef)), + zap.String("from_state", string(current)), + zap.String("to_state", string(next)), + zap.Uint64("version", payment.Version), + ) + if next == agg.StateSettled || next == agg.StateNeedsAttention || next == agg.StateFailed { + logger.Debug("psvc.payment_finalization_state", + zap.String("payment_ref", strings.TrimSpace(payment.PaymentRef)), + zap.String("state", string(next)), + zap.Uint64("version", payment.Version), + ) + } + return true, s.observer.RecordPayment(ctx, oobs.RecordPaymentInput{ + Payment: payment, + Event: paymentEventForState(next), + }) +} + +func (s *svc) deriveAggregateTarget(payment *agg.Payment) agg.State { + if payment == nil { + return agg.StateUnspecified + } + if s.state.IsAggregateTerminal(payment.State) { + return payment.State + } + if len(payment.StepExecutions) == 0 { + return agg.StateCreated + } + + allDone := true + for i := range payment.StepExecutions { + step := payment.StepExecutions[i] + switch step.State { + case agg.StepStateNeedsAttention: + return agg.StateNeedsAttention + case agg.StepStateFailed: + if step.Attempt >= s.maxAttemptsForStep(step.StepRef) { + return agg.StateNeedsAttention + } + allDone = false + case agg.StepStateCompleted, agg.StepStateSkipped: + default: + allDone = false + } + } + if allDone { + return agg.StateSettled + } + return agg.StateExecuting +} + +func (s *svc) transitionAggregateState(current, target agg.State) (agg.State, bool, error) { + if current == target { + return current, false, nil + } + if s.state.IsAggregateTerminal(current) { + return current, false, nil + } + if err := s.state.EnsureAggregateTransition(current, target); err == nil { + return target, true, nil + } + if current == agg.StateCreated { + if err := s.state.EnsureAggregateTransition(current, agg.StateExecuting); err == nil { + current = agg.StateExecuting + } + } + if current == agg.StateNeedsAttention && target == agg.StateCreated { + if err := s.state.EnsureAggregateTransition(current, agg.StateExecuting); err == nil { + current = agg.StateExecuting + } + } + if current == target { + return current, true, nil + } + if err := s.state.EnsureAggregateTransition(current, target); err != nil { + return current, false, nil + } + return target, true, nil +} + +func paymentEventForState(state agg.State) oobs.PaymentEvent { + switch state { + case agg.StateNeedsAttention: + return oobs.PaymentEventNeedsAttention + case agg.StateSettled: + return oobs.PaymentEventSettled + case agg.StateFailed: + return oobs.PaymentEventFailed + default: + return oobs.PaymentEventStateChanged + } +} + +func (s *svc) maxAttemptsForStep(stepRef string) uint32 { + if s.retryPolicy.MaxAttemptsByStepRef != nil { + if maxAttempts := s.retryPolicy.MaxAttemptsByStepRef[stepRef]; maxAttempts > 0 { + return maxAttempts + } + } + if s.retryPolicy.MaxAttempts > 0 { + return s.retryPolicy.MaxAttempts + } + return 1 +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/default_executors.go b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/default_executors.go new file mode 100644 index 00000000..20c18e76 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/default_executors.go @@ -0,0 +1,53 @@ +package psvc + +import ( + "context" + "strings" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/sexec" +) + +type defaultLedgerExecutor struct{} +type defaultCryptoExecutor struct{} +type defaultProviderSettlementExecutor struct{} +type defaultCardPayoutExecutor struct{} +type defaultObserveConfirmExecutor struct{} + +func (defaultLedgerExecutor) ExecuteLedger(_ context.Context, req sexec.StepRequest) (*sexec.ExecuteOutput, error) { + step := req.StepExecution + step.State = agg.StepStateCompleted + step.FailureCode = "" + step.FailureMsg = "" + return &sexec.ExecuteOutput{StepExecution: step}, nil +} + +func (defaultCryptoExecutor) ExecuteCrypto(_ context.Context, req sexec.StepRequest) (*sexec.ExecuteOutput, error) { + return asyncOutput(req.StepExecution, "operation_ref", "crypto:"+req.Step.StepRef), nil +} + +func (defaultProviderSettlementExecutor) ExecuteProviderSettlement(_ context.Context, req sexec.StepRequest) (*sexec.ExecuteOutput, error) { + return asyncOutput(req.StepExecution, "operation_ref", "provider:"+req.Step.StepRef), nil +} + +func (defaultCardPayoutExecutor) ExecuteCardPayout(_ context.Context, req sexec.StepRequest) (*sexec.ExecuteOutput, error) { + return asyncOutput(req.StepExecution, "card_payout_ref", "card:"+req.Step.StepRef), nil +} + +func (defaultObserveConfirmExecutor) ExecuteObserveConfirm(_ context.Context, req sexec.StepRequest) (*sexec.ExecuteOutput, error) { + return asyncOutput(req.StepExecution, "operation_ref", "observe:"+req.Step.StepRef), nil +} + +func asyncOutput(step agg.StepExecution, kind, ref string) *sexec.ExecuteOutput { + step.State = agg.StepStateRunning + step.ExternalRefs = append(step.ExternalRefs, agg.ExternalRef{ + Kind: strings.TrimSpace(kind), + Ref: strings.TrimSpace(ref), + }) + step.FailureCode = "" + step.FailureMsg = "" + return &sexec.ExecuteOutput{ + StepExecution: step, + Async: true, + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute.go b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute.go new file mode 100644 index 00000000..1e2e6289 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute.go @@ -0,0 +1,284 @@ +package psvc + +import ( + "context" + "errors" + "strings" + "time" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/idem" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/oobs" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/prepo" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/qsnap" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/reqval" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan" + "github.com/tech/sendico/pkg/merrors" + orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2" + "go.uber.org/zap" +) + +func (s *svc) ExecutePayment(ctx context.Context, req *orchestrationv2.ExecutePaymentRequest) (resp *orchestrationv2.ExecutePaymentResponse, err error) { + logger := s.logger + orgRef := "" + if req != nil && req.GetMeta() != nil { + orgRef = strings.TrimSpace(req.GetMeta().GetOrganizationRef()) + } + logger.Debug("Starting Execute payment", + zap.String("organization_ref", orgRef), + zap.String("quotation_ref", strings.TrimSpace(req.GetQuotationRef())), + zap.String("intent_ref", strings.TrimSpace(req.GetIntentRef())), + zap.Bool("has_client_payment_ref", strings.TrimSpace(req.GetClientPaymentRef()) != ""), + ) + defer func(start time.Time) { + fields := []zap.Field{zap.Int64("duration_ms", time.Since(start).Milliseconds())} + if resp != nil && resp.Payment != nil { + fields = append(fields, + zap.String("payment_ref", strings.TrimSpace(resp.Payment.GetPaymentRef())), + zap.String("state", resp.Payment.GetState().String()), + zap.Uint64("version", resp.Payment.GetVersion()), + ) + } + if err != nil { + logger.Warn("Failed to execute payment", append(fields, zap.Error(err))...) + return + } + logger.Debug("Completed Execute payment", fields...) + }(time.Now()) + + requestCtx, fingerprint, err := s.prepareExecute(req) + if err != nil { + return nil, err + } + + payment, reused, err := s.tryReuse(ctx, requestCtx, fingerprint) + if err != nil { + return nil, remapIdempotencyError(err) + } + if !reused { + payment, err = s.createNewPayment(ctx, requestCtx) + if err != nil { + return nil, remapIdempotencyError(err) + } + } + if payment != nil { + logger.Debug("psvc.payment_started", + zap.String("payment_ref", strings.TrimSpace(payment.PaymentRef)), + zap.Bool("reused", reused), + zap.String("state", string(payment.State)), + ) + } + + payment, err = s.runRuntime(ctx, payment) + if err != nil { + return nil, err + } + if payment != nil { + logger.Debug("psvc.payment_execution_progressed", + zap.String("payment_ref", strings.TrimSpace(payment.PaymentRef)), + zap.String("state", string(payment.State)), + zap.Uint64("version", payment.Version), + ) + } + protoPayment, err := s.mapPayment(payment) + if err != nil { + return nil, err + } + if payment != nil { + logger.Debug("psvc.payment_finalized", + zap.String("payment_ref", strings.TrimSpace(payment.PaymentRef)), + zap.String("state", string(payment.State)), + zap.Uint64("version", payment.Version), + ) + } + resp = &orchestrationv2.ExecutePaymentResponse{Payment: protoPayment} + return resp, nil +} + +func (s *svc) prepareExecute(req *orchestrationv2.ExecutePaymentRequest) (*reqval.Ctx, string, error) { + requestCtx, err := s.validator.Validate(mapExecuteReq(req)) + if err != nil { + return nil, "", err + } + fingerprint, err := s.idempotency.Fingerprint(idem.FPInput{ + OrganizationRef: requestCtx.OrganizationRef, + QuotationRef: requestCtx.QuotationRef, + IntentRef: requestCtx.IntentRef, + ClientPaymentRef: requestCtx.ClientPaymentRef, + }) + if err != nil { + return nil, "", err + } + return requestCtx, fingerprint, nil +} + +func mapExecuteReq(req *orchestrationv2.ExecutePaymentRequest) *reqval.Req { + if req == nil { + return nil + } + out := &reqval.Req{ + QuotationRef: req.GetQuotationRef(), + IntentRef: req.GetIntentRef(), + ClientPaymentRef: req.GetClientPaymentRef(), + } + meta := req.GetMeta() + if meta == nil { + return out + } + out.Meta = &reqval.Meta{OrganizationRef: meta.GetOrganizationRef()} + if meta.GetTrace() != nil { + out.Meta.Trace = &reqval.Trace{IdempotencyKey: meta.GetTrace().GetIdempotencyKey()} + } + return out +} + +func (s *svc) tryReuse(ctx context.Context, requestCtx *reqval.Ctx, requestFingerprint string) (*agg.Payment, bool, error) { + existing, err := s.repository.GetByIdempotencyKey(ctx, requestCtx.OrganizationID, requestCtx.IdempotencyKey) + if err != nil { + if errors.Is(err, prepo.ErrPaymentNotFound) || errors.Is(err, merrors.ErrNoData) { + return nil, false, nil + } + return nil, false, err + } + if existing == nil { + return nil, false, nil + } + + existingFingerprint, err := s.idempotency.Fingerprint(idem.FPInput{ + OrganizationRef: requestCtx.OrganizationRef, + QuotationRef: existing.QuotationRef, + IntentRef: existing.IntentSnapshot.Ref, + ClientPaymentRef: existing.ClientPaymentRef, + }) + if err != nil { + return nil, false, err + } + if strings.TrimSpace(existingFingerprint) != strings.TrimSpace(requestFingerprint) { + return nil, false, idem.ErrIdempotencyParamMismatch + } + return existing, true, nil +} + +func (s *svc) createNewPayment(ctx context.Context, requestCtx *reqval.Ctx) (*agg.Payment, error) { + resolved, graph, err := s.resolveAndPlan(ctx, requestCtx) + if err != nil { + return nil, err + } + payment, err := s.aggregate.Create(agg.Input{ + OrganizationRef: requestCtx.OrganizationID, + IdempotencyKey: requestCtx.IdempotencyKey, + QuotationRef: resolved.QuotationRef, + ClientPaymentRef: requestCtx.ClientPaymentRef, + IntentSnapshot: resolved.IntentSnapshot, + QuoteSnapshot: resolved.QuoteSnapshot, + Steps: toStepShells(graph), + }) + if err != nil { + return nil, err + } + + if err := s.repository.Create(ctx, payment); err != nil { + if !errors.Is(err, prepo.ErrDuplicatePayment) { + return nil, err + } + reused, ok, reuseErr := s.tryReuse(ctx, requestCtx, mustFingerprint(s.idempotency, requestCtx)) + if reuseErr != nil { + return nil, reuseErr + } + if ok { + return reused, nil + } + return nil, err + } + + if err := s.recordPaymentCreated(ctx, payment, graph); err != nil { + return nil, err + } + return payment, nil +} + +func (s *svc) resolveAndPlan(ctx context.Context, requestCtx *reqval.Ctx) (*qsnap.Output, *xplan.Graph, error) { + resolved, err := s.quote.Resolve(ctx, s.quoteStore, qsnap.Input{ + OrganizationID: requestCtx.OrganizationID, + QuotationRef: requestCtx.QuotationRef, + IntentRef: requestCtx.IntentRef, + }) + if err != nil { + return nil, nil, err + } + graph, err := s.planner.Compile(xplan.Input{ + IntentSnapshot: resolved.IntentSnapshot, + QuoteSnapshot: resolved.QuoteSnapshot, + }) + if err != nil { + return nil, nil, err + } + return resolved, graph, nil +} + +func toStepShells(graph *xplan.Graph) []agg.StepShell { + if graph == nil || len(graph.Steps) == 0 { + return nil + } + out := make([]agg.StepShell, 0, len(graph.Steps)) + for i := range graph.Steps { + out = append(out, agg.StepShell{ + StepRef: graph.Steps[i].StepRef, + StepCode: graph.Steps[i].StepCode, + }) + } + return out +} + +func (s *svc) recordPaymentCreated(ctx context.Context, payment *agg.Payment, graph *xplan.Graph) error { + if err := s.observer.RecordPayment(ctx, oobs.RecordPaymentInput{ + Payment: payment, + Event: oobs.PaymentEventCreated, + Fields: map[string]string{ + "route_ref": graphRouteRef(graph), + }, + }); err != nil { + return err + } + for i := range payment.StepExecutions { + if err := s.observer.RecordStep(ctx, oobs.RecordStepInput{ + PaymentRef: payment.PaymentRef, + Step: payment.StepExecutions[i], + Event: oobs.StepEventScheduled, + }); err != nil { + return err + } + } + return nil +} + +func graphRouteRef(graph *xplan.Graph) string { + if graph == nil { + return "" + } + return strings.TrimSpace(graph.RouteRef) +} + +func remapIdempotencyError(err error) error { + if errors.Is(err, idem.ErrIdempotencyParamMismatch) { + return merrors.InvalidArgument(err.Error()) + } + return err +} + +func mustFingerprint(idemSvc idem.Service, requestCtx *reqval.Ctx) string { + if idemSvc == nil || requestCtx == nil { + return "" + } + value, err := idemSvc.Fingerprint(idem.FPInput{ + OrganizationRef: requestCtx.OrganizationRef, + QuotationRef: requestCtx.QuotationRef, + IntentRef: requestCtx.IntentRef, + ClientPaymentRef: requestCtx.ClientPaymentRef, + }) + if err != nil { + return "" + } + return value +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/external.go b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/external.go new file mode 100644 index 00000000..ad8fa952 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/external.go @@ -0,0 +1,209 @@ +package psvc + +import ( + "context" + "errors" + "strings" + "time" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/erecon" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/oobs" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/prepo" + "github.com/tech/sendico/pkg/merrors" + "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" +) + +func (s *svc) ReconcileExternal(ctx context.Context, in ReconcileExternalInput) (out *ReconcileExternalOutput, err error) { + logger := s.logger + logger.Debug("Starting Reconcile external", + zap.String("organization_ref", strings.TrimSpace(in.OrganizationRef)), + zap.String("payment_ref", strings.TrimSpace(in.PaymentRef)), + zap.String("event_source", externalEventSource(in.Event)), + zap.String("event_status", externalEventStatus(in.Event)), + ) + defer func(start time.Time) { + fields := []zap.Field{zap.Int64("duration_ms", time.Since(start).Milliseconds())} + if out != nil && out.Payment != nil { + fields = append(fields, + zap.String("state", out.Payment.GetState().String()), + zap.Uint64("version", out.Payment.GetVersion()), + ) + } + if err != nil { + logger.Warn("Failed to reconcile external", append(fields, zap.Error(err))...) + return + } + logger.Debug("Completed Reconcile external", fields...) + }(time.Now()) + + orgRef := strings.TrimSpace(in.OrganizationRef) + if orgRef == "" { + return nil, merrors.InvalidArgument("organization_ref is required") + } + orgID, err := bson.ObjectIDFromHex(orgRef) + if err != nil { + return nil, merrors.InvalidArgument("organization_ref must be a valid objectID") + } + paymentRef, err := parsePaymentRef(in.PaymentRef) + if err != nil { + return nil, err + } + + payment, err := s.repository.GetByPaymentRef(ctx, orgID, paymentRef) + if err != nil { + return nil, err + } + if payment == nil { + return nil, prepo.ErrPaymentNotFound + } + + reconOut, err := s.reconciler.Reconcile(erecon.Input{ + Payment: payment, + Event: in.Event, + }) + if err != nil { + return nil, err + } + if reconOut == nil || reconOut.Payment == nil { + return nil, merrors.Internal("reconciler returned nil payment") + } + + if err := s.recordExternal(ctx, reconOut.Payment, in.Event, reconOut.MatchedStepRef); err != nil { + return nil, err + } + if reconOut.StepChanged || reconOut.AggregateChanged { + if err := s.repository.UpdateCAS(ctx, reconOut.Payment, payment.Version); err != nil { + if errors.Is(err, prepo.ErrVersionConflict) { + fresh, reloadErr := s.repository.GetByPaymentRef(ctx, orgID, paymentRef) + if reloadErr != nil { + return nil, reloadErr + } + reconOut.Payment = fresh + } else { + return nil, err + } + } + } + + advanced, err := s.runRuntime(ctx, reconOut.Payment) + if err != nil { + return nil, err + } + mapped, err := s.mapPayment(advanced) + if err != nil { + return nil, err + } + out = &ReconcileExternalOutput{Payment: mapped} + return out, nil +} + +func (s *svc) recordExternal(ctx context.Context, payment *agg.Payment, event erecon.Event, matchedStepRef string) error { + input, ok := buildExternalRecordInput(payment, event, matchedStepRef) + if !ok { + return nil + } + return s.observer.RecordExternal(ctx, input) +} + +func buildExternalRecordInput(payment *agg.Payment, event erecon.Event, matchedStepRef string) (oobs.RecordExternalInput, bool) { + if payment == nil { + return oobs.RecordExternalInput{}, false + } + stepRef := strings.TrimSpace(matchedStepRef) + if stepRef == "" { + if event.Gateway != nil { + stepRef = strings.TrimSpace(event.Gateway.StepRef) + } else if event.Ledger != nil { + stepRef = strings.TrimSpace(event.Ledger.StepRef) + } else if event.Card != nil { + stepRef = strings.TrimSpace(event.Card.StepRef) + } + } + if stepRef == "" { + return oobs.RecordExternalInput{}, false + } + attempt := stepAttempt(payment.StepExecutions, stepRef) + if attempt == 0 { + attempt = 1 + } + + in := oobs.RecordExternalInput{ + PaymentRef: payment.PaymentRef, + StepRef: stepRef, + Attempt: attempt, + } + switch { + case event.Gateway != nil: + in.Source = oobs.ExternalSourceGateway + in.Status = strings.TrimSpace(string(event.Gateway.Status)) + in.RefKind = erecon.ExternalRefKindOperation + in.Ref = firstNonEmpty(event.Gateway.OperationRef, event.Gateway.TransferRef) + if strings.TrimSpace(event.Gateway.TransferRef) != "" { + in.RefKind = erecon.ExternalRefKindTransfer + } + in.Message = strings.TrimSpace(event.Gateway.FailureMsg) + case event.Ledger != nil: + in.Source = oobs.ExternalSourceLedger + in.Status = strings.TrimSpace(string(event.Ledger.Status)) + in.RefKind = erecon.ExternalRefKindLedger + in.Ref = strings.TrimSpace(event.Ledger.EntryRef) + in.Message = strings.TrimSpace(event.Ledger.FailureMsg) + case event.Card != nil: + in.Source = oobs.ExternalSourceCard + in.Status = strings.TrimSpace(string(event.Card.Status)) + in.RefKind = erecon.ExternalRefKindCardPayout + in.Ref = strings.TrimSpace(event.Card.PayoutRef) + in.Message = strings.TrimSpace(event.Card.FailureMsg) + default: + return oobs.RecordExternalInput{}, false + } + return in, true +} + +func stepAttempt(steps []agg.StepExecution, stepRef string) uint32 { + ref := strings.TrimSpace(stepRef) + for i := range steps { + if strings.TrimSpace(steps[i].StepRef) == ref { + return steps[i].Attempt + } + } + return 0 +} + +func firstNonEmpty(values ...string) string { + for i := range values { + if val := strings.TrimSpace(values[i]); val != "" { + return val + } + } + return "" +} + +func externalEventSource(event erecon.Event) string { + switch { + case event.Gateway != nil: + return "gateway" + case event.Ledger != nil: + return "ledger" + case event.Card != nil: + return "card" + default: + return "unknown" + } +} + +func externalEventStatus(event erecon.Event) string { + switch { + case event.Gateway != nil: + return strings.TrimSpace(string(event.Gateway.Status)) + case event.Ledger != nil: + return strings.TrimSpace(string(event.Ledger.Status)) + case event.Card != nil: + return strings.TrimSpace(string(event.Card.Status)) + default: + return "" + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/module.go new file mode 100644 index 00000000..d3ce501a --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/module.go @@ -0,0 +1,72 @@ +package psvc + +import ( + "context" + "time" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/erecon" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/idem" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/oobs" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/ostate" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/pquery" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/prepo" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/prmap" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/qsnap" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/reqval" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/sexec" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/ssched" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan" + "github.com/tech/sendico/pkg/mlogger" + orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2" +) + +// Service orchestrates execute/query/reconcile payment runtime operations. +type Service interface { + ExecutePayment(ctx context.Context, req *orchestrationv2.ExecutePaymentRequest) (*orchestrationv2.ExecutePaymentResponse, error) + GetPayment(ctx context.Context, req *orchestrationv2.GetPaymentRequest) (*orchestrationv2.GetPaymentResponse, error) + ListPayments(ctx context.Context, req *orchestrationv2.ListPaymentsRequest) (*orchestrationv2.ListPaymentsResponse, error) + + ReconcileExternal(ctx context.Context, in ReconcileExternalInput) (*ReconcileExternalOutput, error) +} + +// ReconcileExternalInput is one internal external-event payload. +type ReconcileExternalInput struct { + OrganizationRef string + PaymentRef string + Event erecon.Event +} + +// ReconcileExternalOutput is reconciliation result payload. +type ReconcileExternalOutput struct { + Payment *orchestrationv2.Payment +} + +// Dependencies configures orchestration-v2 runtime modules. +type Dependencies struct { + Logger mlogger.Logger + + QuoteStore qsnap.Store + + Validator reqval.Validator + Idempotency idem.Service + Quote qsnap.Resolver + Aggregate agg.Factory + Planner xplan.Compiler + State ostate.StateMachine + Scheduler ssched.Runtime + Executors sexec.Registry + Reconciler erecon.Reconciler + Repository prepo.Repository + Query pquery.Service + Mapper prmap.Mapper + Observer oobs.Observer + + RetryPolicy ssched.RetryPolicy + Now func() time.Time + MaxTicks int +} + +func New(deps Dependencies) (Service, error) { + return newService(deps) +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/query.go b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/query.go new file mode 100644 index 00000000..361d9920 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/query.go @@ -0,0 +1,166 @@ +package psvc + +import ( + "context" + "strings" + "time" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/pquery" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/prmap" + paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1" + orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2" + "go.uber.org/zap" +) + +func (s *svc) GetPayment(ctx context.Context, req *orchestrationv2.GetPaymentRequest) (resp *orchestrationv2.GetPaymentResponse, err error) { + logger := s.logger + orgRef := "" + if req != nil && req.GetMeta() != nil { + orgRef = strings.TrimSpace(req.GetMeta().GetOrganizationRef()) + } + logger.Debug("Starting Get payment", + zap.String("organization_ref", orgRef), + zap.String("payment_ref", strings.TrimSpace(req.GetPaymentRef())), + ) + defer func(start time.Time) { + fields := []zap.Field{zap.Int64("duration_ms", time.Since(start).Milliseconds())} + if resp != nil && resp.Payment != nil { + fields = append(fields, + zap.String("state", resp.Payment.GetState().String()), + zap.Uint64("version", resp.Payment.GetVersion()), + ) + } + if err != nil { + logger.Warn("Failed to get payment", append(fields, zap.Error(err))...) + return + } + logger.Debug("Completed Get payment", fields...) + }(time.Now()) + + _, orgID, err := parseOrganization(req.GetMeta()) + if err != nil { + return nil, err + } + paymentRef, err := parsePaymentRef(req.GetPaymentRef()) + if err != nil { + return nil, err + } + + payment, err := s.query.GetPayment(ctx, pquery.GetPaymentInput{ + OrganizationRef: orgID, + PaymentRef: paymentRef, + }) + if err != nil { + return nil, err + } + protoPayment, err := s.mapPayment(payment) + if err != nil { + return nil, err + } + resp = &orchestrationv2.GetPaymentResponse{Payment: protoPayment} + return resp, nil +} + +func (s *svc) ListPayments(ctx context.Context, req *orchestrationv2.ListPaymentsRequest) (resp *orchestrationv2.ListPaymentsResponse, err error) { + logger := s.logger + orgRef := "" + if req != nil && req.GetMeta() != nil { + orgRef = strings.TrimSpace(req.GetMeta().GetOrganizationRef()) + } + logger.Debug("Starting List payments", + zap.String("organization_ref", orgRef), + zap.String("quotation_ref", strings.TrimSpace(req.GetQuotationRef())), + zap.Int("states_count", len(req.GetStates())), + ) + defer func(start time.Time) { + fields := []zap.Field{zap.Int64("duration_ms", time.Since(start).Milliseconds())} + if resp != nil { + fields = append(fields, zap.Int("payments_count", len(resp.GetPayments()))) + } + if err != nil { + logger.Warn("Failed to list payments", append(fields, zap.Error(err))...) + return + } + logger.Debug("Completed List payments", fields...) + }(time.Now()) + + _, orgID, err := parseOrganization(req.GetMeta()) + if err != nil { + return nil, err + } + states, err := mapStates(req.GetStates()) + if err != nil { + return nil, err + } + createdFrom, err := parseCreated(req.GetCreatedFrom(), "created_from") + if err != nil { + return nil, err + } + createdTo, err := parseCreated(req.GetCreatedTo(), "created_to") + if err != nil { + return nil, err + } + cursor, err := parseCursor(req.GetPage().GetCursor()) + if err != nil { + return nil, err + } + limit := int32(0) + if req.GetPage() != nil { + limit = req.GetPage().GetLimit() + } + + page, err := s.query.ListPayments(ctx, pquery.ListPaymentsInput{ + OrganizationRef: orgID, + States: states, + QuotationRef: strings.TrimSpace(req.GetQuotationRef()), + CreatedFrom: createdFrom, + CreatedTo: createdTo, + Cursor: cursor, + Limit: limit, + }) + if err != nil { + return nil, err + } + + items, err := s.mapPayments(page.Items) + if err != nil { + return nil, err + } + nextCursor, err := formatCursor(page.NextCursor) + if err != nil { + return nil, err + } + resp = &orchestrationv2.ListPaymentsResponse{ + Payments: items, + Page: &paginationv1.CursorPageResponse{NextCursor: nextCursor}, + } + return resp, nil +} + +func (s *svc) mapPayments(items []*agg.Payment) ([]*orchestrationv2.Payment, error) { + if len(items) == 0 { + return nil, nil + } + out := make([]*orchestrationv2.Payment, 0, len(items)) + for i := range items { + mapped, err := s.mapPayment(items[i]) + if err != nil { + return nil, err + } + out = append(out, mapped) + } + return out, nil +} + +func (s *svc) mapPayment(payment *agg.Payment) (*orchestrationv2.Payment, error) { + mapped, err := s.mapper.Map(prmap.MapInput{Payment: payment}) + if err != nil { + return nil, err + } + if mapped == nil { + return nil, nil + } + return mapped.Payment, nil +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/request_helpers.go b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/request_helpers.go new file mode 100644 index 00000000..553db795 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/request_helpers.go @@ -0,0 +1,128 @@ +package psvc + +import ( + "encoding/base64" + "encoding/json" + "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" + orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2" + sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" + "go.mongodb.org/mongo-driver/v2/bson" + "google.golang.org/protobuf/types/known/timestamppb" +) + +type listCursorPayload struct { + CreatedAt string `json:"created_at"` + ID string `json:"id"` +} + +func parseOrganization(meta *sharedv1.RequestMeta) (string, bson.ObjectID, error) { + if meta == nil { + return "", bson.NilObjectID, merrors.InvalidArgument("meta is required") + } + orgRef := strings.TrimSpace(meta.GetOrganizationRef()) + if orgRef == "" { + return "", bson.NilObjectID, merrors.InvalidArgument("meta.organization_ref is required") + } + orgID, err := bson.ObjectIDFromHex(orgRef) + if err != nil { + return "", bson.NilObjectID, merrors.InvalidArgument("meta.organization_ref must be a valid objectID") + } + return orgRef, orgID, nil +} + +func parsePaymentRef(value string) (string, error) { + ref := strings.TrimSpace(value) + if ref == "" { + return "", merrors.InvalidArgument("payment_ref is required") + } + return ref, nil +} + +func parseCursor(value string) (*prepo.ListCursor, error) { + raw := strings.TrimSpace(value) + if raw == "" { + return nil, nil + } + data, err := base64.RawURLEncoding.DecodeString(raw) + if err != nil { + return nil, merrors.InvalidArgument("page.cursor is invalid") + } + var payload listCursorPayload + if err := json.Unmarshal(data, &payload); err != nil { + return nil, merrors.InvalidArgument("page.cursor is invalid") + } + id, err := bson.ObjectIDFromHex(strings.TrimSpace(payload.ID)) + if err != nil { + return nil, merrors.InvalidArgument("page.cursor is invalid") + } + createdAt, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(payload.CreatedAt)) + if err != nil { + return nil, merrors.InvalidArgument("page.cursor is invalid") + } + return &prepo.ListCursor{ + CreatedAt: createdAt.UTC(), + ID: id, + }, nil +} + +func formatCursor(cursor *prepo.ListCursor) (string, error) { + if cursor == nil || cursor.ID.IsZero() || cursor.CreatedAt.IsZero() { + return "", nil + } + data, err := json.Marshal(listCursorPayload{ + CreatedAt: cursor.CreatedAt.UTC().Format(time.RFC3339Nano), + ID: cursor.ID.Hex(), + }) + if err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(data), nil +} + +func parseCreated(ts *timestamppb.Timestamp, field string) (*time.Time, error) { + if ts == nil { + return nil, nil + } + if err := ts.CheckValid(); err != nil { + return nil, merrors.InvalidArgument(field + " is invalid") + } + value := ts.AsTime().UTC() + return &value, nil +} + +func mapStates(states []orchestrationv2.OrchestrationState) ([]agg.State, error) { + if len(states) == 0 { + return nil, nil + } + out := make([]agg.State, 0, len(states)) + for i := range states { + state, ok := mapState(states[i]) + if !ok { + return nil, merrors.InvalidArgument("states contains invalid value") + } + out = append(out, state) + } + return out, nil +} + +func mapState(state orchestrationv2.OrchestrationState) (agg.State, bool) { + switch state { + case orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_CREATED: + return agg.StateCreated, true + case orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_EXECUTING: + return agg.StateExecuting, true + case orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_NEEDS_ATTENTION: + return agg.StateNeedsAttention, true + case orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_SETTLED: + return agg.StateSettled, true + case orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_FAILED: + return agg.StateFailed, true + default: + return agg.StateUnspecified, false + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/runtime.go b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/runtime.go new file mode 100644 index 00000000..62a36958 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/runtime.go @@ -0,0 +1,422 @@ +package psvc + +import ( + "context" + "errors" + "strings" + "time" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/oobs" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/prepo" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/sexec" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/ssched" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan" + "github.com/tech/sendico/pkg/merrors" + "go.uber.org/zap" +) + +func (s *svc) runRuntime(ctx context.Context, payment *agg.Payment) (*agg.Payment, error) { + logger := s.logger + paymentRef := "" + state := agg.StateUnspecified + stepCount := 0 + if payment != nil { + paymentRef = strings.TrimSpace(payment.PaymentRef) + state = payment.State + stepCount = len(payment.StepExecutions) + } + logger.Debug("Starting Run runtime", + zap.String("payment_ref", paymentRef), + zap.String("state", string(state)), + zap.Int("steps_count", stepCount), + ) + + if payment == nil { + return nil, merrors.InvalidArgument("payment is required") + } + if s.state.IsAggregateTerminal(payment.State) { + logger.Debug("psvc.run_runtime.terminal", zap.String("payment_ref", paymentRef), zap.String("state", string(payment.State))) + return payment, nil + } + + current := payment + for tick := 0; tick < s.maxTicks; tick++ { + graph, err := s.compileGraph(current) + if err != nil { + return nil, err + } + + updated, changed, waitOnly, err := s.runTick(ctx, current, graph) + if err != nil { + return nil, err + } + logger.Debug("psvc.run_runtime.tick", + zap.String("payment_ref", paymentRef), + zap.Int("tick", tick), + zap.Bool("changed", changed), + zap.Bool("wait_only", waitOnly), + zap.String("state", string(updated.State)), + zap.Uint64("version", updated.Version), + ) + if !changed { + return updated, nil + } + + if s.state.IsAggregateTerminal(updated.State) { + return updated, nil + } + if waitOnly { + return updated, nil + } + current = updated + } + logger.Debug("psvc.run_runtime.max_ticks_reached", + zap.String("payment_ref", paymentRef), + zap.Int("max_ticks", s.maxTicks), + zap.String("state", string(current.State)), + zap.Uint64("version", current.Version), + ) + return current, nil +} + +func (s *svc) runTick(ctx context.Context, payment *agg.Payment, graph *xplan.Graph) (*agg.Payment, bool, bool, error) { + logger := s.logger + expectedVersion := payment.Version + + scheduled, err := s.scheduler.Schedule(ssched.Input{ + Steps: graph.Steps, + StepExecutions: payment.StepExecutions, + Retry: s.retryPolicy, + }) + if err != nil { + return nil, false, false, err + } + logger.Debug("psvc.run_tick.scheduled", + zap.String("payment_ref", strings.TrimSpace(payment.PaymentRef)), + zap.Int("runnable_count", len(scheduled.Runnable)), + zap.Int("blocked_count", len(scheduled.Blocked)), + zap.Int("skipped_count", len(scheduled.Skipped)), + ) + + changed := mergeScheduledExecutions(payment, scheduled.StepExecutions) + if changed { + if err := s.recordScheduleTransitions(ctx, payment, scheduled.StepExecutions); err != nil { + return nil, false, false, err + } + } + + for i := range scheduled.Runnable { + stepChanged, runErr := s.executeRunnable(ctx, payment, graph, scheduled.Runnable[i]) + if runErr != nil { + return nil, false, false, runErr + } + changed = changed || stepChanged + } + + aggChanged, err := s.recomputeAggregateState(ctx, payment) + if err != nil { + return nil, false, false, err + } + changed = changed || aggChanged + if !changed { + return payment, false, len(scheduled.Runnable) == 0, nil + } + + if err := s.repository.UpdateCAS(ctx, payment, expectedVersion); err != nil { + if errors.Is(err, prepo.ErrVersionConflict) { + logger.Debug("psvc.run_tick.cas_conflict", + zap.String("payment_ref", strings.TrimSpace(payment.PaymentRef)), + zap.Uint64("expected_version", expectedVersion), + ) + fresh, reloadErr := s.repository.GetByPaymentRef(ctx, payment.OrganizationRef, payment.PaymentRef) + if reloadErr != nil { + return nil, false, false, reloadErr + } + return fresh, true, false, nil + } + return nil, false, false, err + } + logger.Debug("psvc.run_tick.persisted", + zap.String("payment_ref", strings.TrimSpace(payment.PaymentRef)), + zap.Uint64("version", payment.Version), + zap.String("state", string(payment.State)), + ) + return payment, true, len(scheduled.Runnable) == 0, nil +} + +func (s *svc) compileGraph(payment *agg.Payment) (*xplan.Graph, error) { + return s.planner.Compile(xplan.Input{ + IntentSnapshot: payment.IntentSnapshot, + QuoteSnapshot: payment.QuoteSnapshot, + }) +} + +func mergeScheduledExecutions(payment *agg.Payment, updated []agg.StepExecution) bool { + if payment == nil { + return false + } + if len(updated) == 0 { + return false + } + changed := false + index := stepIndexByRef(payment.StepExecutions) + for i := range updated { + step := updated[i] + idx, ok := index[strings.TrimSpace(step.StepRef)] + if !ok { + continue + } + if !stepExecutionEqual(payment.StepExecutions[idx], step) { + payment.StepExecutions[idx] = step + changed = true + } + } + return changed +} + +func (s *svc) recordScheduleTransitions(ctx context.Context, payment *agg.Payment, current []agg.StepExecution) error { + _ = current + for i := range payment.StepExecutions { + step := payment.StepExecutions[i] + if step.State != agg.StepStateSkipped && step.State != agg.StepStateNeedsAttention { + continue + } + event := oobs.StepEventSkipped + if step.State == agg.StepStateNeedsAttention { + event = oobs.StepEventBlocked + } + if err := s.observer.RecordStep(ctx, oobs.RecordStepInput{ + PaymentRef: payment.PaymentRef, + Step: step, + Event: event, + }); err != nil { + return err + } + } + return nil +} + +func (s *svc) executeRunnable(ctx context.Context, payment *agg.Payment, graph *xplan.Graph, runnable ssched.RunnableStep) (bool, error) { + logger := s.logger + logger.Debug("Starting Step execution", + zap.String("payment_ref", strings.TrimSpace(payment.PaymentRef)), + zap.String("step_ref", strings.TrimSpace(runnable.StepRef)), + zap.String("step_code", strings.TrimSpace(runnable.StepCode)), + zap.Uint32("attempt", runnable.Attempt), + ) + + idx, ok := findStepExecution(payment.StepExecutions, runnable.StepRef) + if !ok { + return false, merrors.InvalidArgument("step execution not found: " + runnable.StepRef) + } + stepExecution := payment.StepExecutions[idx] + stepExecution.Attempt = runnable.Attempt + + if stepExecution.State != agg.StepStateRunning { + if err := s.state.EnsureStepTransition(stepExecution.State, agg.StepStateRunning); err != nil { + stepExecution.State = agg.StepStateNeedsAttention + stepExecution.FailureCode = "step.transition_invalid" + stepExecution.FailureMsg = err.Error() + } else { + stepExecution.State = agg.StepStateRunning + } + now := s.nowUTC() + stepExecution.StartedAt = &now + stepExecution.CompletedAt = nil + if err := s.observer.RecordStep(ctx, oobs.RecordStepInput{ + PaymentRef: payment.PaymentRef, + Step: stepExecution, + Event: oobs.StepEventStarted, + }); err != nil { + return false, err + } + } + + step, ok := findGraphStep(graph, runnable.StepRef) + if !ok { + return false, merrors.InvalidArgument("graph step not found: " + runnable.StepRef) + } + out, err := s.executors.Execute(ctx, sexec.ExecuteInput{ + Payment: payment, + Step: step, + StepExecution: stepExecution, + }) + if err != nil { + logger.Warn("psvc.step_execution.executor_error", + zap.String("payment_ref", strings.TrimSpace(payment.PaymentRef)), + zap.String("step_ref", strings.TrimSpace(runnable.StepRef)), + zap.Uint32("attempt", runnable.Attempt), + zap.Error(err), + ) + failed := markStepFailed(stepExecution, "step.executor_error", err.Error(), s.nowUTC()) + payment.StepExecutions[idx] = failed + if obsErr := s.observer.RecordStep(ctx, oobs.RecordStepInput{ + PaymentRef: payment.PaymentRef, + Step: failed, + Event: oobs.StepEventFailed, + }); obsErr != nil { + return false, obsErr + } + return true, nil + } + + next := normalizeExecutorOutput(stepExecution, out, s.nowUTC()) + payment.StepExecutions[idx] = next + logger.Debug("Completed Step execution", + zap.String("payment_ref", strings.TrimSpace(payment.PaymentRef)), + zap.String("step_ref", strings.TrimSpace(next.StepRef)), + zap.String("state", string(next.State)), + zap.Uint32("attempt", next.Attempt), + ) + if next.State == agg.StepStateCompleted || next.State == agg.StepStateFailed || next.State == agg.StepStateNeedsAttention { + event := oobs.StepEventCompleted + if next.State != agg.StepStateCompleted { + event = oobs.StepEventFailed + } + if err := s.observer.RecordStep(ctx, oobs.RecordStepInput{ + PaymentRef: payment.PaymentRef, + Step: next, + Event: event, + Duration: stepDuration(next), + }); err != nil { + return false, err + } + } + return true, nil +} + +func normalizeExecutorOutput(current agg.StepExecution, out *sexec.ExecuteOutput, now time.Time) agg.StepExecution { + if out == nil { + next := current + next.State = agg.StepStateCompleted + next.CompletedAt = &now + return next + } + next := current + if out.StepExecution.StepRef != "" { + next.StepRef = out.StepExecution.StepRef + } + if out.StepExecution.StepCode != "" { + next.StepCode = out.StepExecution.StepCode + } + if out.StepExecution.Attempt != 0 { + next.Attempt = out.StepExecution.Attempt + } + next.ExternalRefs = out.StepExecution.ExternalRefs + next.FailureCode = strings.TrimSpace(out.StepExecution.FailureCode) + next.FailureMsg = strings.TrimSpace(out.StepExecution.FailureMsg) + + switch out.StepExecution.State { + case agg.StepStateCompleted, agg.StepStateFailed, agg.StepStateNeedsAttention, agg.StepStateSkipped: + next.State = out.StepExecution.State + case agg.StepStateRunning: + next.State = agg.StepStateRunning + default: + if out.Async { + next.State = agg.StepStateRunning + } else { + next.State = agg.StepStateCompleted + } + } + + if next.StartedAt == nil { + next.StartedAt = &now + } + if next.State == agg.StepStateRunning { + next.CompletedAt = nil + } else if next.CompletedAt == nil { + next.CompletedAt = &now + } + return next +} + +func markStepFailed(step agg.StepExecution, code, message string, now time.Time) agg.StepExecution { + step.State = agg.StepStateFailed + step.FailureCode = strings.TrimSpace(code) + step.FailureMsg = strings.TrimSpace(message) + if step.StartedAt == nil { + step.StartedAt = &now + } + step.CompletedAt = &now + return step +} + +func stepDuration(step agg.StepExecution) time.Duration { + if step.StartedAt == nil || step.CompletedAt == nil { + return 0 + } + if step.CompletedAt.Before(*step.StartedAt) { + return 0 + } + return step.CompletedAt.Sub(*step.StartedAt) +} + +func stepIndexByRef(steps []agg.StepExecution) map[string]int { + out := make(map[string]int, len(steps)) + for i := range steps { + out[strings.TrimSpace(steps[i].StepRef)] = i + } + return out +} + +func findStepExecution(steps []agg.StepExecution, stepRef string) (int, bool) { + ref := strings.TrimSpace(stepRef) + for i := range steps { + if strings.TrimSpace(steps[i].StepRef) == ref { + return i, true + } + } + return 0, false +} + +func findGraphStep(graph *xplan.Graph, stepRef string) (xplan.Step, bool) { + if graph == nil { + return xplan.Step{}, false + } + ref := strings.TrimSpace(stepRef) + for i := range graph.Steps { + if strings.TrimSpace(graph.Steps[i].StepRef) == ref { + return graph.Steps[i], true + } + } + return xplan.Step{}, false +} + +func stepExecutionEqual(left, right agg.StepExecution) bool { + if left.StepRef != right.StepRef || left.StepCode != right.StepCode { + return false + } + if left.State != right.State || left.Attempt != right.Attempt { + return false + } + if strings.TrimSpace(left.FailureCode) != strings.TrimSpace(right.FailureCode) { + return false + } + if strings.TrimSpace(left.FailureMsg) != strings.TrimSpace(right.FailureMsg) { + return false + } + if !timePtrEqual(left.StartedAt, right.StartedAt) || !timePtrEqual(left.CompletedAt, right.CompletedAt) { + return false + } + if len(left.ExternalRefs) != len(right.ExternalRefs) { + return false + } + for i := range left.ExternalRefs { + if left.ExternalRefs[i] != right.ExternalRefs[i] { + return false + } + } + return true +} + +func timePtrEqual(left *time.Time, right *time.Time) bool { + if left == nil && right == nil { + return true + } + if left == nil || right == nil { + return false + } + return left.UTC().Equal(right.UTC()) +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service.go b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service.go new file mode 100644 index 00000000..8089611f --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service.go @@ -0,0 +1,197 @@ +package psvc + +import ( + "time" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/erecon" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/idem" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/oobs" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/ostate" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/pquery" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/prepo" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/prmap" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/qsnap" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/reqval" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/sexec" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/ssched" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" +) + +const ( + defaultMaxTicks = 32 +) + +type svc struct { + logger mlogger.Logger + + quoteStore qsnap.Store + + validator reqval.Validator + idempotency idem.Service + quote qsnap.Resolver + aggregate agg.Factory + planner xplan.Compiler + state ostate.StateMachine + scheduler ssched.Runtime + executors sexec.Registry + reconciler erecon.Reconciler + repository prepo.Repository + query pquery.Service + mapper prmap.Mapper + observer oobs.Observer + + retryPolicy ssched.RetryPolicy + now func() time.Time + maxTicks int +} + +func newService(deps Dependencies) (Service, error) { + if deps.QuoteStore == nil { + return nil, merrors.InvalidArgument("quote store is required") + } + if deps.Repository == nil { + return nil, merrors.InvalidArgument("payment repository v2 is required") + } + + logger := deps.Logger.Named("psvc") + + observer := deps.Observer + if observer == nil { + var err error + observer, err = oobs.New(oobs.Dependencies{Logger: logger.Named("oobs")}) + if err != nil { + return nil, err + } + } + + query := deps.Query + if query == nil { + var err error + query, err = pquery.New(pquery.Dependencies{ + Repository: deps.Repository, + Logger: logger.Named("pquery"), + }) + if err != nil { + return nil, err + } + } + + out := &svc{ + logger: logger, + + quoteStore: deps.QuoteStore, + + validator: firstValidator(deps.Validator, logger.Named("reqval")), + idempotency: firstIdempotency(deps.Idempotency, logger.Named("idem")), + quote: firstQuoteResolver(deps.Quote, logger.Named("qsnap")), + aggregate: firstAggregateFactory(deps.Aggregate, logger.Named("agg")), + planner: firstPlanCompiler(deps.Planner, logger.Named("xplan")), + state: firstStateMachine(deps.State, logger.Named("ostate")), + scheduler: firstScheduler(deps.Scheduler, logger.Named("ssched")), + executors: firstExecutors(deps.Executors, logger.Named("sexec")), + reconciler: firstReconciler(deps.Reconciler, logger.Named("erecon")), + repository: deps.Repository, + query: query, + mapper: firstMapper(deps.Mapper, logger.Named("prmap")), + observer: observer, + + retryPolicy: deps.RetryPolicy, + now: deps.Now, + maxTicks: deps.MaxTicks, + } + if out.now == nil { + out.now = func() time.Time { return time.Now().UTC() } + } + if out.maxTicks <= 0 { + out.maxTicks = defaultMaxTicks + } + if out.retryPolicy.MaxAttempts == 0 { + out.retryPolicy.MaxAttempts = 2 + } + return out, nil +} + +func firstValidator(v reqval.Validator, logger mlogger.Logger) reqval.Validator { + if v != nil { + return v + } + return reqval.New(reqval.Dependencies{Logger: logger}) +} + +func firstIdempotency(v idem.Service, logger mlogger.Logger) idem.Service { + if v != nil { + return v + } + return idem.New(idem.Dependencies{Logger: logger}) +} + +func firstQuoteResolver(v qsnap.Resolver, logger mlogger.Logger) qsnap.Resolver { + if v != nil { + return v + } + return qsnap.New(qsnap.Dependencies{Logger: logger}) +} + +func firstAggregateFactory(v agg.Factory, logger mlogger.Logger) agg.Factory { + if v != nil { + return v + } + return agg.New(agg.Dependencies{Logger: logger}) +} + +func firstPlanCompiler(v xplan.Compiler, logger mlogger.Logger) xplan.Compiler { + if v != nil { + return v + } + return xplan.New(xplan.Dependencies{Logger: logger}) +} + +func firstStateMachine(v ostate.StateMachine, logger mlogger.Logger) ostate.StateMachine { + if v != nil { + return v + } + return ostate.New(ostate.Dependencies{Logger: logger}) +} + +func firstScheduler(v ssched.Runtime, logger mlogger.Logger) ssched.Runtime { + if v != nil { + return v + } + return ssched.New(ssched.Dependencies{Logger: logger}) +} + +func firstExecutors(v sexec.Registry, logger mlogger.Logger) sexec.Registry { + if v != nil { + return v + } + return sexec.New(sexec.Dependencies{ + Logger: logger, + Ledger: defaultLedgerExecutor{}, + Crypto: defaultCryptoExecutor{}, + ProviderSettlement: defaultProviderSettlementExecutor{}, + CardPayout: defaultCardPayoutExecutor{}, + ObserveConfirm: defaultObserveConfirmExecutor{}, + }) +} + +func firstReconciler(v erecon.Reconciler, logger mlogger.Logger) erecon.Reconciler { + if v != nil { + return v + } + return erecon.New(erecon.Dependencies{Logger: logger}) +} + +func firstMapper(v prmap.Mapper, logger mlogger.Logger) prmap.Mapper { + if v != nil { + return v + } + return prmap.New(prmap.Dependencies{Logger: logger}) +} + +func (s *svc) nowUTC() time.Time { + return s.now().UTC() +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service_e2e_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service_e2e_test.go new file mode 100644 index 00000000..02b1efa5 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service_e2e_test.go @@ -0,0 +1,670 @@ +package psvc + +import ( + "bytes" + "context" + "errors" + "sort" + "strings" + "sync" + "testing" + "time" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/erecon" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/oobs" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/prepo" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/sexec" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/ssched" + "github.com/tech/sendico/payments/storage/model" + quotestorage "github.com/tech/sendico/payments/storage/quote" + "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" + paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1" + tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1" + orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2" + sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func TestExecutePayment_EndToEndSyncSettled(t *testing.T) { + env := newTestEnv(t, func(_ string, req sexec.StepRequest) (*sexec.ExecuteOutput, error) { + step := req.StepExecution + step.State = agg.StepStateCompleted + return &sexec.ExecuteOutput{StepExecution: step}, nil + }) + env.quotes.Put(newExecutableQuote(env.orgID, "quote-sync", "intent-sync", buildLedgerRoute())) + + resp, err := env.svc.ExecutePayment(context.Background(), &orchestrationv2.ExecutePaymentRequest{ + Meta: testMeta(env.orgID, "idem-sync"), + QuotationRef: "quote-sync", + ClientPaymentRef: "client-1", + IntentRef: "intent-sync", + }) + if err != nil { + t.Fatalf("ExecutePayment returned error: %v", err) + } + if resp.GetPayment() == nil { + t.Fatal("expected payment in response") + } + if got, want := resp.GetPayment().GetState(), orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_SETTLED; got != want { + t.Fatalf("state mismatch: got=%s want=%s", got, want) + } + + getResp, err := env.svc.GetPayment(context.Background(), &orchestrationv2.GetPaymentRequest{ + Meta: testMeta(env.orgID, ""), + PaymentRef: resp.GetPayment().GetPaymentRef(), + }) + if err != nil { + t.Fatalf("GetPayment returned error: %v", err) + } + if getResp.GetPayment() == nil { + t.Fatal("expected payment from GetPayment") + } + if got, want := getResp.GetPayment().GetPaymentRef(), resp.GetPayment().GetPaymentRef(); got != want { + t.Fatalf("payment_ref mismatch: got=%q want=%q", got, want) + } + + timeline, err := env.observer.PaymentTimeline(context.Background(), oobs.PaymentTimelineInput{ + PaymentRef: resp.GetPayment().GetPaymentRef(), + }) + if err != nil { + t.Fatalf("PaymentTimeline returned error: %v", err) + } + assertTimelineHasEvent(t, timeline.Items, "created") + assertTimelineHasEvent(t, timeline.Items, "settled") +} + +func TestExecutePayment_IdempotencyMismatch(t *testing.T) { + env := newTestEnv(t, func(_ string, req sexec.StepRequest) (*sexec.ExecuteOutput, error) { + step := req.StepExecution + step.State = agg.StepStateCompleted + return &sexec.ExecuteOutput{StepExecution: step}, nil + }) + env.quotes.Put(newExecutableQuote(env.orgID, "quote-idem", "intent-idem", buildLedgerRoute())) + + _, err := env.svc.ExecutePayment(context.Background(), &orchestrationv2.ExecutePaymentRequest{ + Meta: testMeta(env.orgID, "idem-shared"), + QuotationRef: "quote-idem", + ClientPaymentRef: "client-a", + IntentRef: "intent-idem", + }) + if err != nil { + t.Fatalf("first ExecutePayment returned error: %v", err) + } + + _, err = env.svc.ExecutePayment(context.Background(), &orchestrationv2.ExecutePaymentRequest{ + Meta: testMeta(env.orgID, "idem-shared"), + QuotationRef: "quote-idem", + ClientPaymentRef: "client-b", + IntentRef: "intent-idem", + }) + if !errors.Is(err, merrors.ErrInvalidArg) { + t.Fatalf("expected invalid argument for mismatch, got %v", err) + } +} + +func TestExecutePayment_RetryThenSuccess(t *testing.T) { + env := newTestEnv(t, func(_ string, req sexec.StepRequest) (*sexec.ExecuteOutput, error) { + if req.StepExecution.Attempt == 1 { + return nil, errors.New("temporary ledger failure") + } + step := req.StepExecution + step.State = agg.StepStateCompleted + return &sexec.ExecuteOutput{StepExecution: step}, nil + }) + env.quotes.Put(newExecutableQuote(env.orgID, "quote-retry", "intent-retry", buildLedgerRoute())) + + resp, err := env.svc.ExecutePayment(context.Background(), &orchestrationv2.ExecutePaymentRequest{ + Meta: testMeta(env.orgID, "idem-retry"), + QuotationRef: "quote-retry", + ClientPaymentRef: "client-retry", + IntentRef: "intent-retry", + }) + if err != nil { + t.Fatalf("ExecutePayment returned error: %v", err) + } + if got, want := resp.GetPayment().GetState(), orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_SETTLED; got != want { + t.Fatalf("state mismatch: got=%s want=%s", got, want) + } + if len(resp.GetPayment().GetStepExecutions()) != 1 { + t.Fatalf("expected one step execution, got=%d", len(resp.GetPayment().GetStepExecutions())) + } + if got, want := resp.GetPayment().GetStepExecutions()[0].GetAttempt(), uint32(2); got != want { + t.Fatalf("attempt mismatch: got=%d want=%d", got, want) + } +} + +func TestReconcileExternal_AdvancesAsyncPaymentToSettled(t *testing.T) { + var observeStepRef string + env := newTestEnv(t, func(kind string, req sexec.StepRequest) (*sexec.ExecuteOutput, error) { + step := req.StepExecution + switch kind { + case "card_payout": + step.State = agg.StepStateCompleted + return &sexec.ExecuteOutput{StepExecution: step}, nil + case "observe_confirm": + step.State = agg.StepStateRunning + step.ExternalRefs = append(step.ExternalRefs, agg.ExternalRef{ + GatewayInstanceID: "gw-card", + Kind: erecon.ExternalRefKindOperation, + Ref: "op-1", + }) + observeStepRef = step.StepRef + return &sexec.ExecuteOutput{StepExecution: step, Async: true}, nil + default: + step.State = agg.StepStateCompleted + return &sexec.ExecuteOutput{StepExecution: step}, nil + } + }) + env.quotes.Put(newExecutableQuote(env.orgID, "quote-async", "intent-async", buildCardRoute())) + + resp, err := env.svc.ExecutePayment(context.Background(), &orchestrationv2.ExecutePaymentRequest{ + Meta: testMeta(env.orgID, "idem-async"), + QuotationRef: "quote-async", + ClientPaymentRef: "client-async", + IntentRef: "intent-async", + }) + if err != nil { + t.Fatalf("ExecutePayment returned error: %v", err) + } + if got, want := resp.GetPayment().GetState(), orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_EXECUTING; got != want { + t.Fatalf("expected executing before external reconcile, got=%s", got) + } + if observeStepRef == "" { + t.Fatal("expected observe step ref to be captured") + } + + reconciled, err := env.svc.ReconcileExternal(context.Background(), ReconcileExternalInput{ + OrganizationRef: env.orgID.Hex(), + PaymentRef: resp.GetPayment().GetPaymentRef(), + Event: erecon.Event{ + Gateway: &erecon.GatewayEvent{ + StepRef: observeStepRef, + OperationRef: "op-1", + Status: erecon.GatewayStatusSuccess, + }, + }, + }) + if err != nil { + t.Fatalf("ReconcileExternal returned error: %v", err) + } + if reconciled == nil || reconciled.Payment == nil { + t.Fatal("expected reconciled payment") + } + if got, want := reconciled.Payment.GetState(), orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_SETTLED; got != want { + t.Fatalf("state mismatch after reconcile: got=%s want=%s", got, want) + } +} + +func TestListPayments_FiltersAndCursor(t *testing.T) { + env := newTestEnv(t, func(_ string, req sexec.StepRequest) (*sexec.ExecuteOutput, error) { + step := req.StepExecution + step.State = agg.StepStateCompleted + return &sexec.ExecuteOutput{StepExecution: step}, nil + }) + base := time.Date(2026, time.February, 20, 10, 0, 0, 0, time.UTC) + + mustCreatePayment(t, env.repo, testPayment(env.orgID, "p-1", "idem-1", "quote-list", agg.StateExecuting, base.Add(3*time.Minute))) + mustCreatePayment(t, env.repo, testPayment(env.orgID, "p-2", "idem-2", "quote-list", agg.StateSettled, base.Add(2*time.Minute))) + mustCreatePayment(t, env.repo, testPayment(env.orgID, "p-3", "idem-3", "quote-list", agg.StateFailed, base.Add(1*time.Minute))) + mustCreatePayment(t, env.repo, testPayment(env.orgID, "p-4", "idem-4", "quote-other", agg.StateExecuting, base.Add(4*time.Minute))) + + first, err := env.svc.ListPayments(context.Background(), &orchestrationv2.ListPaymentsRequest{ + Meta: testMeta(env.orgID, ""), + States: []orchestrationv2.OrchestrationState{orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_EXECUTING, orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_SETTLED}, + QuotationRef: "quote-list", + Page: &paginationv1.CursorPageRequest{Limit: 1}, + }) + if err != nil { + t.Fatalf("ListPayments(first) returned error: %v", err) + } + if len(first.GetPayments()) != 1 { + t.Fatalf("expected one payment in first page, got=%d", len(first.GetPayments())) + } + if got, want := first.GetPayments()[0].GetPaymentRef(), "p-1"; got != want { + t.Fatalf("first page payment mismatch: got=%q want=%q", got, want) + } + if strings.TrimSpace(first.GetPage().GetNextCursor()) == "" { + t.Fatal("expected next cursor") + } + + second, err := env.svc.ListPayments(context.Background(), &orchestrationv2.ListPaymentsRequest{ + Meta: testMeta(env.orgID, ""), + States: []orchestrationv2.OrchestrationState{orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_EXECUTING, orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_SETTLED}, + QuotationRef: "quote-list", + Page: &paginationv1.CursorPageRequest{ + Limit: 2, + Cursor: first.GetPage().GetNextCursor(), + }, + }) + if err != nil { + t.Fatalf("ListPayments(second) returned error: %v", err) + } + if len(second.GetPayments()) != 1 { + t.Fatalf("expected one payment in second page, got=%d", len(second.GetPayments())) + } + if got, want := second.GetPayments()[0].GetPaymentRef(), "p-2"; got != want { + t.Fatalf("second page payment mismatch: got=%q want=%q", got, want) + } +} + +func assertTimelineHasEvent(t *testing.T, items []oobs.TimelineEntry, event string) { + t.Helper() + for i := range items { + if items[i].Event == event { + return + } + } + t.Fatalf("timeline missing event %q", event) +} + +type testEnv struct { + svc Service + repo *memoryRepo + quotes *memoryQuoteStore + observer oobs.Observer + orgID bson.ObjectID +} + +func newTestEnv(t *testing.T, handler func(kind string, req sexec.StepRequest) (*sexec.ExecuteOutput, error)) *testEnv { + t.Helper() + repo := newMemoryRepo(func() time.Time { + return time.Now().UTC() + }) + quotes := newMemoryQuoteStore() + observer, err := oobs.New(oobs.Dependencies{}) + if err != nil { + t.Fatalf("oobs.New returned error: %v", err) + } + + script := &scriptedExecutors{handler: handler} + registry := sexec.New(sexec.Dependencies{ + Ledger: script, + Crypto: script, + ProviderSettlement: script, + CardPayout: script, + ObserveConfirm: script, + }) + + svc, err := New(Dependencies{ + QuoteStore: quotes, + Repository: repo, + Executors: registry, + Observer: observer, + RetryPolicy: ssched.RetryPolicy{MaxAttempts: 2}, + MaxTicks: 20, + }) + if err != nil { + t.Fatalf("New returned error: %v", err) + } + return &testEnv{ + svc: svc, + repo: repo, + quotes: quotes, + observer: observer, + orgID: bson.NewObjectID(), + } +} + +type scriptedExecutors struct { + handler func(kind string, req sexec.StepRequest) (*sexec.ExecuteOutput, error) +} + +func (s *scriptedExecutors) ExecuteLedger(_ context.Context, req sexec.StepRequest) (*sexec.ExecuteOutput, error) { + return s.handler("ledger", req) +} +func (s *scriptedExecutors) ExecuteCrypto(_ context.Context, req sexec.StepRequest) (*sexec.ExecuteOutput, error) { + return s.handler("crypto", req) +} +func (s *scriptedExecutors) ExecuteProviderSettlement(_ context.Context, req sexec.StepRequest) (*sexec.ExecuteOutput, error) { + return s.handler("provider_settlement", req) +} +func (s *scriptedExecutors) ExecuteCardPayout(_ context.Context, req sexec.StepRequest) (*sexec.ExecuteOutput, error) { + return s.handler("card_payout", req) +} +func (s *scriptedExecutors) ExecuteObserveConfirm(_ context.Context, req sexec.StepRequest) (*sexec.ExecuteOutput, error) { + return s.handler("observe_confirm", req) +} + +type memoryQuoteStore struct { + mu sync.Mutex + data map[string]*model.PaymentQuoteRecord +} + +func newMemoryQuoteStore() *memoryQuoteStore { + return &memoryQuoteStore{data: map[string]*model.PaymentQuoteRecord{}} +} + +func (s *memoryQuoteStore) Put(record *model.PaymentQuoteRecord) { + s.mu.Lock() + defer s.mu.Unlock() + s.data[quoteKey(record.OrganizationRef, record.QuoteRef)] = cloneQuoteRecord(record) +} + +func (s *memoryQuoteStore) GetByRef(_ context.Context, orgRef bson.ObjectID, quoteRef string) (*model.PaymentQuoteRecord, error) { + s.mu.Lock() + defer s.mu.Unlock() + record := s.data[quoteKey(orgRef, quoteRef)] + if record == nil { + return nil, quotestorage.ErrQuoteNotFound + } + return cloneQuoteRecord(record), nil +} + +func quoteKey(orgRef bson.ObjectID, quoteRef string) string { + return orgRef.Hex() + "|" + strings.TrimSpace(quoteRef) +} + +func cloneQuoteRecord(in *model.PaymentQuoteRecord) *model.PaymentQuoteRecord { + if in == nil { + return nil + } + data, _ := bson.Marshal(in) + out := &model.PaymentQuoteRecord{} + _ = bson.Unmarshal(data, out) + return out +} + +type memoryRepo struct { + mu sync.Mutex + now func() time.Time + + byID map[bson.ObjectID]*agg.Payment + byPaymentRef map[string]bson.ObjectID + byIdempotency map[string]bson.ObjectID +} + +func newMemoryRepo(now func() time.Time) *memoryRepo { + return &memoryRepo{ + now: now, + byID: map[bson.ObjectID]*agg.Payment{}, + byPaymentRef: map[string]bson.ObjectID{}, + byIdempotency: map[string]bson.ObjectID{}, + } +} + +func (r *memoryRepo) Create(_ context.Context, payment *agg.Payment) error { + r.mu.Lock() + defer r.mu.Unlock() + if payment == nil { + return merrors.InvalidArgument("payment is required") + } + if payment.ID.IsZero() { + payment.ID = bson.NewObjectID() + } + if strings.TrimSpace(payment.PaymentRef) == "" { + payment.PaymentRef = payment.ID.Hex() + } + if payment.CreatedAt.IsZero() { + payment.CreatedAt = r.now().UTC() + } + payment.UpdatedAt = payment.CreatedAt + if payment.Version == 0 { + payment.Version = 1 + } + + refKey := repoPaymentRefKey(payment.OrganizationRef, payment.PaymentRef) + if _, exists := r.byPaymentRef[refKey]; exists { + return prepo.ErrDuplicatePayment + } + idemKey := repoIdemKey(payment.OrganizationRef, payment.IdempotencyKey) + if _, exists := r.byIdempotency[idemKey]; exists { + return prepo.ErrDuplicatePayment + } + cloned := clonePayment(payment) + r.byID[cloned.ID] = cloned + r.byPaymentRef[refKey] = cloned.ID + r.byIdempotency[idemKey] = cloned.ID + *payment = *clonePayment(cloned) + return nil +} + +func (r *memoryRepo) UpdateCAS(_ context.Context, payment *agg.Payment, expectedVersion uint64) error { + r.mu.Lock() + defer r.mu.Unlock() + if payment == nil { + return merrors.InvalidArgument("payment is required") + } + stored := r.byID[payment.ID] + if stored == nil { + return prepo.ErrPaymentNotFound + } + if stored.OrganizationRef != payment.OrganizationRef { + return prepo.ErrPaymentNotFound + } + if stored.Version != expectedVersion { + return prepo.ErrVersionConflict + } + next := clonePayment(payment) + next.Version = expectedVersion + 1 + next.UpdatedAt = r.now().UTC() + r.byID[next.ID] = next + *payment = *clonePayment(next) + return nil +} + +func (r *memoryRepo) GetByPaymentRef(_ context.Context, orgRef bson.ObjectID, paymentRef string) (*agg.Payment, error) { + r.mu.Lock() + defer r.mu.Unlock() + id, ok := r.byPaymentRef[repoPaymentRefKey(orgRef, paymentRef)] + if !ok { + return nil, prepo.ErrPaymentNotFound + } + return clonePayment(r.byID[id]), nil +} + +func (r *memoryRepo) GetByIdempotencyKey(_ context.Context, orgRef bson.ObjectID, idempotencyKey string) (*agg.Payment, error) { + r.mu.Lock() + defer r.mu.Unlock() + id, ok := r.byIdempotency[repoIdemKey(orgRef, idempotencyKey)] + if !ok { + return nil, prepo.ErrPaymentNotFound + } + return clonePayment(r.byID[id]), nil +} + +func (r *memoryRepo) ListByQuotationRef(_ context.Context, in prepo.ListByQuotationRefInput) (*prepo.ListOutput, error) { + r.mu.Lock() + defer r.mu.Unlock() + items := make([]*agg.Payment, 0) + for _, payment := range r.byID { + if payment.OrganizationRef != in.OrganizationRef { + continue + } + if payment.QuotationRef != in.QuotationRef { + continue + } + if !isBeforeCursor(payment, in.Cursor) { + continue + } + items = append(items, clonePayment(payment)) + } + return paginatePayments(items, in.Limit), nil +} + +func (r *memoryRepo) ListByState(_ context.Context, in prepo.ListByStateInput) (*prepo.ListOutput, error) { + r.mu.Lock() + defer r.mu.Unlock() + items := make([]*agg.Payment, 0) + for _, payment := range r.byID { + if payment.OrganizationRef != in.OrganizationRef { + continue + } + if payment.State != in.State { + continue + } + if !isBeforeCursor(payment, in.Cursor) { + continue + } + items = append(items, clonePayment(payment)) + } + return paginatePayments(items, in.Limit), nil +} + +func repoPaymentRefKey(orgRef bson.ObjectID, paymentRef string) string { + return orgRef.Hex() + "|" + strings.TrimSpace(paymentRef) +} + +func repoIdemKey(orgRef bson.ObjectID, key string) string { + return orgRef.Hex() + "|" + strings.TrimSpace(key) +} + +func clonePayment(in *agg.Payment) *agg.Payment { + if in == nil { + return nil + } + data, _ := bson.Marshal(in) + out := &agg.Payment{} + _ = bson.Unmarshal(data, out) + return out +} + +func isBeforeCursor(payment *agg.Payment, cursor *prepo.ListCursor) bool { + if cursor == nil { + return true + } + if payment.CreatedAt.Before(cursor.CreatedAt) { + return true + } + if payment.CreatedAt.After(cursor.CreatedAt) { + return false + } + return bytes.Compare(payment.ID[:], cursor.ID[:]) < 0 +} + +func paginatePayments(items []*agg.Payment, limit int32) *prepo.ListOutput { + 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 limit <= 0 { + limit = 50 + } + max := int(limit) + if max > len(items) { + max = len(items) + } + page := items[:max] + out := &prepo.ListOutput{Items: page} + if len(items) > max && max > 0 { + last := page[len(page)-1] + out.NextCursor = &prepo.ListCursor{ + CreatedAt: last.CreatedAt.UTC(), + ID: last.ID, + } + } + return out +} + +func newExecutableQuote(orgRef bson.ObjectID, quoteRef, intentRef string, route *paymenttypes.QuoteRouteSpecification) *model.PaymentQuoteRecord { + now := time.Now().UTC() + return &model.PaymentQuoteRecord{ + Base: modelBase(now), + OrganizationBoundBase: pm.OrganizationBoundBase{ + OrganizationRef: orgRef, + }, + QuoteRef: quoteRef, + Intent: model.PaymentIntent{ + Ref: intentRef, + Kind: model.PaymentKindPayout, + Source: testLedgerEndpoint("ledger-src"), + Destination: testLedgerEndpoint("ledger-dst"), + Amount: &paymenttypes.Money{Amount: "10", Currency: "USD"}, + SettlementCurrency: "USD", + }, + Quote: &model.PaymentQuoteSnapshot{ + QuoteRef: quoteRef, + DebitAmount: &paymenttypes.Money{Amount: "10", Currency: "USD"}, + Route: route, + }, + StatusV2: &model.QuoteStatusV2{State: model.QuoteStateExecutable}, + ExpiresAt: now.Add(1 * time.Hour), + } +} + +func buildLedgerRoute() *paymenttypes.QuoteRouteSpecification { + return &paymenttypes.QuoteRouteSpecification{ + Hops: []*paymenttypes.QuoteRouteHop{ + {Index: 10, Rail: "LEDGER"}, + {Index: 20, Rail: "LEDGER"}, + }, + } +} + +func buildCardRoute() *paymenttypes.QuoteRouteSpecification { + return &paymenttypes.QuoteRouteSpecification{ + Rail: "CARD_PAYOUT", + Provider: "gw-card", + Network: "visa", + } +} + +func testMeta(orgRef bson.ObjectID, idempotencyKey string) *sharedv1.RequestMeta { + meta := &sharedv1.RequestMeta{OrganizationRef: orgRef.Hex()} + if strings.TrimSpace(idempotencyKey) != "" { + meta.Trace = &tracev1.TraceContext{IdempotencyKey: idempotencyKey} + } + return meta +} + +func modelBase(at time.Time) storable.Base { + return storable.Base{ + ID: bson.NewObjectID(), + CreatedAt: at.UTC(), + UpdatedAt: at.UTC(), + } +} + +func testPayment(orgRef bson.ObjectID, paymentRef, idem, quoteRef string, state agg.State, createdAt time.Time) *agg.Payment { + return &agg.Payment{ + Base: modelBase(createdAt), + OrganizationBoundBase: pm.OrganizationBoundBase{ + OrganizationRef: orgRef, + }, + PaymentRef: paymentRef, + IdempotencyKey: idem, + QuotationRef: quoteRef, + ClientPaymentRef: "client-" + paymentRef, + IntentSnapshot: model.PaymentIntent{ + Ref: "intent-" + paymentRef, + Kind: model.PaymentKindPayout, + Source: testLedgerEndpoint("ledger-src"), + Destination: testLedgerEndpoint("ledger-dst"), + Amount: &paymenttypes.Money{Amount: "10", Currency: "USD"}, + SettlementCurrency: "USD", + }, + QuoteSnapshot: &model.PaymentQuoteSnapshot{ + QuoteRef: quoteRef, + DebitAmount: &paymenttypes.Money{Amount: "10", Currency: "USD"}, + Route: buildLedgerRoute(), + }, + State: state, + Version: 1, + StepExecutions: []agg.StepExecution{ + {StepRef: "step-1", StepCode: "edge.10_20.ledger.move", State: agg.StepStateCompleted, Attempt: 1}, + }, + } +} + +func mustCreatePayment(t *testing.T, repo prepo.Repository, payment *agg.Payment) { + t.Helper() + if err := repo.Create(context.Background(), payment); err != nil { + t.Fatalf("Create returned error: %v", err) + } +} + +func testLedgerEndpoint(account string) model.PaymentEndpoint { + return model.PaymentEndpoint{ + Type: model.EndpointTypeLedger, + Ledger: &model.LedgerEndpoint{ + LedgerAccountRef: strings.TrimSpace(account), + }, + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/fake_store_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/fake_store_test.go new file mode 100644 index 00000000..3b792ec0 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/fake_store_test.go @@ -0,0 +1,20 @@ +package qsnap + +import ( + "context" + + "github.com/tech/sendico/payments/storage/model" + quotestorage "github.com/tech/sendico/payments/storage/quote" + "go.mongodb.org/mongo-driver/v2/bson" +) + +type fakeStore struct { + getByRefFn func(ctx context.Context, orgRef bson.ObjectID, quoteRef string) (*model.PaymentQuoteRecord, error) +} + +func (f *fakeStore) GetByRef(ctx context.Context, orgRef bson.ObjectID, quoteRef string) (*model.PaymentQuoteRecord, error) { + if f.getByRefFn == nil { + return nil, quotestorage.ErrQuoteNotFound + } + return f.getByRefFn(ctx, orgRef, quoteRef) +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/module.go index 655d54b2..3b9082f0 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/module.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/module.go @@ -5,6 +5,7 @@ import ( "time" "github.com/tech/sendico/payments/storage/model" + "github.com/tech/sendico/pkg/mlogger" "go.mongodb.org/mongo-driver/v2/bson" ) @@ -33,8 +34,23 @@ type Output struct { QuoteSnapshot *model.PaymentQuoteSnapshot } -func New() Resolver { +// Dependencies configures quote resolver integrations. +type Dependencies struct { + Logger mlogger.Logger + Now func() time.Time +} + +func New(deps ...Dependencies) Resolver { + var dep Dependencies + if len(deps) > 0 { + dep = deps[0] + } + now := dep.Now + if now == nil { + now = time.Now + } return &svc{ - now: time.Now, + logger: dep.Logger.Named("qsnap"), + now: now, } } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/resolve_errors_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/resolve_errors_test.go new file mode 100644 index 00000000..1ce56463 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/resolve_errors_test.go @@ -0,0 +1,189 @@ +package qsnap + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/tech/sendico/payments/storage/model" + quotestorage "github.com/tech/sendico/payments/storage/quote" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func TestResolve_NotFound(t *testing.T) { + resolver := New() + + _, err := resolver.Resolve(context.Background(), &fakeStore{ + getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { + return nil, quotestorage.ErrQuoteNotFound + }, + }, Input{ + OrganizationID: bson.NewObjectID(), + QuotationRef: "quote-ref", + }) + if !errors.Is(err, ErrQuoteNotFound) { + t.Fatalf("expected ErrQuoteNotFound, got %v", err) + } +} + +func TestResolve_Expired(t *testing.T) { + now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC) + resolver := &svc{ + now: func() time.Time { return now }, + } + + _, err := resolver.Resolve(context.Background(), &fakeStore{ + getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { + return &model.PaymentQuoteRecord{ + QuoteRef: "quote-ref", + Intent: model.PaymentIntent{ + Kind: model.PaymentKindPayout, + }, + Quote: &model.PaymentQuoteSnapshot{}, + StatusV2: &model.QuoteStatusV2{ + State: model.QuoteStateExecutable, + }, + ExpiresAt: now.Add(-time.Second), + }, nil + }, + }, Input{ + OrganizationID: bson.NewObjectID(), + QuotationRef: "quote-ref", + }) + if !errors.Is(err, ErrQuoteExpired) { + t.Fatalf("expected ErrQuoteExpired, got %v", err) + } +} + +func TestResolve_NotExecutableState(t *testing.T) { + now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC) + resolver := &svc{ + now: func() time.Time { return now }, + } + + _, err := resolver.Resolve(context.Background(), &fakeStore{ + getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { + return &model.PaymentQuoteRecord{ + QuoteRef: "quote-ref", + Intent: model.PaymentIntent{ + Kind: model.PaymentKindPayout, + }, + Quote: &model.PaymentQuoteSnapshot{}, + StatusV2: &model.QuoteStatusV2{ + State: model.QuoteStateBlocked, + BlockReason: model.QuoteBlockReasonRouteUnavailable, + }, + ExpiresAt: now.Add(time.Minute), + }, nil + }, + }, Input{ + OrganizationID: bson.NewObjectID(), + QuotationRef: "quote-ref", + }) + if !errors.Is(err, ErrQuoteNotExecutable) { + t.Fatalf("expected ErrQuoteNotExecutable, got %v", err) + } +} + +func TestResolve_NotExecutableExecutionNote(t *testing.T) { + now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC) + resolver := &svc{ + now: func() time.Time { return now }, + } + + _, err := resolver.Resolve(context.Background(), &fakeStore{ + getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { + return &model.PaymentQuoteRecord{ + QuoteRef: "quote-ref", + Intent: model.PaymentIntent{ + Kind: model.PaymentKindPayout, + }, + Quote: &model.PaymentQuoteSnapshot{}, + ExecutionNote: "quote will not be executed", + ExpiresAt: now.Add(time.Minute), + }, nil + }, + }, Input{ + OrganizationID: bson.NewObjectID(), + QuotationRef: "quote-ref", + }) + if !errors.Is(err, ErrQuoteNotExecutable) { + t.Fatalf("expected ErrQuoteNotExecutable, got %v", err) + } +} + +func TestResolve_ShapeMismatch(t *testing.T) { + now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC) + resolver := &svc{ + now: func() time.Time { return now }, + } + + _, err := resolver.Resolve(context.Background(), &fakeStore{ + getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { + return &model.PaymentQuoteRecord{ + QuoteRef: "quote-ref", + Intents: []model.PaymentIntent{ + {Kind: model.PaymentKindPayout}, + {Kind: model.PaymentKindPayout}, + }, + Quotes: []*model.PaymentQuoteSnapshot{ + {}, + }, + ExpiresAt: now.Add(time.Minute), + }, nil + }, + }, Input{ + OrganizationID: bson.NewObjectID(), + QuotationRef: "quote-ref", + IntentRef: "intent-1", + }) + if !errors.Is(err, ErrQuoteShapeMismatch) { + t.Fatalf("expected ErrQuoteShapeMismatch, got %v", err) + } +} + +func TestResolve_InputValidation(t *testing.T) { + resolver := New() + orgID := bson.NewObjectID() + + tests := []struct { + name string + store Store + in Input + }{ + { + name: "nil store", + store: nil, + in: Input{ + OrganizationID: orgID, + QuotationRef: "quote-ref", + }, + }, + { + name: "empty org id", + store: &fakeStore{}, + in: Input{ + QuotationRef: "quote-ref", + }, + }, + { + name: "empty quotation ref", + store: &fakeStore{}, + in: Input{ + OrganizationID: orgID, + QuotationRef: " ", + }, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + _, err := resolver.Resolve(context.Background(), tt.store, tt.in) + if err == nil { + t.Fatal("expected error") + } + }) + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/service_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/resolve_shapes_test.go similarity index 58% rename from api/payments/orchestrator/internal/service/orchestrationv2/qsnap/service_test.go rename to api/payments/orchestrator/internal/service/orchestrationv2/qsnap/resolve_shapes_test.go index eb63e11c..5341566b 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/service_test.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/resolve_shapes_test.go @@ -7,7 +7,6 @@ import ( "time" "github.com/tech/sendico/payments/storage/model" - quotestorage "github.com/tech/sendico/payments/storage/quote" paymenttypes "github.com/tech/sendico/pkg/payments/types" "go.mongodb.org/mongo-driver/v2/bson" ) @@ -244,191 +243,3 @@ func TestResolve_MultiShapeIntentRefNotFound(t *testing.T) { t.Fatalf("expected ErrIntentRefNotFound, got %v", err) } } - -func TestResolve_NotFound(t *testing.T) { - resolver := New() - - _, err := resolver.Resolve(context.Background(), &fakeStore{ - getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { - return nil, quotestorage.ErrQuoteNotFound - }, - }, Input{ - OrganizationID: bson.NewObjectID(), - QuotationRef: "quote-ref", - }) - if !errors.Is(err, ErrQuoteNotFound) { - t.Fatalf("expected ErrQuoteNotFound, got %v", err) - } -} - -func TestResolve_Expired(t *testing.T) { - now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC) - resolver := &svc{ - now: func() time.Time { return now }, - } - - _, err := resolver.Resolve(context.Background(), &fakeStore{ - getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { - return &model.PaymentQuoteRecord{ - QuoteRef: "quote-ref", - Intent: model.PaymentIntent{ - Kind: model.PaymentKindPayout, - }, - Quote: &model.PaymentQuoteSnapshot{}, - StatusV2: &model.QuoteStatusV2{ - State: model.QuoteStateExecutable, - }, - ExpiresAt: now.Add(-time.Second), - }, nil - }, - }, Input{ - OrganizationID: bson.NewObjectID(), - QuotationRef: "quote-ref", - }) - if !errors.Is(err, ErrQuoteExpired) { - t.Fatalf("expected ErrQuoteExpired, got %v", err) - } -} - -func TestResolve_NotExecutableState(t *testing.T) { - now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC) - resolver := &svc{ - now: func() time.Time { return now }, - } - - _, err := resolver.Resolve(context.Background(), &fakeStore{ - getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { - return &model.PaymentQuoteRecord{ - QuoteRef: "quote-ref", - Intent: model.PaymentIntent{ - Kind: model.PaymentKindPayout, - }, - Quote: &model.PaymentQuoteSnapshot{}, - StatusV2: &model.QuoteStatusV2{ - State: model.QuoteStateBlocked, - BlockReason: model.QuoteBlockReasonRouteUnavailable, - }, - ExpiresAt: now.Add(time.Minute), - }, nil - }, - }, Input{ - OrganizationID: bson.NewObjectID(), - QuotationRef: "quote-ref", - }) - if !errors.Is(err, ErrQuoteNotExecutable) { - t.Fatalf("expected ErrQuoteNotExecutable, got %v", err) - } -} - -func TestResolve_NotExecutableExecutionNote(t *testing.T) { - now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC) - resolver := &svc{ - now: func() time.Time { return now }, - } - - _, err := resolver.Resolve(context.Background(), &fakeStore{ - getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { - return &model.PaymentQuoteRecord{ - QuoteRef: "quote-ref", - Intent: model.PaymentIntent{ - Kind: model.PaymentKindPayout, - }, - Quote: &model.PaymentQuoteSnapshot{}, - ExecutionNote: "quote will not be executed", - ExpiresAt: now.Add(time.Minute), - }, nil - }, - }, Input{ - OrganizationID: bson.NewObjectID(), - QuotationRef: "quote-ref", - }) - if !errors.Is(err, ErrQuoteNotExecutable) { - t.Fatalf("expected ErrQuoteNotExecutable, got %v", err) - } -} - -func TestResolve_ShapeMismatch(t *testing.T) { - now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC) - resolver := &svc{ - now: func() time.Time { return now }, - } - - _, err := resolver.Resolve(context.Background(), &fakeStore{ - getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { - return &model.PaymentQuoteRecord{ - QuoteRef: "quote-ref", - Intents: []model.PaymentIntent{ - {Kind: model.PaymentKindPayout}, - {Kind: model.PaymentKindPayout}, - }, - Quotes: []*model.PaymentQuoteSnapshot{ - {}, - }, - ExpiresAt: now.Add(time.Minute), - }, nil - }, - }, Input{ - OrganizationID: bson.NewObjectID(), - QuotationRef: "quote-ref", - IntentRef: "intent-1", - }) - if !errors.Is(err, ErrQuoteShapeMismatch) { - t.Fatalf("expected ErrQuoteShapeMismatch, got %v", err) - } -} - -func TestResolve_InputValidation(t *testing.T) { - resolver := New() - orgID := bson.NewObjectID() - - tests := []struct { - name string - store Store - in Input - }{ - { - name: "nil store", - store: nil, - in: Input{ - OrganizationID: orgID, - QuotationRef: "quote-ref", - }, - }, - { - name: "empty org id", - store: &fakeStore{}, - in: Input{ - QuotationRef: "quote-ref", - }, - }, - { - name: "empty quotation ref", - store: &fakeStore{}, - in: Input{ - OrganizationID: orgID, - QuotationRef: " ", - }, - }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - _, err := resolver.Resolve(context.Background(), tt.store, tt.in) - if err == nil { - t.Fatal("expected error") - } - }) - } -} - -type fakeStore struct { - getByRefFn func(ctx context.Context, orgRef bson.ObjectID, quoteRef string) (*model.PaymentQuoteRecord, error) -} - -func (f *fakeStore) GetByRef(ctx context.Context, orgRef bson.ObjectID, quoteRef string) (*model.PaymentQuoteRecord, error) { - if f.getByRefFn == nil { - return nil, quotestorage.ErrQuoteNotFound - } - return f.getByRefFn(ctx, orgRef, quoteRef) -} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/service.go b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/service.go index cf5168fc..91c56a2c 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/service.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/service.go @@ -3,18 +3,21 @@ package qsnap import ( "context" "errors" - "fmt" "strings" "time" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xerr" "github.com/tech/sendico/payments/storage/model" quotestorage "github.com/tech/sendico/payments/storage/quote" "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 { - now func() time.Time + logger mlogger.Logger + now func() time.Time } type resolvedQuoteItem struct { @@ -27,7 +30,28 @@ func (s *svc) Resolve( ctx context.Context, store Store, in Input, -) (*Output, error) { +) (out *Output, err error) { + logger := s.logger + logger.Debug("Starting Resolve", + zap.String("organization_ref", in.OrganizationID.Hex()), + zap.String("quotation_ref", strings.TrimSpace(in.QuotationRef)), + zap.String("intent_ref", strings.TrimSpace(in.IntentRef)), + ) + defer func(start time.Time) { + fields := []zap.Field{zap.Int64("duration_ms", time.Since(start).Milliseconds())} + if out != nil { + fields = append(fields, + zap.String("quotation_ref", strings.TrimSpace(out.QuotationRef)), + zap.String("intent_ref", strings.TrimSpace(out.IntentRef)), + ) + } + if err != nil { + logger.Warn("Failed to resolve", append(fields, zap.Error(err))...) + return + } + logger.Debug("Completed Resolve", fields...) + }(time.Now()) + if store == nil { return nil, merrors.InvalidArgument("quotes store is required") } @@ -69,12 +93,13 @@ func (s *svc) Resolve( item.Quote.QuoteRef = outputRef } - return &Output{ + out = &Output{ QuotationRef: outputRef, IntentRef: firstNonEmpty(strings.TrimSpace(item.Intent.Ref), intentRef), IntentSnapshot: item.Intent, QuoteSnapshot: item.Quote, - }, nil + } + return out, nil } func ensureExecutable( @@ -90,7 +115,7 @@ func ensureExecutable( } if note := strings.TrimSpace(record.ExecutionNote); note != "" { - return fmt.Errorf("%w: %s", ErrQuoteNotExecutable, note) + return xerr.Wrapf(ErrQuoteNotExecutable, "%s", note) } if status == nil { @@ -106,23 +131,23 @@ func ensureExecutable( case model.QuoteStateBlocked: reason := strings.TrimSpace(string(status.BlockReason)) if reason != "" && reason != string(model.QuoteBlockReasonUnspecified) { - return fmt.Errorf("%w: blocked (%s)", ErrQuoteNotExecutable, reason) + return xerr.Wrapf(ErrQuoteNotExecutable, "blocked (%s)", reason) } - return fmt.Errorf("%w: blocked", ErrQuoteNotExecutable) + return xerr.Wrapf(ErrQuoteNotExecutable, "blocked") case model.QuoteStateIndicative: - return fmt.Errorf("%w: indicative", ErrQuoteNotExecutable) + return xerr.Wrapf(ErrQuoteNotExecutable, "indicative") default: state := strings.TrimSpace(string(status.State)) if state == "" { - return fmt.Errorf("%w: unspecified status", ErrQuoteNotExecutable) + return xerr.Wrapf(ErrQuoteNotExecutable, "unspecified status") } - return fmt.Errorf("%w: state=%s", ErrQuoteNotExecutable, state) + return xerr.Wrapf(ErrQuoteNotExecutable, "state=%s", state) } } func resolveRecordItem(record *model.PaymentQuoteRecord, intentRef string) (*resolvedQuoteItem, error) { if record == nil { - return nil, fmt.Errorf("%w: record is nil", ErrQuoteShapeMismatch) + return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "record is nil") } hasArrayShape := len(record.Intents) > 0 || len(record.Quotes) > 0 || len(record.StatusesV2) > 0 @@ -134,19 +159,19 @@ func resolveRecordItem(record *model.PaymentQuoteRecord, intentRef string) (*res func resolveSingleShapeItem(record *model.PaymentQuoteRecord, intentRef string) (*resolvedQuoteItem, error) { if record == nil { - return nil, fmt.Errorf("%w: record is nil", ErrQuoteShapeMismatch) + return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "record is nil") } if record.Quote == nil { - return nil, fmt.Errorf("%w: quote snapshot is empty", ErrQuoteShapeMismatch) + return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "quote snapshot is empty") } if isEmptyIntentSnapshot(record.Intent) { - return nil, fmt.Errorf("%w: intent snapshot is empty", ErrQuoteShapeMismatch) + return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "intent snapshot is empty") } if intentRef != "" { recordIntentRef := strings.TrimSpace(record.Intent.Ref) if recordIntentRef == "" || recordIntentRef != intentRef { - return nil, fmt.Errorf("%w: %s", ErrIntentRefNotFound, intentRef) + return nil, xerr.Wrapf(ErrIntentRefNotFound, "%s", intentRef) } } @@ -168,16 +193,16 @@ func resolveSingleShapeItem(record *model.PaymentQuoteRecord, intentRef string) func resolveArrayShapeItem(record *model.PaymentQuoteRecord, intentRef string) (*resolvedQuoteItem, error) { if len(record.Intents) == 0 { - return nil, fmt.Errorf("%w: intents are empty", ErrQuoteShapeMismatch) + return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "intents are empty") } if len(record.Quotes) == 0 { - return nil, fmt.Errorf("%w: quotes are empty", ErrQuoteShapeMismatch) + return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "quotes are empty") } if len(record.Intents) != len(record.Quotes) { - return nil, fmt.Errorf("%w: intents and quotes count mismatch", ErrQuoteShapeMismatch) + return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "intents and quotes count mismatch") } if len(record.StatusesV2) > 0 && len(record.StatusesV2) != len(record.Quotes) { - return nil, fmt.Errorf("%w: statuses and quotes count mismatch", ErrQuoteShapeMismatch) + return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "statuses and quotes count mismatch") } index := 0 @@ -187,18 +212,18 @@ func resolveArrayShapeItem(record *model.PaymentQuoteRecord, intentRef string) ( } selected, found := findIntentIndex(record.Intents, intentRef) if !found { - return nil, fmt.Errorf("%w: %s", ErrIntentRefNotFound, intentRef) + return nil, xerr.Wrapf(ErrIntentRefNotFound, "%s", intentRef) } index = selected } else if intentRef != "" { if strings.TrimSpace(record.Intents[0].Ref) != intentRef { - return nil, fmt.Errorf("%w: %s", ErrIntentRefNotFound, intentRef) + return nil, xerr.Wrapf(ErrIntentRefNotFound, "%s", intentRef) } } quoteSnapshot := record.Quotes[index] if quoteSnapshot == nil { - return nil, fmt.Errorf("%w: quote snapshot is nil", ErrQuoteShapeMismatch) + return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "quote snapshot is nil") } intentSnapshot, err := cloneIntentSnapshot(record.Intents[index]) @@ -213,7 +238,7 @@ func resolveArrayShapeItem(record *model.PaymentQuoteRecord, intentRef string) ( var statusSnapshot *model.QuoteStatusV2 if len(record.StatusesV2) > 0 { if record.StatusesV2[index] == nil { - return nil, fmt.Errorf("%w: status is nil", ErrQuoteShapeMismatch) + return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "status is nil") } statusSnapshot = record.StatusesV2[index] } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/reqval/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/reqval/module.go index 9909033d..2bef45c4 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/reqval/module.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/reqval/module.go @@ -1,6 +1,9 @@ package reqval -import "go.mongodb.org/mongo-driver/v2/bson" +import ( + "github.com/tech/sendico/pkg/mlogger" + "go.mongodb.org/mongo-driver/v2/bson" +) // Validator validates execute-payment inputs and returns a normalized context. type Validator interface { @@ -37,6 +40,15 @@ type Ctx struct { ClientPaymentRef string } -func New() Validator { - return &svc{} +// Dependencies configures request validator integrations. +type Dependencies struct { + Logger mlogger.Logger +} + +func New(deps ...Dependencies) Validator { + var dep Dependencies + if len(deps) > 0 { + dep = deps[0] + } + return &svc{logger: dep.Logger.Named("reqval")} } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/reqval/validator.go b/api/payments/orchestrator/internal/service/orchestrationv2/reqval/validator.go index f801a6c6..cf323994 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/reqval/validator.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/reqval/validator.go @@ -3,9 +3,12 @@ package reqval import ( "regexp" "strings" + "time" "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" ) const ( @@ -17,9 +20,39 @@ const ( var refTokenRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:/-]*$`) -type svc struct{} +type svc struct { + logger mlogger.Logger +} + +func (s *svc) Validate(req *Req) (out *Ctx, err error) { + logger := s.logger + orgRefIn := "" + if req != nil && req.Meta != nil { + orgRefIn = strings.TrimSpace(req.Meta.OrganizationRef) + } + logger.Debug("Starting Validate", + zap.String("organization_ref", orgRefIn), + zap.String("quotation_ref", strings.TrimSpace(valueOrEmpty(req, func(v *Req) string { return v.QuotationRef }))), + zap.String("intent_ref", strings.TrimSpace(valueOrEmpty(req, func(v *Req) string { return v.IntentRef }))), + zap.Bool("has_idempotency_key", strings.TrimSpace(traceKey(req)) != ""), + ) + defer func(start time.Time) { + fields := []zap.Field{zap.Int64("duration_ms", time.Since(start).Milliseconds())} + if out != nil { + fields = append(fields, + zap.String("organization_ref", out.OrganizationRef), + zap.String("quotation_ref", out.QuotationRef), + zap.String("intent_ref", out.IntentRef), + zap.Bool("has_client_payment_ref", out.ClientPaymentRef != ""), + ) + } + if err != nil { + logger.Warn("Failed to validate", append(fields, zap.Error(err))...) + return + } + logger.Debug("Completed Validate", fields...) + }(time.Now()) -func (s *svc) Validate(req *Req) (*Ctx, error) { if req == nil { return nil, merrors.InvalidArgument("request is required") } @@ -60,14 +93,15 @@ func (s *svc) Validate(req *Req) (*Ctx, error) { return nil, err } - return &Ctx{ + out = &Ctx{ OrganizationRef: orgRef, OrganizationID: orgID, IdempotencyKey: idempotencyKey, QuotationRef: quotationRef, IntentRef: intentRef, ClientPaymentRef: clientPaymentRef, - }, nil + } + return out, nil } func validateRefToken(field, value string, maxLen int, required bool) (string, error) { @@ -86,3 +120,17 @@ func validateRefToken(field, value string, maxLen int, required bool) (string, e } return normalized, nil } + +func valueOrEmpty(req *Req, getter func(*Req) string) string { + if req == nil || getter == nil { + return "" + } + return getter(req) +} + +func traceKey(req *Req) string { + if req == nil || req.Meta == nil || req.Meta.Trace == nil { + return "" + } + return req.Meta.Trace.IdempotencyKey +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/sexec/errors.go b/api/payments/orchestrator/internal/service/orchestrationv2/sexec/errors.go new file mode 100644 index 00000000..24e0d4fb --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/sexec/errors.go @@ -0,0 +1,8 @@ +package sexec + +import "errors" + +var ( + ErrMissingExecutor = errors.New("missing executor") + ErrUnsupportedStep = errors.New("unsupported step") +) diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/sexec/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/sexec/module.go new file mode 100644 index 00000000..f7efc8ba --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/sexec/module.go @@ -0,0 +1,77 @@ +package sexec + +import ( + "context" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan" + "github.com/tech/sendico/pkg/mlogger" +) + +// Registry dispatches orchestration steps to rail/action-specific executors. +type Registry interface { + Execute(ctx context.Context, in ExecuteInput) (*ExecuteOutput, error) +} + +// ExecuteInput is the step-execution payload. +type ExecuteInput struct { + Payment *agg.Payment + Step xplan.Step + StepExecution agg.StepExecution +} + +// ExecuteOutput is the executor result for one step. +type ExecuteOutput struct { + StepExecution agg.StepExecution + Async bool +} + +// StepRequest is the normalized request passed to concrete executors. +type StepRequest struct { + Payment *agg.Payment + Step xplan.Step + StepExecution agg.StepExecution +} + +// LedgerExecutor handles ledger-bound actions. +type LedgerExecutor interface { + ExecuteLedger(ctx context.Context, req StepRequest) (*ExecuteOutput, error) +} + +// CryptoExecutor handles crypto rail SEND/FEE actions. +type CryptoExecutor interface { + ExecuteCrypto(ctx context.Context, req StepRequest) (*ExecuteOutput, error) +} + +// ProviderSettlementExecutor handles provider settlement SEND actions. +type ProviderSettlementExecutor interface { + ExecuteProviderSettlement(ctx context.Context, req StepRequest) (*ExecuteOutput, error) +} + +// CardPayoutExecutor handles card payout SEND actions. +type CardPayoutExecutor interface { + ExecuteCardPayout(ctx context.Context, req StepRequest) (*ExecuteOutput, error) +} + +// ObserveConfirmExecutor handles OBSERVE_CONFIRM actions. +type ObserveConfirmExecutor interface { + ExecuteObserveConfirm(ctx context.Context, req StepRequest) (*ExecuteOutput, error) +} + +// Dependencies defines concrete executors used by the registry. +type Dependencies struct { + Logger mlogger.Logger + Ledger LedgerExecutor + Crypto CryptoExecutor + ProviderSettlement ProviderSettlementExecutor + CardPayout CardPayoutExecutor + ObserveConfirm ObserveConfirmExecutor +} + +func New(deps Dependencies) Registry { + return &svc{ + logger: deps.Logger.Named("sexec"), + deps: deps, + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/sexec/routes.go b/api/payments/orchestrator/internal/service/orchestrationv2/sexec/routes.go new file mode 100644 index 00000000..a05a0018 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/sexec/routes.go @@ -0,0 +1,112 @@ +package sexec + +import ( + "strings" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan" + "github.com/tech/sendico/payments/storage/model" +) + +type route int + +const ( + routeUnknown route = iota + 1 + routeLedger + routeCrypto + routeProviderSettlement + routeCardPayout + routeObserveConfirm +) + +func classifyRoute(step xplan.Step) route { + action := normalizeAction(step.Action) + rail := normalizeRail(step.Rail) + + switch action { + case model.RailOperationObserveConfirm: + return routeObserveConfirm + case model.RailOperationSend: + switch rail { + case model.RailCrypto: + return routeCrypto + case model.RailProviderSettlement: + return routeProviderSettlement + case model.RailCardPayout: + return routeCardPayout + default: + return routeUnknown + } + case model.RailOperationFee: + if rail == model.RailCrypto { + return routeCrypto + } + return routeUnknown + default: + if isLedgerAction(action) { + return routeLedger + } + return routeUnknown + } +} + +func isLedgerAction(action model.RailOperation) bool { + switch action { + case model.RailOperationDebit, + model.RailOperationCredit, + model.RailOperationExternalDebit, + model.RailOperationExternalCredit, + model.RailOperationMove, + model.RailOperationBlock, + model.RailOperationRelease, + model.RailOperationFXConvert: + return true + default: + return false + } +} + +func normalizeAction(action model.RailOperation) model.RailOperation { + switch strings.ToUpper(strings.TrimSpace(string(action))) { + case string(model.RailOperationDebit): + return model.RailOperationDebit + case string(model.RailOperationCredit): + return model.RailOperationCredit + case string(model.RailOperationExternalDebit): + return model.RailOperationExternalDebit + case string(model.RailOperationExternalCredit): + return model.RailOperationExternalCredit + case string(model.RailOperationMove): + return model.RailOperationMove + case string(model.RailOperationSend): + return model.RailOperationSend + case string(model.RailOperationFee): + return model.RailOperationFee + case string(model.RailOperationObserveConfirm): + return model.RailOperationObserveConfirm + case string(model.RailOperationFXConvert): + return model.RailOperationFXConvert + case string(model.RailOperationBlock): + return model.RailOperationBlock + case string(model.RailOperationRelease): + return model.RailOperationRelease + default: + return model.RailOperationUnspecified + } +} + +func normalizeRail(rail model.Rail) model.Rail { + switch strings.ToUpper(strings.TrimSpace(string(rail))) { + case string(model.RailCrypto): + return model.RailCrypto + case string(model.RailProviderSettlement): + return model.RailProviderSettlement + case string(model.RailLedger): + return model.RailLedger + case string(model.RailCardPayout): + return model.RailCardPayout + case string(model.RailFiatOnRamp): + return model.RailFiatOnRamp + default: + return model.RailUnspecified + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/sexec/service.go b/api/payments/orchestrator/internal/service/orchestrationv2/sexec/service.go new file mode 100644 index 00000000..d70b7bf5 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/sexec/service.go @@ -0,0 +1,145 @@ +package sexec + +import ( + "context" + "strings" + "time" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xerr" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" + "go.uber.org/zap" +) + +type svc struct { + logger mlogger.Logger + deps Dependencies +} + +func (s *svc) Execute(ctx context.Context, in ExecuteInput) (out *ExecuteOutput, err error) { + logger := s.logger + logger.Debug("Starting Execute", + zap.String("step_ref", strings.TrimSpace(in.Step.StepRef)), + zap.String("step_code", strings.TrimSpace(in.Step.StepCode)), + zap.String("action", strings.TrimSpace(string(in.Step.Action))), + zap.String("rail", strings.TrimSpace(string(in.Step.Rail))), + ) + defer func(start time.Time) { + fields := []zap.Field{zap.Int64("duration_ms", time.Since(start).Milliseconds())} + if out != nil { + fields = append(fields, + zap.String("result_state", string(out.StepExecution.State)), + zap.Uint32("result_attempt", out.StepExecution.Attempt), + zap.Bool("async", out.Async), + ) + } + if err != nil { + logger.Warn("Failed to execute", append(fields, zap.Error(err))...) + return + } + logger.Debug("Completed Execute", fields...) + }(time.Now()) + + req, err := validateInput(in) + if err != nil { + return nil, err + } + + switch classifyRoute(req.Step) { + case routeLedger: + if s.deps.Ledger == nil { + return nil, missingExecutorError("ledger") + } + out, err = s.deps.Ledger.ExecuteLedger(ctx, req) + return out, err + case routeCrypto: + if s.deps.Crypto == nil { + return nil, missingExecutorError("crypto") + } + out, err = s.deps.Crypto.ExecuteCrypto(ctx, req) + return out, err + case routeProviderSettlement: + if s.deps.ProviderSettlement == nil { + return nil, missingExecutorError("provider_settlement") + } + out, err = s.deps.ProviderSettlement.ExecuteProviderSettlement(ctx, req) + return out, err + case routeCardPayout: + if s.deps.CardPayout == nil { + return nil, missingExecutorError("card_payout") + } + out, err = s.deps.CardPayout.ExecuteCardPayout(ctx, req) + return out, err + case routeObserveConfirm: + if s.deps.ObserveConfirm == nil { + return nil, missingExecutorError("observe_confirm") + } + out, err = s.deps.ObserveConfirm.ExecuteObserveConfirm(ctx, req) + return out, err + default: + return nil, unsupportedStepError(req.Step) + } +} + +func validateInput(in ExecuteInput) (StepRequest, error) { + if in.Payment == nil { + return StepRequest{}, merrors.InvalidArgument("payment is required") + } + + step, err := normalizeStep(in.Step) + if err != nil { + return StepRequest{}, err + } + exec, err := normalizeStepExecution(in.StepExecution, step) + if err != nil { + return StepRequest{}, err + } + + return StepRequest{ + Payment: in.Payment, + Step: step, + StepExecution: exec, + }, nil +} + +func normalizeStep(step xplan.Step) (xplan.Step, error) { + step.StepRef = strings.TrimSpace(step.StepRef) + step.StepCode = strings.TrimSpace(step.StepCode) + if step.StepRef == "" { + return xplan.Step{}, merrors.InvalidArgument("step.step_ref is required") + } + if step.StepCode == "" { + step.StepCode = step.StepRef + } + return step, nil +} + +func normalizeStepExecution(exec agg.StepExecution, step xplan.Step) (agg.StepExecution, error) { + exec.StepRef = strings.TrimSpace(exec.StepRef) + exec.StepCode = strings.TrimSpace(exec.StepCode) + if exec.StepRef == "" { + exec.StepRef = step.StepRef + } + if exec.StepRef != step.StepRef { + return agg.StepExecution{}, merrors.InvalidArgument("step_execution.step_ref must match step.step_ref") + } + if exec.StepCode == "" { + exec.StepCode = step.StepCode + } + if exec.Attempt == 0 { + exec.Attempt = 1 + } + return exec, nil +} + +func missingExecutorError(kind string) error { + return xerr.Wrapf(ErrMissingExecutor, "%s", strings.TrimSpace(kind)) +} + +func unsupportedStepError(step xplan.Step) error { + msg := "action=" + strings.TrimSpace(string(step.Action)) + " rail=" + strings.TrimSpace(string(step.Rail)) + return xerr.Wrapf(ErrUnsupportedStep, "%s", msg) +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/sexec/service_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/sexec/service_test.go new file mode 100644 index 00000000..0d370171 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/sexec/service_test.go @@ -0,0 +1,269 @@ +package sexec + +import ( + "context" + "errors" + "testing" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan" + "github.com/tech/sendico/payments/storage/model" + "github.com/tech/sendico/pkg/merrors" +) + +func TestExecute_DispatchLedger(t *testing.T) { + ledger := &fakeLedgerExecutor{} + registry := New(Dependencies{Ledger: ledger}) + + out, err := registry.Execute(context.Background(), ExecuteInput{ + Payment: &agg.Payment{PaymentRef: "p1"}, + Step: xplan.Step{StepRef: "s1", StepCode: "ledger.debit", Action: model.RailOperationDebit, Rail: model.RailLedger}, + StepExecution: agg.StepExecution{}, + }) + if err != nil { + t.Fatalf("Execute returned error: %v", err) + } + if out == nil { + t.Fatal("expected output") + } + if ledger.calls != 1 { + t.Fatalf("expected ledger executor to be called once, got %d", ledger.calls) + } + if got, want := ledger.lastReq.StepExecution.Attempt, uint32(1); got != want { + t.Fatalf("attempt mismatch: got=%d want=%d", got, want) + } + if got, want := ledger.lastReq.StepExecution.StepRef, "s1"; got != want { + t.Fatalf("step_ref mismatch: got=%q want=%q", got, want) + } +} + +func TestExecute_DispatchSendRailsAndObserve(t *testing.T) { + crypto := &fakeCryptoExecutor{} + provider := &fakeProviderSettlementExecutor{} + card := &fakeCardPayoutExecutor{} + observe := &fakeObserveConfirmExecutor{} + registry := New(Dependencies{ + Crypto: crypto, + ProviderSettlement: provider, + CardPayout: card, + ObserveConfirm: observe, + }) + + tests := []struct { + name string + step xplan.Step + wantCalls func(t *testing.T) + }{ + { + name: "send crypto", + step: xplan.Step{ + StepRef: "s1", StepCode: "crypto.send", Action: model.RailOperationSend, Rail: model.RailCrypto, + }, + wantCalls: func(t *testing.T) { + t.Helper() + if crypto.calls != 1 || provider.calls != 0 || card.calls != 0 || observe.calls != 0 { + t.Fatalf("unexpected call counters crypto=%d provider=%d card=%d observe=%d", crypto.calls, provider.calls, card.calls, observe.calls) + } + }, + }, + { + name: "send provider settlement", + step: xplan.Step{ + StepRef: "s2", StepCode: "provider.send", Action: model.RailOperationSend, Rail: model.RailProviderSettlement, + }, + wantCalls: func(t *testing.T) { + t.Helper() + if crypto.calls != 1 || provider.calls != 1 || card.calls != 0 || observe.calls != 0 { + t.Fatalf("unexpected call counters crypto=%d provider=%d card=%d observe=%d", crypto.calls, provider.calls, card.calls, observe.calls) + } + }, + }, + { + name: "send card payout", + step: xplan.Step{ + StepRef: "s3", StepCode: "card.send", Action: model.RailOperationSend, Rail: model.RailCardPayout, + }, + wantCalls: func(t *testing.T) { + t.Helper() + if crypto.calls != 1 || provider.calls != 1 || card.calls != 1 || observe.calls != 0 { + t.Fatalf("unexpected call counters crypto=%d provider=%d card=%d observe=%d", crypto.calls, provider.calls, card.calls, observe.calls) + } + }, + }, + { + name: "observe confirm", + step: xplan.Step{ + StepRef: "s4", StepCode: "observe", Action: model.RailOperationObserveConfirm, Rail: model.RailCardPayout, + }, + wantCalls: func(t *testing.T) { + t.Helper() + if crypto.calls != 1 || provider.calls != 1 || card.calls != 1 || observe.calls != 1 { + t.Fatalf("unexpected call counters crypto=%d provider=%d card=%d observe=%d", crypto.calls, provider.calls, card.calls, observe.calls) + } + }, + }, + { + name: "crypto fee", + step: xplan.Step{ + StepRef: "s5", StepCode: "crypto.fee", Action: model.RailOperationFee, Rail: model.RailCrypto, + }, + wantCalls: func(t *testing.T) { + t.Helper() + if crypto.calls != 2 || provider.calls != 1 || card.calls != 1 || observe.calls != 1 { + t.Fatalf("unexpected call counters crypto=%d provider=%d card=%d observe=%d", crypto.calls, provider.calls, card.calls, observe.calls) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := registry.Execute(context.Background(), ExecuteInput{ + Payment: &agg.Payment{PaymentRef: "p1"}, + Step: tt.step, + StepExecution: agg.StepExecution{StepRef: tt.step.StepRef, StepCode: tt.step.StepCode, Attempt: 1}, + }) + if err != nil { + t.Fatalf("Execute returned error: %v", err) + } + tt.wantCalls(t) + }) + } +} + +func TestExecute_UnsupportedStep(t *testing.T) { + registry := New(Dependencies{}) + + _, err := registry.Execute(context.Background(), ExecuteInput{ + Payment: &agg.Payment{PaymentRef: "p1"}, + Step: xplan.Step{StepRef: "s1", StepCode: "bad.send", Action: model.RailOperationSend, Rail: model.RailLedger}, + StepExecution: agg.StepExecution{StepRef: "s1", StepCode: "bad.send", Attempt: 1}, + }) + if !errors.Is(err, ErrUnsupportedStep) { + t.Fatalf("expected ErrUnsupportedStep, got %v", err) + } +} + +func TestExecute_MissingExecutor(t *testing.T) { + registry := New(Dependencies{}) + + _, err := registry.Execute(context.Background(), ExecuteInput{ + Payment: &agg.Payment{PaymentRef: "p1"}, + Step: xplan.Step{StepRef: "s1", StepCode: "crypto.send", Action: model.RailOperationSend, Rail: model.RailCrypto}, + StepExecution: agg.StepExecution{StepRef: "s1", StepCode: "crypto.send", Attempt: 1}, + }) + if !errors.Is(err, ErrMissingExecutor) { + t.Fatalf("expected ErrMissingExecutor, got %v", err) + } +} + +func TestExecute_ValidationErrors(t *testing.T) { + ledger := &fakeLedgerExecutor{} + registry := New(Dependencies{Ledger: ledger}) + + tests := []struct { + name string + in ExecuteInput + }{ + { + name: "missing payment", + in: ExecuteInput{ + Step: xplan.Step{StepRef: "s1", Action: model.RailOperationDebit}, + }, + }, + { + name: "missing step ref", + in: ExecuteInput{ + Payment: &agg.Payment{}, + Step: xplan.Step{StepRef: " ", Action: model.RailOperationDebit}, + }, + }, + { + name: "mismatched step execution ref", + in: ExecuteInput{ + Payment: &agg.Payment{}, + Step: xplan.Step{StepRef: "s1", StepCode: "s1", Action: model.RailOperationDebit}, + StepExecution: agg.StepExecution{StepRef: "s2"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := registry.Execute(context.Background(), tt.in) + if !errors.Is(err, merrors.ErrInvalidArg) { + t.Fatalf("expected invalid argument error, got %v", err) + } + }) + } +} + +type fakeLedgerExecutor struct { + calls int + lastReq StepRequest +} + +func (f *fakeLedgerExecutor) ExecuteLedger(_ context.Context, req StepRequest) (*ExecuteOutput, error) { + f.calls++ + f.lastReq = req + return &ExecuteOutput{ + StepExecution: req.StepExecution, + Async: false, + }, nil +} + +type fakeCryptoExecutor struct { + calls int + lastReq StepRequest +} + +func (f *fakeCryptoExecutor) ExecuteCrypto(_ context.Context, req StepRequest) (*ExecuteOutput, error) { + f.calls++ + f.lastReq = req + return &ExecuteOutput{ + StepExecution: req.StepExecution, + Async: true, + }, nil +} + +type fakeProviderSettlementExecutor struct { + calls int + lastReq StepRequest +} + +func (f *fakeProviderSettlementExecutor) ExecuteProviderSettlement(_ context.Context, req StepRequest) (*ExecuteOutput, error) { + f.calls++ + f.lastReq = req + return &ExecuteOutput{ + StepExecution: req.StepExecution, + Async: true, + }, nil +} + +type fakeCardPayoutExecutor struct { + calls int + lastReq StepRequest +} + +func (f *fakeCardPayoutExecutor) ExecuteCardPayout(_ context.Context, req StepRequest) (*ExecuteOutput, error) { + f.calls++ + f.lastReq = req + return &ExecuteOutput{ + StepExecution: req.StepExecution, + Async: true, + }, nil +} + +type fakeObserveConfirmExecutor struct { + calls int + lastReq StepRequest +} + +func (f *fakeObserveConfirmExecutor) ExecuteObserveConfirm(_ context.Context, req StepRequest) (*ExecuteOutput, error) { + f.calls++ + f.lastReq = req + return &ExecuteOutput{ + StepExecution: req.StepExecution, + Async: true, + }, nil +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/ssched/input.go b/api/payments/orchestrator/internal/service/orchestrationv2/ssched/input.go new file mode 100644 index 00000000..9a202502 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/ssched/input.go @@ -0,0 +1,271 @@ +package ssched + +import ( + "strings" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan" + "github.com/tech/sendico/pkg/merrors" +) + +func (s *svc) prepareInput(in Input) (*preparedInput, error) { + if len(in.Steps) == 0 { + return nil, merrors.InvalidArgument("steps are required") + } + + stepsByRef := make(map[string]xplan.Step, len(in.Steps)) + order := make([]string, 0, len(in.Steps)) + + for i := range in.Steps { + step, err := normalizeGraphStep(in.Steps[i], i) + if err != nil { + return nil, err + } + if _, exists := stepsByRef[step.StepRef]; exists { + return nil, merrors.InvalidArgument("steps[" + itoa(i) + "].step_ref must be unique") + } + stepsByRef[step.StepRef] = step + order = append(order, step.StepRef) + } + + for i := range order { + step := stepsByRef[order[i]] + for _, dep := range step.DependsOn { + if _, ok := stepsByRef[dep]; !ok { + return nil, merrors.InvalidArgument("step dependency is unknown: " + dep) + } + } + for _, dep := range step.CommitAfter { + if _, ok := stepsByRef[dep]; !ok { + return nil, merrors.InvalidArgument("step commit_after dependency is unknown: " + dep) + } + } + } + + maxAttemptsByRef := buildMaxAttemptsByRef(order, in.Retry) + executionsByRef, err := s.normalizeStepExecutions(in.StepExecutions, stepsByRef, maxAttemptsByRef) + if err != nil { + return nil, err + } + seedMissingExecutions(order, stepsByRef, executionsByRef, maxAttemptsByRef) + + return &preparedInput{ + stepsByRef: stepsByRef, + order: order, + executionsByRef: executionsByRef, + maxAttemptsByRef: maxAttemptsByRef, + }, nil +} + +func normalizeGraphStep(step xplan.Step, index int) (xplan.Step, error) { + step.StepRef = strings.TrimSpace(step.StepRef) + step.StepCode = strings.TrimSpace(step.StepCode) + if step.StepRef == "" { + return xplan.Step{}, merrors.InvalidArgument("steps[" + itoa(index) + "].step_ref is required") + } + if step.StepCode == "" { + step.StepCode = step.StepRef + } + step.DependsOn = normalizeRefList(step.DependsOn) + step.CommitAfter = normalizeRefList(step.CommitAfter) + return step, nil +} + +func normalizeRefList(refs []string) []string { + if len(refs) == 0 { + return nil + } + out := make([]string, 0, len(refs)) + seen := make(map[string]struct{}, len(refs)) + for i := range refs { + ref := strings.TrimSpace(refs[i]) + if ref == "" { + continue + } + if _, ok := seen[ref]; ok { + continue + } + seen[ref] = struct{}{} + out = append(out, ref) + } + return out +} + +func buildMaxAttemptsByRef(order []string, retry RetryPolicy) map[string]uint32 { + defaultMax := retry.MaxAttempts + if defaultMax == 0 { + defaultMax = 1 + } + out := make(map[string]uint32, len(order)) + for i := range order { + out[order[i]] = defaultMax + } + for stepRef, maxAttempts := range retry.MaxAttemptsByStepRef { + stepRef = strings.TrimSpace(stepRef) + if stepRef == "" || maxAttempts == 0 { + continue + } + out[stepRef] = maxAttempts + } + return out +} + +func (s *svc) normalizeStepExecutions( + steps []agg.StepExecution, + stepsByRef map[string]xplan.Step, + maxAttemptsByRef map[string]uint32, +) (map[string]*agg.StepExecution, error) { + if len(steps) == 0 { + return map[string]*agg.StepExecution{}, nil + } + + out := make(map[string]*agg.StepExecution, len(steps)) + for i := range steps { + exec, err := s.normalizeStepExecution(steps[i], i) + if err != nil { + return nil, err + } + stepRef := exec.StepRef + if _, ok := stepsByRef[stepRef]; !ok { + return nil, merrors.InvalidArgument("step_executions[" + itoa(i) + "].step_ref is unknown: " + stepRef) + } + if _, exists := out[stepRef]; exists { + return nil, merrors.InvalidArgument("step_executions[" + itoa(i) + "].step_ref must be unique") + } + if exec.Attempt == 0 { + exec.Attempt = 1 + } + if maxAttemptsByRef[stepRef] == 0 { + maxAttemptsByRef[stepRef] = 1 + } + stepCode := strings.TrimSpace(exec.StepCode) + if stepCode == "" { + stepCode = stepsByRef[stepRef].StepCode + } + exec.StepCode = stepCode + cloned := cloneStepExecution(exec) + out[stepRef] = &cloned + } + return out, nil +} + +func (s *svc) normalizeStepExecution(exec agg.StepExecution, index int) (agg.StepExecution, error) { + exec.StepRef = strings.TrimSpace(exec.StepRef) + exec.StepCode = strings.TrimSpace(exec.StepCode) + exec.FailureCode = strings.TrimSpace(exec.FailureCode) + exec.FailureMsg = strings.TrimSpace(exec.FailureMsg) + exec.ExternalRefs = cloneExternalRefs(exec.ExternalRefs) + if exec.StepRef == "" { + return agg.StepExecution{}, merrors.InvalidArgument("step_executions[" + itoa(index) + "].step_ref is required") + } + + state, ok := normalizeStepState(exec.State) + if !ok { + return agg.StepExecution{}, merrors.InvalidArgument("step_executions[" + itoa(index) + "].state is invalid") + } + exec.State = state + if err := s.stateMachine.EnsureStepTransition(exec.State, exec.State); err != nil { + return agg.StepExecution{}, merrors.InvalidArgument("step_executions[" + itoa(index) + "].state is invalid") + } + return exec, nil +} + +func seedMissingExecutions( + order []string, + stepsByRef map[string]xplan.Step, + executionsByRef map[string]*agg.StepExecution, + maxAttemptsByRef map[string]uint32, +) { + for i := range order { + stepRef := order[i] + if _, ok := executionsByRef[stepRef]; ok { + continue + } + step := stepsByRef[stepRef] + attempt := uint32(1) + if maxAttemptsByRef[stepRef] == 0 { + maxAttemptsByRef[stepRef] = 1 + } + executionsByRef[stepRef] = &agg.StepExecution{ + StepRef: step.StepRef, + StepCode: step.StepCode, + State: agg.StepStatePending, + Attempt: attempt, + } + } +} + +func normalizeStepState(state agg.StepState) (agg.StepState, bool) { + switch strings.ToLower(strings.TrimSpace(string(state))) { + case "": + return agg.StepStateUnspecified, true + case string(agg.StepStateUnspecified): + return agg.StepStateUnspecified, true + 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 cloneStepExecution(exec agg.StepExecution) agg.StepExecution { + out := exec + out.ExternalRefs = cloneExternalRefs(exec.ExternalRefs) + 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 firstNonEmpty(values ...string) string { + for i := range values { + val := strings.TrimSpace(values[i]) + if val != "" { + return val + } + } + return "" +} + +func max(left, right uint32) uint32 { + if left > right { + return left + } + return right +} + +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:]) +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/ssched/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/ssched/module.go new file mode 100644 index 00000000..7197e56b --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/ssched/module.go @@ -0,0 +1,91 @@ +package ssched + +import ( + "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/xplan" + "github.com/tech/sendico/pkg/mlogger" +) + +// Runtime selects runnable orchestration steps and reconciles step runtime states. +type Runtime interface { + Schedule(in Input) (*Output, error) +} + +// Input is the scheduler payload. +type Input struct { + Steps []xplan.Step + StepExecutions []agg.StepExecution + Retry RetryPolicy +} + +// RetryPolicy configures per-step retry limits. +type RetryPolicy struct { + MaxAttempts uint32 + MaxAttemptsByStepRef map[string]uint32 +} + +// RunnableStep is a step selected for execution. +type RunnableStep struct { + StepRef string + StepCode string + Attempt uint32 +} + +// BlockedReason classifies why a step is not runnable. +type BlockedReason string + +const ( + BlockedWaitingDependencies BlockedReason = "waiting_dependencies" + BlockedInProgress BlockedReason = "in_progress" + BlockedNeedsAttention BlockedReason = "needs_attention" + BlockedRetryExhausted BlockedReason = "retry_exhausted" + BlockedDependencyMismatch BlockedReason = "dependency_mismatch" +) + +// BlockedStep is a step that cannot run in the current scheduling tick. +type BlockedStep struct { + StepRef string + StepCode string + Reason BlockedReason +} + +// Output is the scheduler decision for one tick. +type Output struct { + StepExecutions []agg.StepExecution + Runnable []RunnableStep + Blocked []BlockedStep + Skipped []string +} + +// Dependencies configures scheduler integrations. +type Dependencies struct { + Logger mlogger.Logger + StateMachine ostate.StateMachine + Now func() time.Time +} + +func New(deps ...Dependencies) Runtime { + var dep Dependencies + if len(deps) > 0 { + dep = deps[0] + } + stateMachine := dep.StateMachine + if stateMachine == nil { + stateMachine = ostate.New(ostate.Dependencies{Logger: dep.Logger.Named("ssched.ostate")}) + } + now := dep.Now + if now == nil { + now = func() time.Time { + return time.Now().UTC() + } + } + return &svc{ + logger: dep.Logger.Named("ssched"), + stateMachine: stateMachine, + now: now, + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/ssched/service.go b/api/payments/orchestrator/internal/service/orchestrationv2/ssched/service.go new file mode 100644 index 00000000..5f94a778 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/ssched/service.go @@ -0,0 +1,324 @@ +package ssched + +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/xplan" + "github.com/tech/sendico/payments/storage/model" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" + "go.uber.org/zap" +) + +type svc struct { + logger mlogger.Logger + stateMachine ostate.StateMachine + now func() time.Time +} + +type preparedInput struct { + stepsByRef map[string]xplan.Step + order []string + executionsByRef map[string]*agg.StepExecution + maxAttemptsByRef map[string]uint32 +} + +type gate int + +const ( + gateReady gate = iota + 1 + gateWaiting + gateImpossible +) + +type stepOutcome int + +const ( + outcomeUnknown stepOutcome = iota + 1 + outcomeSuccess + outcomeFailure + outcomeSkipped +) + +func (s *svc) Schedule(in Input) (out *Output, err error) { + logger := s.logger + logger.Debug("Starting Schedule", + zap.Int("steps_count", len(in.Steps)), + zap.Int("step_executions_count", len(in.StepExecutions)), + zap.Uint32("retry_max_attempts", in.Retry.MaxAttempts), + ) + defer func(start time.Time) { + fields := []zap.Field{zap.Int64("duration_ms", time.Since(start).Milliseconds())} + if out != nil { + fields = append(fields, + zap.Int("runnable_count", len(out.Runnable)), + zap.Int("blocked_count", len(out.Blocked)), + zap.Int("skipped_count", len(out.Skipped)), + ) + } + if err != nil { + logger.Warn("Failed to schedule", append(fields, zap.Error(err))...) + return + } + logger.Debug("Completed Schedule", fields...) + }(time.Now()) + + prep, err := s.prepareInput(in) + if err != nil { + return nil, err + } + + skipped := map[string]struct{}{} + s.reconcileStates(prep, skipped) + + out = &Output{ + StepExecutions: make([]agg.StepExecution, 0, len(prep.order)), + } + for _, stepRef := range prep.order { + step := prep.stepsByRef[stepRef] + exec := prep.executionsByRef[stepRef] + if exec == nil { + return nil, merrors.InvalidArgument("execution is required for step_ref " + stepRef) + } + + switch exec.State { + case agg.StepStatePending: + switch evaluateGate(step, prep.executionsByRef, prep.maxAttemptsByRef) { + case gateReady: + out.Runnable = append(out.Runnable, RunnableStep{ + StepRef: exec.StepRef, + StepCode: firstNonEmpty(exec.StepCode, step.StepCode), + Attempt: max(exec.Attempt, 1), + }) + case gateWaiting: + out.Blocked = append(out.Blocked, blockedStep(exec, step, BlockedWaitingDependencies)) + default: + out.Blocked = append(out.Blocked, blockedStep(exec, step, BlockedDependencyMismatch)) + } + + case agg.StepStateFailed: + maxAttempts := prep.maxAttemptsByRef[stepRef] + if exec.Attempt >= maxAttempts { + out.Blocked = append(out.Blocked, blockedStep(exec, step, BlockedRetryExhausted)) + break + } + switch evaluateGate(step, prep.executionsByRef, prep.maxAttemptsByRef) { + case gateReady: + out.Runnable = append(out.Runnable, RunnableStep{ + StepRef: exec.StepRef, + StepCode: firstNonEmpty(exec.StepCode, step.StepCode), + Attempt: exec.Attempt + 1, + }) + case gateWaiting: + out.Blocked = append(out.Blocked, blockedStep(exec, step, BlockedWaitingDependencies)) + default: + out.Blocked = append(out.Blocked, blockedStep(exec, step, BlockedDependencyMismatch)) + } + + case agg.StepStateNeedsAttention: + out.Blocked = append(out.Blocked, blockedStep(exec, step, BlockedNeedsAttention)) + + case agg.StepStateRunning: + out.Blocked = append(out.Blocked, blockedStep(exec, step, BlockedInProgress)) + } + + out.StepExecutions = append(out.StepExecutions, cloneStepExecution(*exec)) + } + + if len(skipped) > 0 { + out.Skipped = make([]string, 0, len(skipped)) + for _, stepRef := range prep.order { + if _, ok := skipped[stepRef]; ok { + out.Skipped = append(out.Skipped, stepRef) + } + } + } + + return out, nil +} + +func (s *svc) reconcileStates(prep *preparedInput, skipped map[string]struct{}) { + for pass := 0; pass < len(prep.order)+1; pass++ { + changed := false + for _, stepRef := range prep.order { + step := prep.stepsByRef[stepRef] + exec := prep.executionsByRef[stepRef] + if exec == nil { + continue + } + + if s.promoteRetryExhaustedToNeedsAttention(exec, prep.maxAttemptsByRef[stepRef]) { + changed = true + } + + if s.skipImpossiblePending(step, exec, prep, skipped) { + changed = true + } + } + if !changed { + return + } + } +} + +func (s *svc) promoteRetryExhaustedToNeedsAttention(exec *agg.StepExecution, maxAttempts uint32) bool { + if exec == nil || exec.State != agg.StepStateFailed { + return false + } + if exec.Attempt < maxAttempts { + return false + } + if err := s.stateMachine.EnsureStepTransition(exec.State, agg.StepStateNeedsAttention); err != nil { + return false + } + exec.State = agg.StepStateNeedsAttention + return true +} + +func (s *svc) skipImpossiblePending(step xplan.Step, exec *agg.StepExecution, prep *preparedInput, skipped map[string]struct{}) bool { + if exec == nil || exec.State != agg.StepStatePending { + return false + } + if evaluateGate(step, prep.executionsByRef, prep.maxAttemptsByRef) != gateImpossible { + return false + } + if err := s.stateMachine.EnsureStepTransition(exec.State, agg.StepStateSkipped); err != nil { + return false + } + now := s.now().UTC() + exec.State = agg.StepStateSkipped + exec.FailureCode = "" + exec.FailureMsg = "" + exec.CompletedAt = &now + skipped[exec.StepRef] = struct{}{} + return true +} + +func evaluateGate(step xplan.Step, executionsByRef map[string]*agg.StepExecution, maxAttemptsByRef map[string]uint32) gate { + depOutcomes := make(map[string]stepOutcome, len(step.DependsOn)) + for _, dep := range step.DependsOn { + depExec := executionsByRef[dep] + if depExec == nil { + return gateWaiting + } + outcome := outcomeForStep(depExec, maxAttemptsByRef[dep]) + if outcome == outcomeUnknown { + return gateWaiting + } + depOutcomes[dep] = outcome + } + + policy := normalizeCommitPolicy(step.CommitPolicy) + switch policy { + case model.CommitPolicyAfterSuccess: + return evaluateAll(stepCommitTargets(step), executionsByRef, maxAttemptsByRef, outcomeSuccess) + case model.CommitPolicyAfterFailure: + return evaluateAll(stepCommitTargets(step), executionsByRef, maxAttemptsByRef, outcomeFailure) + case model.CommitPolicyAfterCanceled: + return evaluateTerminal(stepCommitTargets(step), executionsByRef, maxAttemptsByRef) + default: + for _, outcome := range depOutcomes { + if outcome == outcomeFailure { + return gateImpossible + } + } + return gateReady + } +} + +func evaluateAll( + refs []string, + executionsByRef map[string]*agg.StepExecution, + maxAttemptsByRef map[string]uint32, + want stepOutcome, +) gate { + for _, ref := range refs { + exec := executionsByRef[ref] + if exec == nil { + return gateWaiting + } + outcome := outcomeForStep(exec, maxAttemptsByRef[ref]) + if outcome == outcomeUnknown { + return gateWaiting + } + if outcome != want { + return gateImpossible + } + } + return gateReady +} + +func evaluateTerminal(refs []string, executionsByRef map[string]*agg.StepExecution, maxAttemptsByRef map[string]uint32) gate { + for _, ref := range refs { + exec := executionsByRef[ref] + if exec == nil { + return gateWaiting + } + if outcomeForStep(exec, maxAttemptsByRef[ref]) == outcomeUnknown { + return gateWaiting + } + } + return gateReady +} + +func outcomeForStep(exec *agg.StepExecution, maxAttempts uint32) stepOutcome { + if exec == nil { + return outcomeUnknown + } + if maxAttempts == 0 { + maxAttempts = 1 + } + switch exec.State { + case agg.StepStateCompleted: + return outcomeSuccess + case agg.StepStateSkipped: + return outcomeSkipped + case agg.StepStateNeedsAttention: + return outcomeFailure + case agg.StepStateFailed: + if exec.Attempt < maxAttempts { + return outcomeUnknown + } + return outcomeFailure + default: + return outcomeUnknown + } +} + +func stepCommitTargets(step xplan.Step) []string { + if len(step.CommitAfter) > 0 { + return step.CommitAfter + } + return step.DependsOn +} + +func normalizeCommitPolicy(policy model.CommitPolicy) model.CommitPolicy { + switch strings.ToUpper(strings.TrimSpace(string(policy))) { + case string(model.CommitPolicyImmediate): + return model.CommitPolicyImmediate + case string(model.CommitPolicyAfterSuccess): + return model.CommitPolicyAfterSuccess + case string(model.CommitPolicyAfterFailure): + return model.CommitPolicyAfterFailure + case string(model.CommitPolicyAfterCanceled): + return model.CommitPolicyAfterCanceled + default: + return model.CommitPolicyUnspecified + } +} + +func blockedStep(exec *agg.StepExecution, step xplan.Step, reason BlockedReason) BlockedStep { + stepCode := step.StepCode + if exec != nil && strings.TrimSpace(exec.StepCode) != "" { + stepCode = exec.StepCode + } + return BlockedStep{ + StepRef: step.StepRef, + StepCode: strings.TrimSpace(stepCode), + Reason: reason, + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/ssched/service_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/ssched/service_test.go new file mode 100644 index 00000000..7128937b --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/ssched/service_test.go @@ -0,0 +1,327 @@ +package ssched + +import ( + "errors" + "testing" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan" + "github.com/tech/sendico/payments/storage/model" + "github.com/tech/sendico/pkg/merrors" +) + +func TestSchedule_LinearFlowPicksFirstRunnable(t *testing.T) { + runtime := New() + + out, err := runtime.Schedule(Input{ + Steps: []xplan.Step{ + step("a", nil), + step("b", []string{"a"}), + }, + StepExecutions: []agg.StepExecution{ + exec("a", agg.StepStatePending, 1), + exec("b", agg.StepStatePending, 1), + }, + }) + if err != nil { + t.Fatalf("Schedule returned error: %v", err) + } + + assertRunnableRefs(t, out, []string{"a"}) + assertRunnableAttempt(t, out, "a", 1) + assertBlockedReason(t, out, "b", BlockedWaitingDependencies) + if len(out.Skipped) != 0 { + t.Fatalf("expected no skipped steps, got %v", out.Skipped) + } +} + +func TestSchedule_SuccessBranchSkipsFailureBranch(t *testing.T) { + runtime := New() + + out, err := runtime.Schedule(Input{ + Steps: []xplan.Step{ + step("observe", nil), + successStep("debit", "observe"), + failureStep("release", "observe"), + }, + StepExecutions: []agg.StepExecution{ + exec("observe", agg.StepStateCompleted, 1), + exec("debit", agg.StepStatePending, 1), + exec("release", agg.StepStatePending, 1), + }, + }) + if err != nil { + t.Fatalf("Schedule returned error: %v", err) + } + + assertRunnableRefs(t, out, []string{"debit"}) + assertSkippedRefs(t, out, []string{"release"}) + release := mustExecution(t, out, "release") + if release.State != agg.StepStateSkipped { + t.Fatalf("release state mismatch: got=%q want=%q", release.State, agg.StepStateSkipped) + } + if release.CompletedAt == nil { + t.Fatal("expected skipped step to have completed_at") + } +} + +func TestSchedule_AfterFailureWaitsWhenDependencyCanRetry(t *testing.T) { + runtime := New() + + out, err := runtime.Schedule(Input{ + Steps: []xplan.Step{ + step("observe", nil), + failureStep("release", "observe"), + }, + StepExecutions: []agg.StepExecution{ + exec("observe", agg.StepStateFailed, 1), + exec("release", agg.StepStatePending, 1), + }, + Retry: RetryPolicy{MaxAttempts: 2}, + }) + if err != nil { + t.Fatalf("Schedule returned error: %v", err) + } + + assertRunnableRefs(t, out, []string{"observe"}) + assertRunnableAttempt(t, out, "observe", 2) + assertBlockedReason(t, out, "release", BlockedWaitingDependencies) + if len(out.Skipped) != 0 { + t.Fatalf("expected no skipped steps, got %v", out.Skipped) + } +} + +func TestSchedule_AfterFailureRunsWhenDependencyExhausted(t *testing.T) { + runtime := New() + + out, err := runtime.Schedule(Input{ + Steps: []xplan.Step{ + step("observe", nil), + successStep("debit", "observe"), + failureStep("release", "observe"), + }, + StepExecutions: []agg.StepExecution{ + exec("observe", agg.StepStateFailed, 2), + exec("debit", agg.StepStatePending, 1), + exec("release", agg.StepStatePending, 1), + }, + Retry: RetryPolicy{MaxAttempts: 2}, + }) + if err != nil { + t.Fatalf("Schedule returned error: %v", err) + } + + observe := mustExecution(t, out, "observe") + if observe.State != agg.StepStateNeedsAttention { + t.Fatalf("observe state mismatch: got=%q want=%q", observe.State, agg.StepStateNeedsAttention) + } + assertRunnableRefs(t, out, []string{"release"}) + assertSkippedRefs(t, out, []string{"debit"}) + assertBlockedReason(t, out, "observe", BlockedNeedsAttention) +} + +func TestSchedule_RetryExhaustedPromotesNeedsAttention(t *testing.T) { + runtime := New() + + out, err := runtime.Schedule(Input{ + Steps: []xplan.Step{ + step("single", nil), + }, + StepExecutions: []agg.StepExecution{ + exec("single", agg.StepStateFailed, 1), + }, + Retry: RetryPolicy{MaxAttempts: 1}, + }) + if err != nil { + t.Fatalf("Schedule returned error: %v", err) + } + + single := mustExecution(t, out, "single") + if single.State != agg.StepStateNeedsAttention { + t.Fatalf("single state mismatch: got=%q want=%q", single.State, agg.StepStateNeedsAttention) + } + assertBlockedReason(t, out, "single", BlockedNeedsAttention) + if len(out.Runnable) != 0 { + t.Fatalf("expected no runnable steps, got %d", len(out.Runnable)) + } +} + +func TestSchedule_FailedDependencySkipsImmediateDependents(t *testing.T) { + runtime := New() + + out, err := runtime.Schedule(Input{ + Steps: []xplan.Step{ + step("a", nil), + step("b", []string{"a"}), + }, + StepExecutions: []agg.StepExecution{ + exec("a", agg.StepStateFailed, 1), + exec("b", agg.StepStatePending, 1), + }, + Retry: RetryPolicy{MaxAttempts: 1}, + }) + if err != nil { + t.Fatalf("Schedule returned error: %v", err) + } + + a := mustExecution(t, out, "a") + if a.State != agg.StepStateNeedsAttention { + t.Fatalf("a state mismatch: got=%q want=%q", a.State, agg.StepStateNeedsAttention) + } + assertSkippedRefs(t, out, []string{"b"}) + assertBlockedReason(t, out, "a", BlockedNeedsAttention) +} + +func TestSchedule_ValidationErrors(t *testing.T) { + runtime := New() + + tests := []struct { + name string + in Input + }{ + { + name: "missing steps", + in: Input{ + StepExecutions: []agg.StepExecution{exec("a", agg.StepStatePending, 1)}, + }, + }, + { + name: "step dependency unknown", + in: Input{ + Steps: []xplan.Step{ + step("a", []string{"missing"}), + }, + }, + }, + { + name: "unknown execution state", + in: Input{ + Steps: []xplan.Step{ + step("a", nil), + }, + StepExecutions: []agg.StepExecution{ + {StepRef: "a", StepCode: "a", State: agg.StepState("bad_state"), Attempt: 1}, + }, + }, + }, + { + name: "execution ref unknown in graph", + in: Input{ + Steps: []xplan.Step{ + step("a", nil), + }, + StepExecutions: []agg.StepExecution{ + exec("x", agg.StepStatePending, 1), + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := runtime.Schedule(tt.in) + if !errors.Is(err, merrors.ErrInvalidArg) { + t.Fatalf("expected invalid argument error, got %v", err) + } + }) + } +} + +func step(ref string, deps []string) xplan.Step { + return xplan.Step{ + StepRef: ref, + StepCode: ref, + DependsOn: deps, + } +} + +func successStep(ref, dep string) xplan.Step { + return xplan.Step{ + StepRef: ref, + StepCode: ref, + DependsOn: []string{dep}, + CommitPolicy: model.CommitPolicyAfterSuccess, + CommitAfter: []string{dep}, + } +} + +func failureStep(ref, dep string) xplan.Step { + return xplan.Step{ + StepRef: ref, + StepCode: ref, + DependsOn: []string{dep}, + CommitPolicy: model.CommitPolicyAfterFailure, + CommitAfter: []string{dep}, + } +} + +func exec(ref string, state agg.StepState, attempt uint32) agg.StepExecution { + return agg.StepExecution{ + StepRef: ref, + StepCode: ref, + State: state, + Attempt: attempt, + } +} + +func mustExecution(t *testing.T, out *Output, stepRef string) agg.StepExecution { + t.Helper() + for i := range out.StepExecutions { + if out.StepExecutions[i].StepRef == stepRef { + return out.StepExecutions[i] + } + } + t.Fatalf("missing execution for step_ref %q", stepRef) + return agg.StepExecution{} +} + +func assertRunnableRefs(t *testing.T, out *Output, want []string) { + t.Helper() + if len(out.Runnable) != len(want) { + t.Fatalf("runnable count mismatch: got=%d want=%d", len(out.Runnable), len(want)) + } + for i := range want { + if out.Runnable[i].StepRef != want[i] { + t.Fatalf("runnable[%d] mismatch: got=%q want=%q", i, out.Runnable[i].StepRef, want[i]) + } + } +} + +func assertRunnableAttempt(t *testing.T, out *Output, stepRef string, want uint32) { + t.Helper() + for i := range out.Runnable { + if out.Runnable[i].StepRef == stepRef { + if out.Runnable[i].Attempt != want { + t.Fatalf("runnable attempt mismatch for %q: got=%d want=%d", stepRef, out.Runnable[i].Attempt, want) + } + return + } + } + t.Fatalf("runnable step %q not found", stepRef) +} + +func assertBlockedReason(t *testing.T, out *Output, stepRef string, want BlockedReason) { + t.Helper() + for i := range out.Blocked { + if out.Blocked[i].StepRef != stepRef { + continue + } + if out.Blocked[i].Reason != want { + t.Fatalf("blocked reason mismatch for %q: got=%q want=%q", stepRef, out.Blocked[i].Reason, want) + } + return + } + t.Fatalf("blocked step %q not found", stepRef) +} + +func assertSkippedRefs(t *testing.T, out *Output, want []string) { + t.Helper() + if len(out.Skipped) != len(want) { + t.Fatalf("skipped count mismatch: got=%d want=%d", len(out.Skipped), len(want)) + } + for i := range want { + if out.Skipped[i] != want[i] { + t.Fatalf("skipped[%d] mismatch: got=%q want=%q", i, out.Skipped[i], want[i]) + } + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/xerr/wrap.go b/api/payments/orchestrator/internal/service/orchestrationv2/xerr/wrap.go new file mode 100644 index 00000000..e2476417 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/xerr/wrap.go @@ -0,0 +1,37 @@ +package xerr + +import ( + "fmt" + "strings" +) + +type wrappedError struct { + base error + msg string +} + +func (e wrappedError) Error() string { + msg := strings.TrimSpace(e.msg) + if e.base == nil { + return msg + } + if msg == "" { + return e.base.Error() + } + return e.base.Error() + ": " + msg +} + +func (e wrappedError) Unwrap() error { + return e.base +} + +func Wrap(base error, msg string) error { + return wrappedError{ + base: base, + msg: strings.TrimSpace(msg), + } +} + +func Wrapf(base error, format string, args ...any) error { + return Wrap(base, fmt.Sprintf(format, args...)) +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/service_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/compile_flow_test.go similarity index 56% rename from api/payments/orchestrator/internal/service/orchestrationv2/xplan/service_test.go rename to api/payments/orchestrator/internal/service/orchestrationv2/xplan/compile_flow_test.go index bff3e3d5..385e8a3e 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/service_test.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/compile_flow_test.go @@ -1,11 +1,9 @@ package xplan import ( - "errors" "testing" "github.com/tech/sendico/payments/storage/model" - "github.com/tech/sendico/pkg/merrors" paymenttypes "github.com/tech/sendico/pkg/payments/types" ) @@ -233,261 +231,3 @@ func TestCompile_SingleExternalFallback(t *testing.T) { t.Fatalf("observe dependency mismatch: got=%v want=%v", got, want) } } - -func TestCompile_PolicyOverrideByRailPair(t *testing.T) { - compiler := New() - - cardRail := model.RailCardPayout - ledgerRail := model.RailLedger - - graph, err := compiler.Compile(Input{ - IntentSnapshot: testIntent(model.PaymentKindPayout), - QuoteSnapshot: &model.PaymentQuoteSnapshot{ - Route: &paymenttypes.QuoteRouteSpecification{ - Hops: []*paymenttypes.QuoteRouteHop{ - {Index: 10, Rail: "CRYPTO", Role: paymenttypes.QuoteRouteHopRoleSource}, - {Index: 20, Rail: "CARD_PAYOUT", Role: paymenttypes.QuoteRouteHopRoleDestination}, - }, - }, - }, - Policies: []Policy{ - { - ID: "crypto-to-card-override", - Match: EdgeMatch{ - Source: EndpointMatch{Rail: railPtr(model.RailCrypto)}, - Target: EndpointMatch{Rail: railPtr(model.RailCardPayout)}, - }, - Steps: []PolicyStep{ - {Code: "custom.review", Action: model.RailOperationMove, Rail: &ledgerRail}, - {Code: "custom.submit", Action: model.RailOperationSend, Rail: &cardRail, Visibility: model.ReportVisibilityUser}, - }, - Success: []PolicyStep{ - {Code: "custom.finalize", Action: model.RailOperationDebit, Rail: &ledgerRail}, - }, - Failure: []PolicyStep{ - {Code: "custom.release", Action: model.RailOperationRelease, Rail: &ledgerRail}, - }, - }, - }, - }) - if err != nil { - t.Fatalf("Compile returned error: %v", err) - } - - if len(graph.Steps) != 4 { - t.Fatalf("expected 4 steps, got %d", len(graph.Steps)) - } - assertStep(t, graph.Steps[0], "custom.review", model.RailOperationMove, model.RailLedger, model.ReportVisibilityHidden) - assertStep(t, graph.Steps[1], "custom.submit", model.RailOperationSend, model.RailCardPayout, model.ReportVisibilityUser) - assertStep(t, graph.Steps[2], "custom.finalize", model.RailOperationDebit, model.RailLedger, model.ReportVisibilityHidden) - assertStep(t, graph.Steps[3], "custom.release", model.RailOperationRelease, model.RailLedger, model.ReportVisibilityHidden) - - if graph.Steps[2].CommitPolicy != model.CommitPolicyAfterSuccess { - t.Fatalf("expected custom.finalize AFTER_SUCCESS, got %q", graph.Steps[2].CommitPolicy) - } - if graph.Steps[3].CommitPolicy != model.CommitPolicyAfterFailure { - t.Fatalf("expected custom.release AFTER_FAILURE, got %q", graph.Steps[3].CommitPolicy) - } -} - -func TestCompile_PolicyPriorityAndCustodyMatching(t *testing.T) { - compiler := New() - cardRail := model.RailCardPayout - - on := true - external := CustodyExternal - - graph, err := compiler.Compile(Input{ - IntentSnapshot: testIntent(model.PaymentKindPayout), - QuoteSnapshot: &model.PaymentQuoteSnapshot{ - Route: &paymenttypes.QuoteRouteSpecification{ - Hops: []*paymenttypes.QuoteRouteHop{ - {Index: 10, Rail: "CRYPTO", Role: paymenttypes.QuoteRouteHopRoleSource}, - {Index: 20, Rail: "CARD_PAYOUT", Role: paymenttypes.QuoteRouteHopRoleDestination}, - }, - }, - }, - Policies: []Policy{ - { - ID: "generic-external", - Enabled: &on, - Priority: 1, - Match: EdgeMatch{ - Source: EndpointMatch{Custody: &external}, - Target: EndpointMatch{Custody: &external}, - }, - Steps: []PolicyStep{{Code: "generic.submit", Action: model.RailOperationSend, Rail: &cardRail}}, - }, - { - ID: "specific-crypto-card", - Enabled: &on, - Priority: 10, - Match: EdgeMatch{ - Source: EndpointMatch{Rail: railPtr(model.RailCrypto), Custody: &external}, - Target: EndpointMatch{Rail: railPtr(model.RailCardPayout), Custody: &external}, - }, - Steps: []PolicyStep{{Code: "specific.submit", Action: model.RailOperationSend, Rail: &cardRail}}, - }, - }, - }) - if err != nil { - t.Fatalf("Compile returned error: %v", err) - } - - if len(graph.Steps) != 1 { - t.Fatalf("expected 1 policy step, got %d", len(graph.Steps)) - } - if got, want := graph.Steps[0].StepCode, "specific.submit"; got != want { - t.Fatalf("expected high-priority specific policy, got %q", got) - } -} - -func TestCompile_IndicativeRejected(t *testing.T) { - compiler := New() - - _, err := compiler.Compile(Input{ - IntentSnapshot: testIntent(model.PaymentKindPayout), - QuoteSnapshot: &model.PaymentQuoteSnapshot{ - Route: &paymenttypes.QuoteRouteSpecification{ - Rail: "CRYPTO", - }, - ExecutionConditions: &paymenttypes.QuoteExecutionConditions{ - Readiness: paymenttypes.QuoteExecutionReadinessIndicative, - }, - }, - }) - if !errors.Is(err, ErrNotExecutable) { - t.Fatalf("expected ErrNotExecutable, got %v", err) - } -} - -func TestCompile_ValidationErrors(t *testing.T) { - compiler := New() - - enabled := true - - tests := []struct { - name string - in Input - }{ - { - name: "missing intent", - in: Input{ - QuoteSnapshot: &model.PaymentQuoteSnapshot{ - Route: &paymenttypes.QuoteRouteSpecification{Rail: "CRYPTO"}, - }, - }, - }, - { - name: "missing quote", - in: Input{ - IntentSnapshot: testIntent(model.PaymentKindPayout), - }, - }, - { - name: "missing route", - in: Input{ - IntentSnapshot: testIntent(model.PaymentKindPayout), - QuoteSnapshot: &model.PaymentQuoteSnapshot{}, - }, - }, - { - name: "unknown hop rail", - in: Input{ - IntentSnapshot: testIntent(model.PaymentKindPayout), - QuoteSnapshot: &model.PaymentQuoteSnapshot{ - Route: &paymenttypes.QuoteRouteSpecification{ - Hops: []*paymenttypes.QuoteRouteHop{{Index: 1, Rail: "UNKNOWN"}}, - }, - }, - }, - }, - { - name: "invalid policy step action", - in: Input{ - IntentSnapshot: testIntent(model.PaymentKindPayout), - QuoteSnapshot: &model.PaymentQuoteSnapshot{ - Route: &paymenttypes.QuoteRouteSpecification{ - Hops: []*paymenttypes.QuoteRouteHop{ - {Index: 1, Rail: "LEDGER", Role: paymenttypes.QuoteRouteHopRoleSource}, - {Index: 2, Rail: "CARD_PAYOUT", Role: paymenttypes.QuoteRouteHopRoleDestination}, - }, - }, - }, - Policies: []Policy{ - { - ID: "bad-policy", - Enabled: &enabled, - Priority: 1, - Match: EdgeMatch{ - Source: EndpointMatch{Rail: railPtr(model.RailLedger)}, - Target: EndpointMatch{Rail: railPtr(model.RailCardPayout)}, - }, - Steps: []PolicyStep{ - {Code: "bad.step", Action: model.RailOperationUnspecified}, - }, - }, - }, - }, - }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - _, err := compiler.Compile(tt.in) - if !errors.Is(err, merrors.ErrInvalidArg) { - t.Fatalf("expected invalid argument, got %v", err) - } - }) - } -} - -func assertStep( - t *testing.T, - step Step, - code string, - action model.RailOperation, - rail model.Rail, - visibility model.ReportVisibility, -) { - t.Helper() - if got, want := step.StepCode, code; got != want { - t.Fatalf("step code mismatch: got=%q want=%q", got, want) - } - if got, want := step.Action, action; got != want { - t.Fatalf("step action mismatch: got=%q want=%q", got, want) - } - if got, want := step.Rail, rail; got != want { - t.Fatalf("step rail mismatch: got=%q want=%q", got, want) - } - if got, want := step.Visibility, visibility; got != want { - t.Fatalf("step visibility mismatch: got=%q want=%q", got, want) - } -} - -func testIntent(kind model.PaymentKind) model.PaymentIntent { - return model.PaymentIntent{ - Kind: kind, - Amount: &paymenttypes.Money{ - Amount: "10", - Currency: "USD", - }, - } -} - -func railPtr(v model.Rail) *model.Rail { - return &v -} - -func equalStringSlice(a, b []string) bool { - if len(a) != len(b) { - return false - } - for i := range a { - if a[i] != b[i] { - return false - } - } - return true -} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/compile_policy_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/compile_policy_test.go new file mode 100644 index 00000000..1d90930a --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/compile_policy_test.go @@ -0,0 +1,219 @@ +package xplan + +import ( + "errors" + "testing" + + "github.com/tech/sendico/payments/storage/model" + "github.com/tech/sendico/pkg/merrors" + paymenttypes "github.com/tech/sendico/pkg/payments/types" +) + +func TestCompile_PolicyOverrideByRailPair(t *testing.T) { + compiler := New() + + cardRail := model.RailCardPayout + ledgerRail := model.RailLedger + + graph, err := compiler.Compile(Input{ + IntentSnapshot: testIntent(model.PaymentKindPayout), + QuoteSnapshot: &model.PaymentQuoteSnapshot{ + Route: &paymenttypes.QuoteRouteSpecification{ + Hops: []*paymenttypes.QuoteRouteHop{ + {Index: 10, Rail: "CRYPTO", Role: paymenttypes.QuoteRouteHopRoleSource}, + {Index: 20, Rail: "CARD_PAYOUT", Role: paymenttypes.QuoteRouteHopRoleDestination}, + }, + }, + }, + Policies: []Policy{ + { + ID: "crypto-to-card-override", + Match: EdgeMatch{ + Source: EndpointMatch{Rail: railPtr(model.RailCrypto)}, + Target: EndpointMatch{Rail: railPtr(model.RailCardPayout)}, + }, + Steps: []PolicyStep{ + {Code: "custom.review", Action: model.RailOperationMove, Rail: &ledgerRail}, + {Code: "custom.submit", Action: model.RailOperationSend, Rail: &cardRail, Visibility: model.ReportVisibilityUser}, + }, + Success: []PolicyStep{ + {Code: "custom.finalize", Action: model.RailOperationDebit, Rail: &ledgerRail}, + }, + Failure: []PolicyStep{ + {Code: "custom.release", Action: model.RailOperationRelease, Rail: &ledgerRail}, + }, + }, + }, + }) + if err != nil { + t.Fatalf("Compile returned error: %v", err) + } + + if len(graph.Steps) != 4 { + t.Fatalf("expected 4 steps, got %d", len(graph.Steps)) + } + assertStep(t, graph.Steps[0], "custom.review", model.RailOperationMove, model.RailLedger, model.ReportVisibilityHidden) + assertStep(t, graph.Steps[1], "custom.submit", model.RailOperationSend, model.RailCardPayout, model.ReportVisibilityUser) + assertStep(t, graph.Steps[2], "custom.finalize", model.RailOperationDebit, model.RailLedger, model.ReportVisibilityHidden) + assertStep(t, graph.Steps[3], "custom.release", model.RailOperationRelease, model.RailLedger, model.ReportVisibilityHidden) + + if graph.Steps[2].CommitPolicy != model.CommitPolicyAfterSuccess { + t.Fatalf("expected custom.finalize AFTER_SUCCESS, got %q", graph.Steps[2].CommitPolicy) + } + if graph.Steps[3].CommitPolicy != model.CommitPolicyAfterFailure { + t.Fatalf("expected custom.release AFTER_FAILURE, got %q", graph.Steps[3].CommitPolicy) + } +} + +func TestCompile_PolicyPriorityAndCustodyMatching(t *testing.T) { + compiler := New() + cardRail := model.RailCardPayout + + on := true + external := CustodyExternal + + graph, err := compiler.Compile(Input{ + IntentSnapshot: testIntent(model.PaymentKindPayout), + QuoteSnapshot: &model.PaymentQuoteSnapshot{ + Route: &paymenttypes.QuoteRouteSpecification{ + Hops: []*paymenttypes.QuoteRouteHop{ + {Index: 10, Rail: "CRYPTO", Role: paymenttypes.QuoteRouteHopRoleSource}, + {Index: 20, Rail: "CARD_PAYOUT", Role: paymenttypes.QuoteRouteHopRoleDestination}, + }, + }, + }, + Policies: []Policy{ + { + ID: "generic-external", + Enabled: &on, + Priority: 1, + Match: EdgeMatch{ + Source: EndpointMatch{Custody: &external}, + Target: EndpointMatch{Custody: &external}, + }, + Steps: []PolicyStep{{Code: "generic.submit", Action: model.RailOperationSend, Rail: &cardRail}}, + }, + { + ID: "specific-crypto-card", + Enabled: &on, + Priority: 10, + Match: EdgeMatch{ + Source: EndpointMatch{Rail: railPtr(model.RailCrypto), Custody: &external}, + Target: EndpointMatch{Rail: railPtr(model.RailCardPayout), Custody: &external}, + }, + Steps: []PolicyStep{{Code: "specific.submit", Action: model.RailOperationSend, Rail: &cardRail}}, + }, + }, + }) + if err != nil { + t.Fatalf("Compile returned error: %v", err) + } + + if len(graph.Steps) != 1 { + t.Fatalf("expected 1 policy step, got %d", len(graph.Steps)) + } + if got, want := graph.Steps[0].StepCode, "specific.submit"; got != want { + t.Fatalf("expected high-priority specific policy, got %q", got) + } +} + +func TestCompile_IndicativeRejected(t *testing.T) { + compiler := New() + + _, err := compiler.Compile(Input{ + IntentSnapshot: testIntent(model.PaymentKindPayout), + QuoteSnapshot: &model.PaymentQuoteSnapshot{ + Route: &paymenttypes.QuoteRouteSpecification{ + Rail: "CRYPTO", + }, + ExecutionConditions: &paymenttypes.QuoteExecutionConditions{ + Readiness: paymenttypes.QuoteExecutionReadinessIndicative, + }, + }, + }) + if !errors.Is(err, ErrNotExecutable) { + t.Fatalf("expected ErrNotExecutable, got %v", err) + } +} + +func TestCompile_ValidationErrors(t *testing.T) { + compiler := New() + + enabled := true + + tests := []struct { + name string + in Input + }{ + { + name: "missing intent", + in: Input{ + QuoteSnapshot: &model.PaymentQuoteSnapshot{ + Route: &paymenttypes.QuoteRouteSpecification{Rail: "CRYPTO"}, + }, + }, + }, + { + name: "missing quote", + in: Input{ + IntentSnapshot: testIntent(model.PaymentKindPayout), + }, + }, + { + name: "missing route", + in: Input{ + IntentSnapshot: testIntent(model.PaymentKindPayout), + QuoteSnapshot: &model.PaymentQuoteSnapshot{}, + }, + }, + { + name: "unknown hop rail", + in: Input{ + IntentSnapshot: testIntent(model.PaymentKindPayout), + QuoteSnapshot: &model.PaymentQuoteSnapshot{ + Route: &paymenttypes.QuoteRouteSpecification{ + Hops: []*paymenttypes.QuoteRouteHop{{Index: 1, Rail: "UNKNOWN"}}, + }, + }, + }, + }, + { + name: "invalid policy step action", + in: Input{ + IntentSnapshot: testIntent(model.PaymentKindPayout), + QuoteSnapshot: &model.PaymentQuoteSnapshot{ + Route: &paymenttypes.QuoteRouteSpecification{ + Hops: []*paymenttypes.QuoteRouteHop{ + {Index: 1, Rail: "LEDGER", Role: paymenttypes.QuoteRouteHopRoleSource}, + {Index: 2, Rail: "CARD_PAYOUT", Role: paymenttypes.QuoteRouteHopRoleDestination}, + }, + }, + }, + Policies: []Policy{ + { + ID: "bad-policy", + Enabled: &enabled, + Priority: 1, + Match: EdgeMatch{ + Source: EndpointMatch{Rail: railPtr(model.RailLedger)}, + Target: EndpointMatch{Rail: railPtr(model.RailCardPayout)}, + }, + Steps: []PolicyStep{ + {Code: "bad.step", Action: model.RailOperationUnspecified}, + }, + }, + }, + }, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + _, err := compiler.Compile(tt.in) + if !errors.Is(err, merrors.ErrInvalidArg) { + t.Fatalf("expected invalid argument, got %v", err) + } + }) + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/expansion.go b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/expansion.go new file mode 100644 index 00000000..3efcc9a0 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/expansion.go @@ -0,0 +1,117 @@ +package xplan + +import ( + "strings" + + "github.com/tech/sendico/payments/storage/model" + paymenttypes "github.com/tech/sendico/pkg/payments/types" +) + +func (e *expansion) appendMain(step Step) string { + step = normalizeStep(step) + if len(step.DependsOn) == 0 && strings.TrimSpace(e.lastMainRef) != "" { + step.DependsOn = []string{e.lastMainRef} + } + if len(step.CommitAfter) == 0 && step.CommitPolicy != model.CommitPolicyUnspecified { + step.CommitAfter = cloneStringSlice(step.DependsOn) + } + step.StepRef = e.nextRef(firstNonEmpty(step.StepRef, step.StepCode)) + if strings.TrimSpace(step.StepCode) == "" { + step.StepCode = step.StepRef + } + e.steps = append(e.steps, step) + e.lastMainRef = step.StepRef + return step.StepRef +} + +func (e *expansion) appendBranch(step Step) string { + step = normalizeStep(step) + if len(step.CommitAfter) == 0 && step.CommitPolicy != model.CommitPolicyUnspecified { + step.CommitAfter = cloneStringSlice(step.DependsOn) + } + step.StepRef = e.nextRef(firstNonEmpty(step.StepRef, step.StepCode)) + if strings.TrimSpace(step.StepCode) == "" { + step.StepCode = step.StepRef + } + e.steps = append(e.steps, step) + return step.StepRef +} + +func (e *expansion) nextRef(base string) string { + token := sanitizeToken(base) + if token == "" { + token = "step" + } + count := e.refSeq[token] + e.refSeq[token] = count + 1 + if count == 0 { + return token + } + return token + "_" + itoa(count+1) +} + +func normalizeStep(step Step) Step { + step.StepRef = strings.TrimSpace(step.StepRef) + step.StepCode = strings.TrimSpace(step.StepCode) + step.Gateway = strings.TrimSpace(step.Gateway) + step.InstanceID = strings.TrimSpace(step.InstanceID) + step.UserLabel = strings.TrimSpace(step.UserLabel) + step.Visibility = model.NormalizeReportVisibility(step.Visibility) + step.CommitPolicy = normalizeCommitPolicy(step.CommitPolicy) + step.DependsOn = normalizeStringList(step.DependsOn) + step.CommitAfter = normalizeStringList(step.CommitAfter) + step.Metadata = normalizeMetadata(step.Metadata) + return step +} + +func normalizeCommitPolicy(policy model.CommitPolicy) model.CommitPolicy { + switch strings.ToUpper(strings.TrimSpace(string(policy))) { + case string(model.CommitPolicyImmediate): + return model.CommitPolicyImmediate + case string(model.CommitPolicyAfterSuccess): + return model.CommitPolicyAfterSuccess + case string(model.CommitPolicyAfterFailure): + return model.CommitPolicyAfterFailure + case string(model.CommitPolicyAfterCanceled): + return model.CommitPolicyAfterCanceled + default: + return model.CommitPolicyUnspecified + } +} + +func defaultVisibilityForAction(action model.RailOperation, role paymenttypes.QuoteRouteHopRole) model.ReportVisibility { + switch action { + case model.RailOperationSend, model.RailOperationObserveConfirm: + if role == paymenttypes.QuoteRouteHopRoleDestination { + return model.ReportVisibilityUser + } + return model.ReportVisibilityBackoffice + default: + return model.ReportVisibilityHidden + } +} + +func defaultUserLabel( + action model.RailOperation, + rail model.Rail, + role paymenttypes.QuoteRouteHopRole, + kind model.PaymentKind, +) string { + if role != paymenttypes.QuoteRouteHopRoleDestination { + return "" + } + switch action { + case model.RailOperationSend: + if kind == model.PaymentKindPayout && rail == model.RailCardPayout { + return "Card payout submitted" + } + return "Transfer submitted" + case model.RailOperationObserveConfirm: + if kind == model.PaymentKindPayout && rail == model.RailCardPayout { + return "Card payout confirmed" + } + return "Transfer confirmed" + default: + return "" + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/helpers.go b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/helpers.go new file mode 100644 index 00000000..167cbb1c --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/helpers.go @@ -0,0 +1,121 @@ +package xplan + +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 sanitizeToken(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + if value == "" { + return "" + } + + var b strings.Builder + prevUnderscore := false + for _, r := range value { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + b.WriteRune(r) + prevUnderscore = false + continue + } + if !prevUnderscore { + b.WriteByte('_') + prevUnderscore = true + } + } + + return strings.Trim(b.String(), "_") +} + +func normalizeStringList(items []string) []string { + if len(items) == 0 { + return nil + } + seen := make(map[string]struct{}, len(items)) + out := make([]string, 0, len(items)) + for _, item := range items { + token := strings.TrimSpace(item) + if token == "" { + continue + } + if _, exists := seen[token]; exists { + continue + } + seen[token] = struct{}{} + out = append(out, token) + } + if len(out) == 0 { + return nil + } + return out +} + +func normalizeMetadata(input map[string]string) map[string]string { + if len(input) == 0 { + return nil + } + out := make(map[string]string, len(input)) + for key, value := range input { + k := strings.TrimSpace(key) + if k == "" { + continue + } + out[k] = strings.TrimSpace(value) + } + if len(out) == 0 { + return nil + } + return out +} + +func cloneMetadata(input map[string]string) map[string]string { + if len(input) == 0 { + return nil + } + out := make(map[string]string, len(input)) + for key, value := range input { + out[key] = value + } + return out +} + +func cloneStringSlice(values []string) []string { + if len(values) == 0 { + return nil + } + out := make([]string, len(values)) + copy(out, values) + return out +} + +func itoa(v int) string { + if v == 0 { + return "0" + } + 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:]) +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/module.go index f2d8411f..f1295002 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/module.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/module.go @@ -2,6 +2,7 @@ package xplan import ( "github.com/tech/sendico/payments/storage/model" + "github.com/tech/sendico/pkg/mlogger" paymenttypes "github.com/tech/sendico/pkg/payments/types" ) @@ -105,6 +106,17 @@ type Policy struct { Failure []PolicyStep `json:"failure,omitempty" bson:"failure,omitempty"` } -func New() Compiler { - return &svc{} +// Dependencies configures execution graph compiler integrations. +type Dependencies struct { + Logger mlogger.Logger +} + +func New(deps ...Dependencies) Compiler { + var dep Dependencies + if len(deps) > 0 { + dep = deps[0] + } + return &svc{ + logger: dep.Logger.Named("xplan"), + } } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/route.go b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/route.go new file mode 100644 index 00000000..81bb0346 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/route.go @@ -0,0 +1,182 @@ +package xplan + +import ( + "fmt" + "slices" + "strings" + + "github.com/tech/sendico/payments/storage/model" + "github.com/tech/sendico/pkg/merrors" + paymenttypes "github.com/tech/sendico/pkg/payments/types" +) + +func internalRailForBoundary(from normalizedHop, to normalizedHop) model.Rail { + if isInternalRail(from.rail) { + return from.rail + } + if isInternalRail(to.rail) { + return to.rail + } + return model.RailLedger +} + +func isInternalRail(rail model.Rail) bool { + return rail == model.RailLedger +} + +func isExternalRail(rail model.Rail) bool { + switch rail { + case model.RailCrypto, model.RailProviderSettlement, model.RailCardPayout, model.RailFiatOnRamp: + return true + default: + return false + } +} + +func custodyForRail(rail model.Rail) Custody { + if isInternalRail(rail) { + return CustodyInternal + } + if isExternalRail(rail) { + return CustodyExternal + } + return CustodyUnspecified +} + +func singleHopCode(hop normalizedHop, op string) string { + return fmt.Sprintf("hop.%d.%s.%s", hop.index, railToken(hop.rail), strings.TrimSpace(op)) +} + +func edgeCode(from normalizedHop, to normalizedHop, rail model.Rail, op string) string { + return fmt.Sprintf( + "edge.%d_%d.%s.%s", + from.index, + to.index, + railToken(rail), + strings.TrimSpace(op), + ) +} + +func railToken(rail model.Rail) string { + return strings.ToLower(strings.TrimSpace(string(rail))) +} + +func observedKey(hop normalizedHop) string { + return fmt.Sprintf("%d:%d:%s:%s", hop.pos, hop.index, strings.TrimSpace(string(hop.rail)), hop.instanceID) +} + +func normalizeRouteHops(route *paymenttypes.QuoteRouteSpecification, intent model.PaymentIntent) ([]normalizedHop, error) { + if route == nil { + return nil, merrors.InvalidArgument("quote_snapshot.route is required") + } + + if len(route.Hops) == 0 { + rail := normalizeRail(route.Rail) + if rail == model.RailUnspecified { + return nil, merrors.InvalidArgument("quote_snapshot.route.rail is required") + } + return []normalizedHop{ + { + index: 0, + rail: rail, + gateway: strings.TrimSpace(route.Provider), + instanceID: "", + network: strings.TrimSpace(route.Network), + role: paymenttypes.QuoteRouteHopRoleDestination, + pos: 0, + }, + }, nil + } + + hops := make([]normalizedHop, 0, len(route.Hops)) + for i, hop := range route.Hops { + if hop == nil { + continue + } + + rail := normalizeRail(firstNonEmpty(hop.Rail, route.Rail)) + if rail == model.RailUnspecified { + return nil, merrors.InvalidArgument("quote_snapshot.route.hops[" + itoa(i) + "].rail is required") + } + + hops = append(hops, normalizedHop{ + index: hop.Index, + rail: rail, + gateway: strings.TrimSpace(firstNonEmpty(hop.Gateway, route.Provider)), + instanceID: strings.TrimSpace(hop.InstanceID), + network: strings.TrimSpace(firstNonEmpty(hop.Network, route.Network)), + role: normalizeHopRole(hop.Role, i, len(route.Hops), intent), + pos: i, + }) + } + + if len(hops) == 0 { + return nil, merrors.InvalidArgument("quote_snapshot.route.hops are empty") + } + + slices.SortFunc(hops, func(a, b normalizedHop) int { + switch { + case a.index < b.index: + return -1 + case a.index > b.index: + return 1 + case a.pos < b.pos: + return -1 + case a.pos > b.pos: + return 1 + default: + return 0 + } + }) + + return hops, nil +} + +func normalizeHopRole( + role paymenttypes.QuoteRouteHopRole, + position int, + total int, + _ model.PaymentIntent, +) paymenttypes.QuoteRouteHopRole { + switch role { + case paymenttypes.QuoteRouteHopRoleSource, + paymenttypes.QuoteRouteHopRoleTransit, + paymenttypes.QuoteRouteHopRoleDestination: + return role + } + + if total <= 1 { + return paymenttypes.QuoteRouteHopRoleDestination + } + if position == 0 { + return paymenttypes.QuoteRouteHopRoleSource + } + if position == total-1 { + return paymenttypes.QuoteRouteHopRoleDestination + } + return paymenttypes.QuoteRouteHopRoleTransit +} + +func normalizeRail(raw string) model.Rail { + token := strings.ToUpper(strings.TrimSpace(raw)) + token = strings.ReplaceAll(token, "-", "_") + token = strings.ReplaceAll(token, " ", "_") + for strings.Contains(token, "__") { + token = strings.ReplaceAll(token, "__", "_") + } + + switch token { + case "CRYPTO": + return model.RailCrypto + case "PROVIDER_SETTLEMENT", "PROVIDER": + return model.RailProviderSettlement + case "LEDGER": + return model.RailLedger + case "CARD_PAYOUT", "CARD": + return model.RailCardPayout + case "FIAT_ONRAMP", "FIAT_ON_RAMP": + return model.RailFiatOnRamp + default: + return model.RailUnspecified + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/service.go b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/service.go index c1cc2b8b..405b9d9b 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/service.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/service.go @@ -1,16 +1,19 @@ package xplan import ( - "fmt" - "slices" "strings" + "time" "github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" paymenttypes "github.com/tech/sendico/pkg/payments/types" + "go.uber.org/zap" ) -type svc struct{} +type svc struct { + logger mlogger.Logger +} type normalizedHop struct { index uint32 @@ -36,7 +39,29 @@ func newExpansion() *expansion { } } -func (s *svc) Compile(in Input) (*Graph, error) { +func (s *svc) Compile(in Input) (graph *Graph, err error) { + logger := s.logger + logger.Debug("Starting Compile", + zap.String("intent_ref", strings.TrimSpace(in.IntentSnapshot.Ref)), + zap.String("quote_ref", quoteRef(in.QuoteSnapshot)), + zap.Int("policies_count", len(in.Policies)), + ) + defer func(start time.Time) { + fields := []zap.Field{zap.Int64("duration_ms", time.Since(start).Milliseconds())} + if graph != nil { + fields = append(fields, + zap.String("route_ref", strings.TrimSpace(graph.RouteRef)), + zap.String("readiness", strings.TrimSpace(string(graph.Readiness))), + zap.Int("steps_count", len(graph.Steps)), + ) + } + if err != nil { + logger.Warn("Failed to compile", append(fields, zap.Error(err))...) + return + } + logger.Debug("Completed Compile", fields...) + }(time.Now()) + if isEmptyIntentSnapshot(in.IntentSnapshot) { return nil, merrors.InvalidArgument("intent_snapshot is required") } @@ -91,899 +116,17 @@ func (s *svc) Compile(in Input) (*Graph, error) { return nil, merrors.InvalidArgument("compiled graph is empty") } - return &Graph{ + graph = &Graph{ RouteRef: strings.TrimSpace(in.QuoteSnapshot.Route.RouteRef), Readiness: readiness, Steps: ex.steps, - }, nil + } + return graph, nil } -func (s *svc) expandSingleHop(ex *expansion, hop normalizedHop, intent model.PaymentIntent) error { - if isExternalRail(hop.rail) { - _, err := s.ensureExternalObserved(ex, hop, intent) - return err - } - - switch hop.role { - case paymenttypes.QuoteRouteHopRoleSource: - ex.appendMain(Step{ - StepCode: singleHopCode(hop, "debit"), - Kind: StepKindFundsDebit, - Action: model.RailOperationDebit, - Rail: hop.rail, - HopIndex: hop.index, - HopRole: hop.role, - Visibility: model.ReportVisibilityHidden, - }) - case paymenttypes.QuoteRouteHopRoleDestination: - ex.appendMain(Step{ - StepCode: singleHopCode(hop, "credit"), - Kind: StepKindFundsCredit, - Action: model.RailOperationCredit, - Rail: hop.rail, - HopIndex: hop.index, - HopRole: hop.role, - Visibility: model.ReportVisibilityHidden, - }) - default: - ex.appendMain(Step{ - StepCode: singleHopCode(hop, "move"), - Kind: StepKindFundsMove, - Action: model.RailOperationMove, - Rail: hop.rail, - HopIndex: hop.index, - HopRole: hop.role, - Visibility: model.ReportVisibilityHidden, - }) - } - return nil -} - -func (s *svc) applyDefaultBoundary( - ex *expansion, - from normalizedHop, - to normalizedHop, - intent model.PaymentIntent, -) error { - switch { - case isExternalRail(from.rail) && isInternalRail(to.rail): - if _, err := s.ensureExternalObserved(ex, from, intent); err != nil { - return err - } - ex.appendMain(makeFundsCreditStep(from, to, internalRailForBoundary(from, to))) - return nil - - case isInternalRail(from.rail) && isExternalRail(to.rail): - internalRail := internalRailForBoundary(from, to) - ex.appendMain(makeFundsBlockStep(from, to, internalRail)) - observeRef, err := s.ensureExternalObserved(ex, to, intent) - if err != nil { - return err - } - appendSettlementBranches(ex, from, to, internalRail, observeRef) - return nil - - case isExternalRail(from.rail) && isExternalRail(to.rail): - if _, err := s.ensureExternalObserved(ex, from, intent); err != nil { - return err - } - internalRail := internalRailForBoundary(from, to) - ex.appendMain(makeFundsCreditStep(from, to, internalRail)) - ex.appendMain(makeFundsBlockStep(from, to, internalRail)) - observeRef, err := s.ensureExternalObserved(ex, to, intent) - if err != nil { - return err - } - appendSettlementBranches(ex, from, to, internalRail, observeRef) - return nil - - case isInternalRail(from.rail) && isInternalRail(to.rail): - ex.appendMain(makeFundsMoveStep(from, to, internalRailForBoundary(from, to))) - return nil - - default: - return merrors.InvalidArgument("unsupported rail boundary") - } -} - -func (s *svc) ensureExternalObserved(ex *expansion, hop normalizedHop, intent model.PaymentIntent) (string, error) { - key := observedKey(hop) - if ref := strings.TrimSpace(ex.externalObserved[key]); ref != "" { - return ref, nil - } - - sendStep := makeRailSendStep(hop, intent) - sendRef := ex.appendMain(sendStep) - - observeStep := makeRailObserveStep(hop, intent) - if sendRef != "" { - observeStep.DependsOn = []string{sendRef} - } - observeRef := ex.appendMain(observeStep) - - ex.externalObserved[key] = observeRef - return observeRef, nil -} - -func (s *svc) applyPolicy( - ex *expansion, - policy Policy, - from normalizedHop, - to normalizedHop, - intent model.PaymentIntent, -) error { - if len(policy.Steps) == 0 { - return merrors.InvalidArgument("policy.steps are required") - } - - anchorRef := "" - for i := range policy.Steps { - step, err := policyStepToStep(policy.Steps[i], from, to, intent) - if err != nil { - return merrors.InvalidArgument("policy " + strings.TrimSpace(policy.ID) + " step[" + itoa(i) + "]: " + err.Error()) - } - anchorRef = ex.appendMain(step) - } - if strings.TrimSpace(anchorRef) == "" { - return merrors.InvalidArgument("policy produced no anchor step") - } - - for i := range policy.Success { - step, err := policyStepToStep(policy.Success[i], from, to, intent) - if err != nil { - return merrors.InvalidArgument("policy " + strings.TrimSpace(policy.ID) + " success[" + itoa(i) + "]: " + err.Error()) - } - if len(step.DependsOn) == 0 { - step.DependsOn = []string{anchorRef} - } - if len(step.CommitAfter) == 0 { - step.CommitAfter = cloneStringSlice(step.DependsOn) - } - step.CommitPolicy = model.CommitPolicyAfterSuccess - ex.appendBranch(step) - } - - for i := range policy.Failure { - step, err := policyStepToStep(policy.Failure[i], from, to, intent) - if err != nil { - return merrors.InvalidArgument("policy " + strings.TrimSpace(policy.ID) + " failure[" + itoa(i) + "]: " + err.Error()) - } - if len(step.DependsOn) == 0 { - step.DependsOn = []string{anchorRef} - } - if len(step.CommitAfter) == 0 { - step.CommitAfter = cloneStringSlice(step.DependsOn) - } - step.CommitPolicy = model.CommitPolicyAfterFailure - ex.appendBranch(step) - } - - return nil -} - -func selectPolicy(from normalizedHop, to normalizedHop, policies []Policy) *Policy { - best := -1 - bestPriority := 0 - - for i := range policies { - policy := &policies[i] - if !policyEnabled(*policy) { - continue - } - if !policyMatches(policy.Match, from, to) { - continue - } - - if best == -1 || policy.Priority > bestPriority { - best = i - bestPriority = policy.Priority - } - } - - if best == -1 { - return nil - } - return &policies[best] -} - -func policyEnabled(policy Policy) bool { - if policy.Enabled == nil { - return true - } - return *policy.Enabled -} - -func policyMatches(match EdgeMatch, from normalizedHop, to normalizedHop) bool { - return endpointMatches(match.Source, from) && endpointMatches(match.Target, to) -} - -func endpointMatches(match EndpointMatch, hop normalizedHop) bool { - if match.Rail != nil && normalizeRail(string(*match.Rail)) != hop.rail { - return false - } - if match.Custody != nil && *match.Custody != custodyForRail(hop.rail) { - return false - } - if gateway := strings.TrimSpace(match.Gateway); gateway != "" && !strings.EqualFold(gateway, hop.gateway) { - return false - } - if network := strings.TrimSpace(match.Network); network != "" && !strings.EqualFold(network, hop.network) { - return false - } - if strings.TrimSpace(match.Method) != "" { - // Method-matching is reserved for the next phase once method is passed in intent/route context. - return false - } - return true -} - -func policyStepToStep(spec PolicyStep, from normalizedHop, to normalizedHop, intent model.PaymentIntent) (Step, error) { - code := strings.TrimSpace(spec.Code) - if code == "" { - return Step{}, merrors.InvalidArgument("code is required") - } - - action := normalizeAction(spec.Action) - if action == model.RailOperationUnspecified { - return Step{}, merrors.InvalidArgument("action is required") - } - - rail := inferPolicyRail(spec, action, from, to) - if rail == model.RailUnspecified { - return Step{}, merrors.InvalidArgument("rail could not be inferred") - } - - hopIndex, hopRole, gateway, instanceID := resolveStepContext(rail, action, from, to) - - visibility := model.NormalizeReportVisibility(spec.Visibility) - if visibility == model.ReportVisibilityUnspecified { - visibility = defaultVisibilityForAction(action, hopRole) - } - - userLabel := strings.TrimSpace(spec.UserLabel) - if userLabel == "" && visibility == model.ReportVisibilityUser { - userLabel = defaultUserLabel(action, rail, hopRole, intent.Kind) - } - - return Step{ - StepCode: code, - Kind: kindForAction(action), - Action: action, - DependsOn: cloneStringSlice(spec.DependsOn), - Rail: rail, - Gateway: gateway, - InstanceID: instanceID, - HopIndex: hopIndex, - HopRole: hopRole, - Visibility: visibility, - UserLabel: userLabel, - Metadata: cloneMetadata(spec.Metadata), - }, nil -} - -func normalizeAction(action model.RailOperation) model.RailOperation { - switch strings.ToUpper(strings.TrimSpace(string(action))) { - case string(model.RailOperationDebit): - return model.RailOperationDebit - case string(model.RailOperationCredit): - return model.RailOperationCredit - case string(model.RailOperationExternalDebit): - return model.RailOperationExternalDebit - case string(model.RailOperationExternalCredit): - return model.RailOperationExternalCredit - case string(model.RailOperationMove): - return model.RailOperationMove - case string(model.RailOperationSend): - return model.RailOperationSend - case string(model.RailOperationFee): - return model.RailOperationFee - case string(model.RailOperationObserveConfirm): - return model.RailOperationObserveConfirm - case string(model.RailOperationFXConvert): - return model.RailOperationFXConvert - case string(model.RailOperationBlock): - return model.RailOperationBlock - case string(model.RailOperationRelease): - return model.RailOperationRelease - default: - return model.RailOperationUnspecified - } -} - -func inferPolicyRail(spec PolicyStep, action model.RailOperation, from normalizedHop, to normalizedHop) model.Rail { - if spec.Rail != nil { - return normalizeRail(string(*spec.Rail)) - } - - switch action { - case model.RailOperationSend, model.RailOperationObserveConfirm, model.RailOperationFee: - return to.rail - case model.RailOperationBlock, - model.RailOperationRelease, - model.RailOperationDebit, - model.RailOperationCredit, - model.RailOperationExternalDebit, - model.RailOperationExternalCredit, - model.RailOperationMove: - return internalRailForBoundary(from, to) - default: - return model.RailUnspecified - } -} - -func resolveStepContext( - rail model.Rail, - action model.RailOperation, - from normalizedHop, - to normalizedHop, -) (uint32, paymenttypes.QuoteRouteHopRole, string, string) { - if rail == to.rail && (action == model.RailOperationSend || action == model.RailOperationObserveConfirm || action == model.RailOperationFee) { - return to.index, to.role, to.gateway, to.instanceID - } - if rail == from.rail { - return from.index, from.role, from.gateway, from.instanceID - } - if rail == to.rail { - return to.index, to.role, to.gateway, to.instanceID - } - return to.index, paymenttypes.QuoteRouteHopRoleTransit, "", "" -} - -func kindForAction(action model.RailOperation) StepKind { - switch action { - case model.RailOperationSend: - return StepKindRailSend - case model.RailOperationObserveConfirm: - return StepKindRailObserve - case model.RailOperationCredit, model.RailOperationExternalCredit: - return StepKindFundsCredit - case model.RailOperationDebit, model.RailOperationExternalDebit: - return StepKindFundsDebit - case model.RailOperationMove: - return StepKindFundsMove - case model.RailOperationBlock: - return StepKindFundsBlock - case model.RailOperationRelease: - return StepKindFundsRelease - default: - return StepKindUnspecified - } -} - -func appendGuards(ex *expansion, conditions *paymenttypes.QuoteExecutionConditions) { - if conditions == nil { - return - } - - if conditions.LiquidityCheckRequiredAtExecution { - ex.appendMain(Step{ - StepCode: "liquidity.check", - Kind: StepKindLiquidityCheck, - Action: model.RailOperationUnspecified, - Rail: model.RailUnspecified, - Visibility: model.ReportVisibilityHidden, - }) - } - - if conditions.PrefundingRequired { - ex.appendMain(Step{ - StepCode: "prefunding.ensure", - Kind: StepKindPrefunding, - Action: model.RailOperationUnspecified, - Rail: model.RailUnspecified, - Visibility: model.ReportVisibilityHidden, - }) - } -} - -func makeRailSendStep(hop normalizedHop, intent model.PaymentIntent) Step { - visibility := defaultVisibilityForAction(model.RailOperationSend, hop.role) - userLabel := "" - if visibility == model.ReportVisibilityUser { - userLabel = defaultUserLabel(model.RailOperationSend, hop.rail, hop.role, intent.Kind) - } - return Step{ - StepCode: singleHopCode(hop, "send"), - Kind: StepKindRailSend, - Action: model.RailOperationSend, - Rail: hop.rail, - Gateway: hop.gateway, - InstanceID: hop.instanceID, - HopIndex: hop.index, - HopRole: hop.role, - Visibility: visibility, - UserLabel: userLabel, - } -} - -func makeRailObserveStep(hop normalizedHop, intent model.PaymentIntent) Step { - visibility := defaultVisibilityForAction(model.RailOperationObserveConfirm, hop.role) - userLabel := "" - if visibility == model.ReportVisibilityUser { - userLabel = defaultUserLabel(model.RailOperationObserveConfirm, hop.rail, hop.role, intent.Kind) - } - return Step{ - StepCode: singleHopCode(hop, "observe"), - Kind: StepKindRailObserve, - Action: model.RailOperationObserveConfirm, - Rail: hop.rail, - Gateway: hop.gateway, - InstanceID: hop.instanceID, - HopIndex: hop.index, - HopRole: hop.role, - Visibility: visibility, - UserLabel: userLabel, - } -} - -func makeFundsCreditStep(from normalizedHop, to normalizedHop, rail model.Rail) Step { - return Step{ - StepCode: edgeCode(from, to, rail, "credit"), - Kind: StepKindFundsCredit, - Action: model.RailOperationCredit, - Rail: rail, - HopIndex: to.index, - HopRole: paymenttypes.QuoteRouteHopRoleTransit, - Visibility: model.ReportVisibilityHidden, - } -} - -func makeFundsBlockStep(from normalizedHop, to normalizedHop, rail model.Rail) Step { - return Step{ - StepCode: edgeCode(from, to, rail, "block"), - Kind: StepKindFundsBlock, - Action: model.RailOperationBlock, - Rail: rail, - HopIndex: to.index, - HopRole: paymenttypes.QuoteRouteHopRoleTransit, - Visibility: model.ReportVisibilityHidden, - } -} - -func makeFundsMoveStep(from normalizedHop, to normalizedHop, rail model.Rail) Step { - return Step{ - StepCode: edgeCode(from, to, rail, "move"), - Kind: StepKindFundsMove, - Action: model.RailOperationMove, - Rail: rail, - HopIndex: to.index, - HopRole: paymenttypes.QuoteRouteHopRoleTransit, - Visibility: model.ReportVisibilityHidden, - } -} - -func appendSettlementBranches( - ex *expansion, - from normalizedHop, - to normalizedHop, - rail model.Rail, - anchorObserveRef string, -) { - if strings.TrimSpace(anchorObserveRef) == "" { - return - } - - successStep := Step{ - StepCode: edgeCode(from, to, rail, "debit"), - Kind: StepKindFundsDebit, - Action: model.RailOperationDebit, - DependsOn: []string{anchorObserveRef}, - Rail: rail, - HopIndex: to.index, - HopRole: paymenttypes.QuoteRouteHopRoleTransit, - Visibility: model.ReportVisibilityHidden, - CommitPolicy: model.CommitPolicyAfterSuccess, - CommitAfter: []string{anchorObserveRef}, - Metadata: map[string]string{"mode": "finalize_debit"}, - } - ex.appendBranch(successStep) - - failureStep := Step{ - StepCode: edgeCode(from, to, rail, "release"), - Kind: StepKindFundsRelease, - Action: model.RailOperationRelease, - DependsOn: []string{anchorObserveRef}, - Rail: rail, - HopIndex: to.index, - HopRole: paymenttypes.QuoteRouteHopRoleTransit, - Visibility: model.ReportVisibilityHidden, - CommitPolicy: model.CommitPolicyAfterFailure, - CommitAfter: []string{anchorObserveRef}, - Metadata: map[string]string{"mode": "unlock_hold"}, - } - ex.appendBranch(failureStep) -} - -func (e *expansion) appendMain(step Step) string { - step = normalizeStep(step) - if len(step.DependsOn) == 0 && strings.TrimSpace(e.lastMainRef) != "" { - step.DependsOn = []string{e.lastMainRef} - } - if len(step.CommitAfter) == 0 && step.CommitPolicy != model.CommitPolicyUnspecified { - step.CommitAfter = cloneStringSlice(step.DependsOn) - } - step.StepRef = e.nextRef(firstNonEmpty(step.StepRef, step.StepCode)) - if strings.TrimSpace(step.StepCode) == "" { - step.StepCode = step.StepRef - } - e.steps = append(e.steps, step) - e.lastMainRef = step.StepRef - return step.StepRef -} - -func (e *expansion) appendBranch(step Step) string { - step = normalizeStep(step) - if len(step.CommitAfter) == 0 && step.CommitPolicy != model.CommitPolicyUnspecified { - step.CommitAfter = cloneStringSlice(step.DependsOn) - } - step.StepRef = e.nextRef(firstNonEmpty(step.StepRef, step.StepCode)) - if strings.TrimSpace(step.StepCode) == "" { - step.StepCode = step.StepRef - } - e.steps = append(e.steps, step) - return step.StepRef -} - -func (e *expansion) nextRef(base string) string { - token := sanitizeToken(base) - if token == "" { - token = "step" - } - count := e.refSeq[token] - e.refSeq[token] = count + 1 - if count == 0 { - return token - } - return token + "_" + itoa(count+1) -} - -func normalizeStep(step Step) Step { - step.StepRef = strings.TrimSpace(step.StepRef) - step.StepCode = strings.TrimSpace(step.StepCode) - step.Gateway = strings.TrimSpace(step.Gateway) - step.InstanceID = strings.TrimSpace(step.InstanceID) - step.UserLabel = strings.TrimSpace(step.UserLabel) - step.Visibility = model.NormalizeReportVisibility(step.Visibility) - step.CommitPolicy = normalizeCommitPolicy(step.CommitPolicy) - step.DependsOn = normalizeStringList(step.DependsOn) - step.CommitAfter = normalizeStringList(step.CommitAfter) - step.Metadata = normalizeMetadata(step.Metadata) - return step -} - -func normalizeCommitPolicy(policy model.CommitPolicy) model.CommitPolicy { - switch strings.ToUpper(strings.TrimSpace(string(policy))) { - case string(model.CommitPolicyImmediate): - return model.CommitPolicyImmediate - case string(model.CommitPolicyAfterSuccess): - return model.CommitPolicyAfterSuccess - case string(model.CommitPolicyAfterFailure): - return model.CommitPolicyAfterFailure - case string(model.CommitPolicyAfterCanceled): - return model.CommitPolicyAfterCanceled - default: - return model.CommitPolicyUnspecified - } -} - -func defaultVisibilityForAction(action model.RailOperation, role paymenttypes.QuoteRouteHopRole) model.ReportVisibility { - switch action { - case model.RailOperationSend, model.RailOperationObserveConfirm: - if role == paymenttypes.QuoteRouteHopRoleDestination { - return model.ReportVisibilityUser - } - return model.ReportVisibilityBackoffice - default: - return model.ReportVisibilityHidden - } -} - -func defaultUserLabel( - action model.RailOperation, - rail model.Rail, - role paymenttypes.QuoteRouteHopRole, - kind model.PaymentKind, -) string { - if role != paymenttypes.QuoteRouteHopRoleDestination { - return "" - } - switch action { - case model.RailOperationSend: - if kind == model.PaymentKindPayout && rail == model.RailCardPayout { - return "Card payout submitted" - } - return "Transfer submitted" - case model.RailOperationObserveConfirm: - if kind == model.PaymentKindPayout && rail == model.RailCardPayout { - return "Card payout confirmed" - } - return "Transfer confirmed" - default: +func quoteRef(snapshot *model.PaymentQuoteSnapshot) string { + if snapshot == nil { return "" } -} - -func internalRailForBoundary(from normalizedHop, to normalizedHop) model.Rail { - if isInternalRail(from.rail) { - return from.rail - } - if isInternalRail(to.rail) { - return to.rail - } - return model.RailLedger -} - -func isInternalRail(rail model.Rail) bool { - return rail == model.RailLedger -} - -func isExternalRail(rail model.Rail) bool { - switch rail { - case model.RailCrypto, model.RailProviderSettlement, model.RailCardPayout, model.RailFiatOnRamp: - return true - default: - return false - } -} - -func custodyForRail(rail model.Rail) Custody { - if isInternalRail(rail) { - return CustodyInternal - } - if isExternalRail(rail) { - return CustodyExternal - } - return CustodyUnspecified -} - -func singleHopCode(hop normalizedHop, op string) string { - return fmt.Sprintf("hop.%d.%s.%s", hop.index, railToken(hop.rail), strings.TrimSpace(op)) -} - -func edgeCode(from normalizedHop, to normalizedHop, rail model.Rail, op string) string { - return fmt.Sprintf( - "edge.%d_%d.%s.%s", - from.index, - to.index, - railToken(rail), - strings.TrimSpace(op), - ) -} - -func railToken(rail model.Rail) string { - return strings.ToLower(strings.TrimSpace(string(rail))) -} - -func observedKey(hop normalizedHop) string { - return fmt.Sprintf("%d:%d:%s:%s", hop.pos, hop.index, strings.TrimSpace(string(hop.rail)), hop.instanceID) -} - -func normalizeRouteHops(route *paymenttypes.QuoteRouteSpecification, intent model.PaymentIntent) ([]normalizedHop, error) { - if route == nil { - return nil, merrors.InvalidArgument("quote_snapshot.route is required") - } - - if len(route.Hops) == 0 { - rail := normalizeRail(route.Rail) - if rail == model.RailUnspecified { - return nil, merrors.InvalidArgument("quote_snapshot.route.rail is required") - } - return []normalizedHop{ - { - index: 0, - rail: rail, - gateway: strings.TrimSpace(route.Provider), - instanceID: "", - network: strings.TrimSpace(route.Network), - role: paymenttypes.QuoteRouteHopRoleDestination, - pos: 0, - }, - }, nil - } - - hops := make([]normalizedHop, 0, len(route.Hops)) - for i, hop := range route.Hops { - if hop == nil { - continue - } - - rail := normalizeRail(firstNonEmpty(hop.Rail, route.Rail)) - if rail == model.RailUnspecified { - return nil, merrors.InvalidArgument("quote_snapshot.route.hops[" + itoa(i) + "].rail is required") - } - - hops = append(hops, normalizedHop{ - index: hop.Index, - rail: rail, - gateway: strings.TrimSpace(firstNonEmpty(hop.Gateway, route.Provider)), - instanceID: strings.TrimSpace(hop.InstanceID), - network: strings.TrimSpace(firstNonEmpty(hop.Network, route.Network)), - role: normalizeHopRole(hop.Role, i, len(route.Hops), intent), - pos: i, - }) - } - - if len(hops) == 0 { - return nil, merrors.InvalidArgument("quote_snapshot.route.hops are empty") - } - - slices.SortFunc(hops, func(a, b normalizedHop) int { - switch { - case a.index < b.index: - return -1 - case a.index > b.index: - return 1 - case a.pos < b.pos: - return -1 - case a.pos > b.pos: - return 1 - default: - return 0 - } - }) - - return hops, nil -} - -func normalizeHopRole( - role paymenttypes.QuoteRouteHopRole, - position int, - total int, - _ model.PaymentIntent, -) paymenttypes.QuoteRouteHopRole { - switch role { - case paymenttypes.QuoteRouteHopRoleSource, - paymenttypes.QuoteRouteHopRoleTransit, - paymenttypes.QuoteRouteHopRoleDestination: - return role - } - - if total <= 1 { - return paymenttypes.QuoteRouteHopRoleDestination - } - if position == 0 { - return paymenttypes.QuoteRouteHopRoleSource - } - if position == total-1 { - return paymenttypes.QuoteRouteHopRoleDestination - } - return paymenttypes.QuoteRouteHopRoleTransit -} - -func normalizeRail(raw string) model.Rail { - token := strings.ToUpper(strings.TrimSpace(raw)) - token = strings.ReplaceAll(token, "-", "_") - token = strings.ReplaceAll(token, " ", "_") - for strings.Contains(token, "__") { - token = strings.ReplaceAll(token, "__", "_") - } - - switch token { - case "CRYPTO": - return model.RailCrypto - case "PROVIDER_SETTLEMENT", "PROVIDER": - return model.RailProviderSettlement - case "LEDGER": - return model.RailLedger - case "CARD_PAYOUT", "CARD": - return model.RailCardPayout - case "FIAT_ONRAMP", "FIAT_ON_RAMP": - return model.RailFiatOnRamp - default: - return model.RailUnspecified - } -} - -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 sanitizeToken(value string) string { - value = strings.ToLower(strings.TrimSpace(value)) - if value == "" { - return "" - } - - var b strings.Builder - prevUnderscore := false - for _, r := range value { - if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { - b.WriteRune(r) - prevUnderscore = false - continue - } - if !prevUnderscore { - b.WriteByte('_') - prevUnderscore = true - } - } - - return strings.Trim(b.String(), "_") -} - -func normalizeStringList(items []string) []string { - if len(items) == 0 { - return nil - } - seen := make(map[string]struct{}, len(items)) - out := make([]string, 0, len(items)) - for _, item := range items { - token := strings.TrimSpace(item) - if token == "" { - continue - } - if _, exists := seen[token]; exists { - continue - } - seen[token] = struct{}{} - out = append(out, token) - } - if len(out) == 0 { - return nil - } - return out -} - -func normalizeMetadata(input map[string]string) map[string]string { - if len(input) == 0 { - return nil - } - out := make(map[string]string, len(input)) - for key, value := range input { - k := strings.TrimSpace(key) - if k == "" { - continue - } - out[k] = strings.TrimSpace(value) - } - if len(out) == 0 { - return nil - } - return out -} - -func cloneMetadata(input map[string]string) map[string]string { - if len(input) == 0 { - return nil - } - out := make(map[string]string, len(input)) - for key, value := range input { - out[key] = value - } - return out -} - -func cloneStringSlice(values []string) []string { - if len(values) == 0 { - return nil - } - out := make([]string, len(values)) - copy(out, values) - return out -} - -func itoa(v int) string { - if v == 0 { - return "0" - } - 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:]) + return strings.TrimSpace(snapshot.QuoteRef) } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/service_boundaries.go b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/service_boundaries.go new file mode 100644 index 00000000..1318404b --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/service_boundaries.go @@ -0,0 +1,260 @@ +package xplan + +import ( + "strings" + + "github.com/tech/sendico/payments/storage/model" + "github.com/tech/sendico/pkg/merrors" + paymenttypes "github.com/tech/sendico/pkg/payments/types" +) + +func (s *svc) expandSingleHop(ex *expansion, hop normalizedHop, intent model.PaymentIntent) error { + if isExternalRail(hop.rail) { + _, err := s.ensureExternalObserved(ex, hop, intent) + return err + } + + switch hop.role { + case paymenttypes.QuoteRouteHopRoleSource: + ex.appendMain(Step{ + StepCode: singleHopCode(hop, "debit"), + Kind: StepKindFundsDebit, + Action: model.RailOperationDebit, + Rail: hop.rail, + HopIndex: hop.index, + HopRole: hop.role, + Visibility: model.ReportVisibilityHidden, + }) + case paymenttypes.QuoteRouteHopRoleDestination: + ex.appendMain(Step{ + StepCode: singleHopCode(hop, "credit"), + Kind: StepKindFundsCredit, + Action: model.RailOperationCredit, + Rail: hop.rail, + HopIndex: hop.index, + HopRole: hop.role, + Visibility: model.ReportVisibilityHidden, + }) + default: + ex.appendMain(Step{ + StepCode: singleHopCode(hop, "move"), + Kind: StepKindFundsMove, + Action: model.RailOperationMove, + Rail: hop.rail, + HopIndex: hop.index, + HopRole: hop.role, + Visibility: model.ReportVisibilityHidden, + }) + } + return nil +} + +func (s *svc) applyDefaultBoundary( + ex *expansion, + from normalizedHop, + to normalizedHop, + intent model.PaymentIntent, +) error { + switch { + case isExternalRail(from.rail) && isInternalRail(to.rail): + if _, err := s.ensureExternalObserved(ex, from, intent); err != nil { + return err + } + ex.appendMain(makeFundsCreditStep(from, to, internalRailForBoundary(from, to))) + return nil + + case isInternalRail(from.rail) && isExternalRail(to.rail): + internalRail := internalRailForBoundary(from, to) + ex.appendMain(makeFundsBlockStep(from, to, internalRail)) + observeRef, err := s.ensureExternalObserved(ex, to, intent) + if err != nil { + return err + } + appendSettlementBranches(ex, from, to, internalRail, observeRef) + return nil + + case isExternalRail(from.rail) && isExternalRail(to.rail): + if _, err := s.ensureExternalObserved(ex, from, intent); err != nil { + return err + } + internalRail := internalRailForBoundary(from, to) + ex.appendMain(makeFundsCreditStep(from, to, internalRail)) + ex.appendMain(makeFundsBlockStep(from, to, internalRail)) + observeRef, err := s.ensureExternalObserved(ex, to, intent) + if err != nil { + return err + } + appendSettlementBranches(ex, from, to, internalRail, observeRef) + return nil + + case isInternalRail(from.rail) && isInternalRail(to.rail): + ex.appendMain(makeFundsMoveStep(from, to, internalRailForBoundary(from, to))) + return nil + + default: + return merrors.InvalidArgument("unsupported rail boundary") + } +} + +func (s *svc) ensureExternalObserved(ex *expansion, hop normalizedHop, intent model.PaymentIntent) (string, error) { + key := observedKey(hop) + if ref := strings.TrimSpace(ex.externalObserved[key]); ref != "" { + return ref, nil + } + + sendStep := makeRailSendStep(hop, intent) + sendRef := ex.appendMain(sendStep) + + observeStep := makeRailObserveStep(hop, intent) + if sendRef != "" { + observeStep.DependsOn = []string{sendRef} + } + observeRef := ex.appendMain(observeStep) + + ex.externalObserved[key] = observeRef + return observeRef, nil +} + +func appendGuards(ex *expansion, conditions *paymenttypes.QuoteExecutionConditions) { + if conditions == nil { + return + } + + if conditions.LiquidityCheckRequiredAtExecution { + ex.appendMain(Step{ + StepCode: "liquidity.check", + Kind: StepKindLiquidityCheck, + Action: model.RailOperationUnspecified, + Rail: model.RailUnspecified, + Visibility: model.ReportVisibilityHidden, + }) + } + + if conditions.PrefundingRequired { + ex.appendMain(Step{ + StepCode: "prefunding.ensure", + Kind: StepKindPrefunding, + Action: model.RailOperationUnspecified, + Rail: model.RailUnspecified, + Visibility: model.ReportVisibilityHidden, + }) + } +} + +func makeRailSendStep(hop normalizedHop, intent model.PaymentIntent) Step { + visibility := defaultVisibilityForAction(model.RailOperationSend, hop.role) + userLabel := "" + if visibility == model.ReportVisibilityUser { + userLabel = defaultUserLabel(model.RailOperationSend, hop.rail, hop.role, intent.Kind) + } + return Step{ + StepCode: singleHopCode(hop, "send"), + Kind: StepKindRailSend, + Action: model.RailOperationSend, + Rail: hop.rail, + Gateway: hop.gateway, + InstanceID: hop.instanceID, + HopIndex: hop.index, + HopRole: hop.role, + Visibility: visibility, + UserLabel: userLabel, + } +} + +func makeRailObserveStep(hop normalizedHop, intent model.PaymentIntent) Step { + visibility := defaultVisibilityForAction(model.RailOperationObserveConfirm, hop.role) + userLabel := "" + if visibility == model.ReportVisibilityUser { + userLabel = defaultUserLabel(model.RailOperationObserveConfirm, hop.rail, hop.role, intent.Kind) + } + return Step{ + StepCode: singleHopCode(hop, "observe"), + Kind: StepKindRailObserve, + Action: model.RailOperationObserveConfirm, + Rail: hop.rail, + Gateway: hop.gateway, + InstanceID: hop.instanceID, + HopIndex: hop.index, + HopRole: hop.role, + Visibility: visibility, + UserLabel: userLabel, + } +} + +func makeFundsCreditStep(from normalizedHop, to normalizedHop, rail model.Rail) Step { + return Step{ + StepCode: edgeCode(from, to, rail, "credit"), + Kind: StepKindFundsCredit, + Action: model.RailOperationCredit, + Rail: rail, + HopIndex: to.index, + HopRole: paymenttypes.QuoteRouteHopRoleTransit, + Visibility: model.ReportVisibilityHidden, + } +} + +func makeFundsBlockStep(from normalizedHop, to normalizedHop, rail model.Rail) Step { + return Step{ + StepCode: edgeCode(from, to, rail, "block"), + Kind: StepKindFundsBlock, + Action: model.RailOperationBlock, + Rail: rail, + HopIndex: to.index, + HopRole: paymenttypes.QuoteRouteHopRoleTransit, + Visibility: model.ReportVisibilityHidden, + } +} + +func makeFundsMoveStep(from normalizedHop, to normalizedHop, rail model.Rail) Step { + return Step{ + StepCode: edgeCode(from, to, rail, "move"), + Kind: StepKindFundsMove, + Action: model.RailOperationMove, + Rail: rail, + HopIndex: to.index, + HopRole: paymenttypes.QuoteRouteHopRoleTransit, + Visibility: model.ReportVisibilityHidden, + } +} + +func appendSettlementBranches( + ex *expansion, + from normalizedHop, + to normalizedHop, + rail model.Rail, + anchorObserveRef string, +) { + if strings.TrimSpace(anchorObserveRef) == "" { + return + } + + successStep := Step{ + StepCode: edgeCode(from, to, rail, "debit"), + Kind: StepKindFundsDebit, + Action: model.RailOperationDebit, + DependsOn: []string{anchorObserveRef}, + Rail: rail, + HopIndex: to.index, + HopRole: paymenttypes.QuoteRouteHopRoleTransit, + Visibility: model.ReportVisibilityHidden, + CommitPolicy: model.CommitPolicyAfterSuccess, + CommitAfter: []string{anchorObserveRef}, + Metadata: map[string]string{"mode": "finalize_debit"}, + } + ex.appendBranch(successStep) + + failureStep := Step{ + StepCode: edgeCode(from, to, rail, "release"), + Kind: StepKindFundsRelease, + Action: model.RailOperationRelease, + DependsOn: []string{anchorObserveRef}, + Rail: rail, + HopIndex: to.index, + HopRole: paymenttypes.QuoteRouteHopRoleTransit, + Visibility: model.ReportVisibilityHidden, + CommitPolicy: model.CommitPolicyAfterFailure, + CommitAfter: []string{anchorObserveRef}, + Metadata: map[string]string{"mode": "unlock_hold"}, + } + ex.appendBranch(failureStep) +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/service_policy.go b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/service_policy.go new file mode 100644 index 00000000..f0ba594c --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/service_policy.go @@ -0,0 +1,254 @@ +package xplan + +import ( + "strings" + + "github.com/tech/sendico/payments/storage/model" + "github.com/tech/sendico/pkg/merrors" + paymenttypes "github.com/tech/sendico/pkg/payments/types" +) + +func (s *svc) applyPolicy( + ex *expansion, + policy Policy, + from normalizedHop, + to normalizedHop, + intent model.PaymentIntent, +) error { + if len(policy.Steps) == 0 { + return merrors.InvalidArgument("policy.steps are required") + } + + anchorRef := "" + for i := range policy.Steps { + step, err := policyStepToStep(policy.Steps[i], from, to, intent) + if err != nil { + return merrors.InvalidArgument("policy " + strings.TrimSpace(policy.ID) + " step[" + itoa(i) + "]: " + err.Error()) + } + anchorRef = ex.appendMain(step) + } + if strings.TrimSpace(anchorRef) == "" { + return merrors.InvalidArgument("policy produced no anchor step") + } + + for i := range policy.Success { + step, err := policyStepToStep(policy.Success[i], from, to, intent) + if err != nil { + return merrors.InvalidArgument("policy " + strings.TrimSpace(policy.ID) + " success[" + itoa(i) + "]: " + err.Error()) + } + if len(step.DependsOn) == 0 { + step.DependsOn = []string{anchorRef} + } + if len(step.CommitAfter) == 0 { + step.CommitAfter = cloneStringSlice(step.DependsOn) + } + step.CommitPolicy = model.CommitPolicyAfterSuccess + ex.appendBranch(step) + } + + for i := range policy.Failure { + step, err := policyStepToStep(policy.Failure[i], from, to, intent) + if err != nil { + return merrors.InvalidArgument("policy " + strings.TrimSpace(policy.ID) + " failure[" + itoa(i) + "]: " + err.Error()) + } + if len(step.DependsOn) == 0 { + step.DependsOn = []string{anchorRef} + } + if len(step.CommitAfter) == 0 { + step.CommitAfter = cloneStringSlice(step.DependsOn) + } + step.CommitPolicy = model.CommitPolicyAfterFailure + ex.appendBranch(step) + } + + return nil +} + +func selectPolicy(from normalizedHop, to normalizedHop, policies []Policy) *Policy { + best := -1 + bestPriority := 0 + + for i := range policies { + policy := &policies[i] + if !policyEnabled(*policy) { + continue + } + if !policyMatches(policy.Match, from, to) { + continue + } + + if best == -1 || policy.Priority > bestPriority { + best = i + bestPriority = policy.Priority + } + } + + if best == -1 { + return nil + } + return &policies[best] +} + +func policyEnabled(policy Policy) bool { + if policy.Enabled == nil { + return true + } + return *policy.Enabled +} + +func policyMatches(match EdgeMatch, from normalizedHop, to normalizedHop) bool { + return endpointMatches(match.Source, from) && endpointMatches(match.Target, to) +} + +func endpointMatches(match EndpointMatch, hop normalizedHop) bool { + if match.Rail != nil && normalizeRail(string(*match.Rail)) != hop.rail { + return false + } + if match.Custody != nil && *match.Custody != custodyForRail(hop.rail) { + return false + } + if gateway := strings.TrimSpace(match.Gateway); gateway != "" && !strings.EqualFold(gateway, hop.gateway) { + return false + } + if network := strings.TrimSpace(match.Network); network != "" && !strings.EqualFold(network, hop.network) { + return false + } + if strings.TrimSpace(match.Method) != "" { + // Method-matching is reserved for the next phase once method is passed in intent/route context. + return false + } + return true +} + +func policyStepToStep(spec PolicyStep, from normalizedHop, to normalizedHop, intent model.PaymentIntent) (Step, error) { + code := strings.TrimSpace(spec.Code) + if code == "" { + return Step{}, merrors.InvalidArgument("code is required") + } + + action := normalizeAction(spec.Action) + if action == model.RailOperationUnspecified { + return Step{}, merrors.InvalidArgument("action is required") + } + + rail := inferPolicyRail(spec, action, from, to) + if rail == model.RailUnspecified { + return Step{}, merrors.InvalidArgument("rail could not be inferred") + } + + hopIndex, hopRole, gateway, instanceID := resolveStepContext(rail, action, from, to) + + visibility := model.NormalizeReportVisibility(spec.Visibility) + if visibility == model.ReportVisibilityUnspecified { + visibility = defaultVisibilityForAction(action, hopRole) + } + + userLabel := strings.TrimSpace(spec.UserLabel) + if userLabel == "" && visibility == model.ReportVisibilityUser { + userLabel = defaultUserLabel(action, rail, hopRole, intent.Kind) + } + + return Step{ + StepCode: code, + Kind: kindForAction(action), + Action: action, + DependsOn: cloneStringSlice(spec.DependsOn), + Rail: rail, + Gateway: gateway, + InstanceID: instanceID, + HopIndex: hopIndex, + HopRole: hopRole, + Visibility: visibility, + UserLabel: userLabel, + Metadata: cloneMetadata(spec.Metadata), + }, nil +} + +func normalizeAction(action model.RailOperation) model.RailOperation { + switch strings.ToUpper(strings.TrimSpace(string(action))) { + case string(model.RailOperationDebit): + return model.RailOperationDebit + case string(model.RailOperationCredit): + return model.RailOperationCredit + case string(model.RailOperationExternalDebit): + return model.RailOperationExternalDebit + case string(model.RailOperationExternalCredit): + return model.RailOperationExternalCredit + case string(model.RailOperationMove): + return model.RailOperationMove + case string(model.RailOperationSend): + return model.RailOperationSend + case string(model.RailOperationFee): + return model.RailOperationFee + case string(model.RailOperationObserveConfirm): + return model.RailOperationObserveConfirm + case string(model.RailOperationFXConvert): + return model.RailOperationFXConvert + case string(model.RailOperationBlock): + return model.RailOperationBlock + case string(model.RailOperationRelease): + return model.RailOperationRelease + default: + return model.RailOperationUnspecified + } +} + +func inferPolicyRail(spec PolicyStep, action model.RailOperation, from normalizedHop, to normalizedHop) model.Rail { + if spec.Rail != nil { + return normalizeRail(string(*spec.Rail)) + } + + switch action { + case model.RailOperationSend, model.RailOperationObserveConfirm, model.RailOperationFee: + return to.rail + case model.RailOperationBlock, + model.RailOperationRelease, + model.RailOperationDebit, + model.RailOperationCredit, + model.RailOperationExternalDebit, + model.RailOperationExternalCredit, + model.RailOperationMove: + return internalRailForBoundary(from, to) + default: + return model.RailUnspecified + } +} + +func resolveStepContext( + rail model.Rail, + action model.RailOperation, + from normalizedHop, + to normalizedHop, +) (uint32, paymenttypes.QuoteRouteHopRole, string, string) { + if rail == to.rail && (action == model.RailOperationSend || action == model.RailOperationObserveConfirm || action == model.RailOperationFee) { + return to.index, to.role, to.gateway, to.instanceID + } + if rail == from.rail { + return from.index, from.role, from.gateway, from.instanceID + } + if rail == to.rail { + return to.index, to.role, to.gateway, to.instanceID + } + return to.index, paymenttypes.QuoteRouteHopRoleTransit, "", "" +} + +func kindForAction(action model.RailOperation) StepKind { + switch action { + case model.RailOperationSend: + return StepKindRailSend + case model.RailOperationObserveConfirm: + return StepKindRailObserve + case model.RailOperationCredit, model.RailOperationExternalCredit: + return StepKindFundsCredit + case model.RailOperationDebit, model.RailOperationExternalDebit: + return StepKindFundsDebit + case model.RailOperationMove: + return StepKindFundsMove + case model.RailOperationBlock: + return StepKindFundsBlock + case model.RailOperationRelease: + return StepKindFundsRelease + default: + return StepKindUnspecified + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/test_helpers_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/test_helpers_test.go new file mode 100644 index 00000000..01a32834 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/test_helpers_test.go @@ -0,0 +1,57 @@ +package xplan + +import ( + "testing" + + "github.com/tech/sendico/payments/storage/model" + paymenttypes "github.com/tech/sendico/pkg/payments/types" +) + +func assertStep( + t *testing.T, + step Step, + code string, + action model.RailOperation, + rail model.Rail, + visibility model.ReportVisibility, +) { + t.Helper() + if got, want := step.StepCode, code; got != want { + t.Fatalf("step code mismatch: got=%q want=%q", got, want) + } + if got, want := step.Action, action; got != want { + t.Fatalf("step action mismatch: got=%q want=%q", got, want) + } + if got, want := step.Rail, rail; got != want { + t.Fatalf("step rail mismatch: got=%q want=%q", got, want) + } + if got, want := step.Visibility, visibility; got != want { + t.Fatalf("step visibility mismatch: got=%q want=%q", got, want) + } +} + +func testIntent(kind model.PaymentKind) model.PaymentIntent { + return model.PaymentIntent{ + Kind: kind, + Amount: &paymenttypes.Money{ + Amount: "10", + Currency: "USD", + }, + } +} + +func railPtr(v model.Rail) *model.Rail { + return &v +} + +func equalStringSlice(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/card_payout_constants.go b/api/payments/orchestrator/internal/service/orchestrator/card_payout_constants.go deleted file mode 100644 index eff95946..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/card_payout_constants.go +++ /dev/null @@ -1,10 +0,0 @@ -package orchestrator - -const ( - defaultCardGateway = "monetix" - - stepCodeGasTopUp = "gas_top_up" - stepCodeFundingTransfer = "funding_transfer" - stepCodeCardPayout = "card_payout" - stepCodeFeeTransfer = "fee_transfer" -) diff --git a/api/payments/orchestrator/internal/service/orchestrator/card_payout_funding.go b/api/payments/orchestrator/internal/service/orchestrator/card_payout_funding.go deleted file mode 100644 index c26b16d6..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/card_payout_funding.go +++ /dev/null @@ -1,367 +0,0 @@ -package orchestrator - -import ( - "context" - "strings" - - "github.com/shopspring/decimal" - chainclient "github.com/tech/sendico/gateway/chain/client" - "github.com/tech/sendico/payments/storage/model" - "github.com/tech/sendico/pkg/merrors" - paymenttypes "github.com/tech/sendico/pkg/payments/types" - moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" - chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" - "go.uber.org/zap" -) - -func (s *Service) submitCardFundingTransfers(ctx context.Context, payment *model.Payment, quote *sharedv1.PaymentQuote) error { - if payment == nil { - return merrors.InvalidArgument("payment is required") - } - intent := payment.Intent - source := intent.Source.ManagedWallet - if source == nil || strings.TrimSpace(source.ManagedWalletRef) == "" { - return merrors.InvalidArgument("card funding: source managed wallet is required") - } - route, err := s.cardRoute(defaultCardGateway) - if err != nil { - return err - } - sourceWalletRef := strings.TrimSpace(source.ManagedWalletRef) - fundingAddress := strings.TrimSpace(route.FundingAddress) - feeWalletRef := strings.TrimSpace(route.FeeWalletRef) - - intentAmount := cloneMoney(intent.Amount) - if intentAmount == nil || strings.TrimSpace(intentAmount.GetAmount()) == "" || strings.TrimSpace(intentAmount.GetCurrency()) == "" { - return merrors.InvalidArgument("card funding: amount is required") - } - intentAmountProto := protoMoney(intentAmount) - - payoutAmount, err := cardPayoutAmount(payment) - if err != nil { - return err - } - - var feeAmount *paymenttypes.Money - if quote != nil { - feeAmount = moneyFromProto(quote.GetExpectedFeeTotal()) - } - if feeAmount == nil && payment.LastQuote != nil { - feeAmount = cloneMoney(payment.LastQuote.ExpectedFeeTotal) - } - feeDecimal := decimal.Zero - if feeAmount != nil && strings.TrimSpace(feeAmount.GetAmount()) != "" { - if strings.TrimSpace(feeAmount.GetCurrency()) == "" { - return merrors.InvalidArgument("card funding: fee currency is required") - } - feeDecimal, err = decimalFromMoney(feeAmount) - if err != nil { - return err - } - } - feeRequired := feeDecimal.IsPositive() - feeAmountProto := protoMoney(feeAmount) - - network := networkFromEndpoint(intent.Source) - instanceID := strings.TrimSpace(intent.Source.InstanceID) - actions := []model.RailOperation{model.RailOperationSend} - if feeRequired { - actions = append(actions, model.RailOperationFee) - } - chainClient, _, err := s.resolveChainGatewayClient(ctx, network, intentAmount, actions, instanceID, payment.PaymentRef) - if err != nil { - s.logger.Warn("Card funding gateway resolution failed", zap.Error(err), zap.String("payment_ref", payment.PaymentRef)) - return err - } - - fundingDest := &chainv1.TransferDestination{ - Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: fundingAddress}, - } - fundingFee, err := s.estimateTransferNetworkFee(ctx, chainClient, sourceWalletRef, fundingDest, intentAmountProto) - if err != nil { - return err - } - - var feeTransferFee *moneyv1.Money - if feeRequired { - if feeWalletRef == "" { - return merrors.InvalidArgument("card funding: fee wallet ref is required when fee exists") - } - feeDest := &chainv1.TransferDestination{ - Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: feeWalletRef}, - } - feeTransferFee, err = s.estimateTransferNetworkFee(ctx, chainClient, sourceWalletRef, feeDest, feeAmountProto) - if err != nil { - return err - } - } - - totalFee, gasCurrency, err := sumNetworkFees(fundingFee, feeTransferFee) - if err != nil { - return err - } - - var estimatedTotalFee *moneyv1.Money - if gasCurrency != "" && !totalFee.IsNegative() { - estimatedTotalFee = makeMoney(gasCurrency, totalFee) - } - - var topUpMoney *moneyv1.Money - var topUpFee *moneyv1.Money - topUpPositive := false - if estimatedTotalFee != nil { - computeResp, err := chainClient.ComputeGasTopUp(ctx, &chainv1.ComputeGasTopUpRequest{ - WalletRef: sourceWalletRef, - EstimatedTotalFee: estimatedTotalFee, - }) - if err != nil { - s.logger.Warn("Card funding gas top-up compute failed", zap.Error(err), zap.String("payment_ref", payment.PaymentRef)) - return err - } - if computeResp != nil { - topUpMoney = computeResp.GetTopupAmount() - } - if topUpMoney != nil && strings.TrimSpace(topUpMoney.GetAmount()) != "" { - amountDec, err := decimalFromMoney(topUpMoney) - if err != nil { - return err - } - topUpPositive = amountDec.IsPositive() - } - if topUpMoney != nil && topUpPositive { - if strings.TrimSpace(topUpMoney.GetCurrency()) == "" { - return merrors.InvalidArgument("card funding: gas top-up currency is required") - } - if feeWalletRef == "" { - return merrors.InvalidArgument("card funding: fee wallet ref is required for gas top-up") - } - topUpDest := &chainv1.TransferDestination{ - Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: sourceWalletRef}, - } - topUpFee, err = s.estimateTransferNetworkFee(ctx, chainClient, feeWalletRef, topUpDest, topUpMoney) - if err != nil { - return err - } - } - } - - plan := ensureExecutionPlan(payment) - var gasStep *model.ExecutionStep - var feeStep *model.ExecutionStep - if topUpMoney != nil && topUpPositive { - gasStep = ensureExecutionStep(plan, stepCodeGasTopUp) - setExecutionStepRole(gasStep, executionStepRoleSource) - setExecutionStepStatus(gasStep, model.OperationStatePlanned) - gasStep.Description = "Top up native gas from fee wallet" - gasStep.Amount = moneyFromProto(topUpMoney) - gasStep.NetworkFee = moneyFromProto(topUpFee) - gasStep.SourceWalletRef = feeWalletRef - gasStep.DestinationRef = sourceWalletRef - } - - fundStep := ensureExecutionStep(plan, stepCodeFundingTransfer) - setExecutionStepRole(fundStep, executionStepRoleSource) - setExecutionStepStatus(fundStep, model.OperationStatePlanned) - fundStep.Description = "Transfer payout amount to card funding wallet" - fundStep.Amount = cloneMoney(intentAmount) - fundStep.NetworkFee = moneyFromProto(fundingFee) - fundStep.SourceWalletRef = sourceWalletRef - fundStep.DestinationRef = fundingAddress - - if feeRequired { - feeStep = ensureExecutionStep(plan, stepCodeFeeTransfer) - setExecutionStepRole(feeStep, executionStepRoleSource) - setExecutionStepStatus(feeStep, model.OperationStatePlanned) - feeStep.Description = "Transfer fee to fee wallet" - feeStep.Amount = cloneMoney(feeAmount) - feeStep.NetworkFee = moneyFromProto(feeTransferFee) - feeStep.SourceWalletRef = sourceWalletRef - feeStep.DestinationRef = feeWalletRef - } - - cardStep := ensureExecutionStep(plan, stepCodeCardPayout) - setExecutionStepRole(cardStep, executionStepRoleConsumer) - setExecutionStepStatus(cardStep, model.OperationStatePlanned) - cardStep.Description = "Submit card payout" - cardStep.Amount = cloneMoney(payoutAmount) - if card := intent.Destination.Card; card != nil { - if masked := strings.TrimSpace(card.MaskedPan); masked != "" { - cardStep.DestinationRef = masked - } - } - - updateExecutionPlanTotalNetworkFee(plan) - - exec := payment.Execution - if exec == nil { - exec = &model.ExecutionRefs{} - } - - if topUpMoney != nil && topUpPositive { - ensureResp, gasErr := chainClient.EnsureGasTopUp(ctx, &chainv1.EnsureGasTopUpRequest{ - IdempotencyKey: payment.IdempotencyKey + ":card:gas", - OrganizationRef: payment.OrganizationRef.Hex(), - IntentRef: strings.TrimSpace(payment.Intent.Ref), - OperationRef: strings.TrimSpace(cardStep.OperationRef), - SourceWalletRef: feeWalletRef, - TargetWalletRef: sourceWalletRef, - EstimatedTotalFee: estimatedTotalFee, - Metadata: cloneMetadata(payment.Metadata), - PaymentRef: payment.PaymentRef, - }) - if gasErr != nil { - s.logger.Warn("Card gas top-up transfer failed", zap.Error(gasErr), zap.String("payment_ref", payment.PaymentRef)) - return gasErr - } - if gasStep != nil { - actual := (*moneyv1.Money)(nil) - if ensureResp != nil { - actual = ensureResp.GetTopupAmount() - if transfer := ensureResp.GetTransfer(); transfer != nil { - gasStep.TransferRef = strings.TrimSpace(transfer.GetTransferRef()) - } - } - actualPositive := false - if actual != nil && strings.TrimSpace(actual.GetAmount()) != "" { - actualDec, err := decimalFromMoney(actual) - if err != nil { - return err - } - actualPositive = actualDec.IsPositive() - } - if actual != nil && actualPositive { - gasStep.Amount = moneyFromProto(actual) - if strings.TrimSpace(actual.GetCurrency()) == "" { - return merrors.InvalidArgument("card funding: gas top-up currency is required") - } - topUpDest := &chainv1.TransferDestination{ - Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: sourceWalletRef}, - } - topUpFee, err = s.estimateTransferNetworkFee(ctx, chainClient, feeWalletRef, topUpDest, actual) - if err != nil { - return err - } - gasStep.NetworkFee = moneyFromProto(topUpFee) - setExecutionStepStatus(gasStep, model.OperationStateWaiting) - } else { - gasStep.Amount = nil - gasStep.NetworkFee = nil - gasStep.TransferRef = "" - setExecutionStepStatus(gasStep, model.OperationStateSkipped) - } - } - if gasStep != nil { - s.logger.Info("Card gas top-up transfer submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", gasStep.TransferRef)) - } - updateExecutionPlanTotalNetworkFee(plan) - } - - fundResp, err := chainClient.SubmitTransfer(ctx, &chainv1.SubmitTransferRequest{ - IdempotencyKey: payment.IdempotencyKey + ":card:fund", - OrganizationRef: payment.OrganizationRef.Hex(), - SourceWalletRef: sourceWalletRef, - Destination: fundingDest, - Amount: cloneProtoMoney(intentAmountProto), - Metadata: cloneMetadata(payment.Metadata), - PaymentRef: payment.PaymentRef, - IntentRef: strings.TrimSpace(intent.Ref), - OperationRef: strings.TrimSpace(cardStep.OperationRef), - }) - if err != nil { - return err - } - if fundResp != nil && fundResp.GetTransfer() != nil { - exec.ChainTransferRef = strings.TrimSpace(fundResp.GetTransfer().GetTransferRef()) - fundStep.TransferRef = exec.ChainTransferRef - } - setExecutionStepStatus(fundStep, model.OperationStateWaiting) - updateExecutionPlanTotalNetworkFee(plan) - - if feeRequired { - feeResp, err := chainClient.SubmitTransfer(ctx, &chainv1.SubmitTransferRequest{ - IntentRef: intent.Ref, - OperationRef: feeStep.OperationRef, - IdempotencyKey: payment.IdempotencyKey + ":card:fee", - OrganizationRef: payment.OrganizationRef.Hex(), - SourceWalletRef: sourceWalletRef, - Destination: &chainv1.TransferDestination{ - Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: feeWalletRef}, - }, - Amount: cloneProtoMoney(feeAmountProto), - Metadata: cloneMetadata(payment.Metadata), - PaymentRef: payment.PaymentRef, - }) - if err != nil { - return err - } - if feeResp != nil && feeResp.GetTransfer() != nil { - exec.FeeTransferRef = strings.TrimSpace(feeResp.GetTransfer().GetTransferRef()) - feeStep.TransferRef = exec.FeeTransferRef - } - setExecutionStepStatus(feeStep, model.OperationStateWaiting) - s.logger.Info("Card fee transfer submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", exec.FeeTransferRef)) - } - - payment.Execution = exec - return nil -} - -func (s *Service) estimateTransferNetworkFee(ctx context.Context, client chainclient.Client, sourceWalletRef string, destination *chainv1.TransferDestination, amount *moneyv1.Money) (*moneyv1.Money, error) { - if client == nil { - return nil, merrors.InvalidArgument("chain gateway unavailable") - } - sourceWalletRef = strings.TrimSpace(sourceWalletRef) - if sourceWalletRef == "" { - return nil, merrors.InvalidArgument("source wallet ref is required") - } - if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" { - return nil, merrors.InvalidArgument("amount is required") - } - - resp, err := client.EstimateTransferFee(ctx, &chainv1.EstimateTransferFeeRequest{ - SourceWalletRef: sourceWalletRef, - Destination: destination, - Amount: cloneProtoMoney(amount), - }) - if err != nil { - s.logger.Warn("Chain gateway fee estimation failed", zap.Error(err), zap.String("source_wallet_ref", sourceWalletRef)) - return nil, merrors.Internal("chain_gateway_fee_estimation_failed") - } - if resp == nil { - s.logger.Warn("Chain gateway fee estimation returned empty response", zap.String("source_wallet_ref", sourceWalletRef)) - return nil, merrors.Internal("chain_gateway_fee_estimation_failed") - } - fee := resp.GetNetworkFee() - if fee == nil || strings.TrimSpace(fee.GetAmount()) == "" || strings.TrimSpace(fee.GetCurrency()) == "" { - s.logger.Warn("Chain gateway fee estimation missing network fee", zap.String("source_wallet_ref", sourceWalletRef)) - return nil, merrors.Internal("chain_gateway_fee_estimation_failed") - } - return cloneProtoMoney(fee), nil -} - -func sumNetworkFees(fees ...*moneyv1.Money) (decimal.Decimal, string, error) { - total := decimal.Zero - currency := "" - for _, fee := range fees { - if fee == nil { - continue - } - amount := strings.TrimSpace(fee.GetAmount()) - feeCurrency := strings.TrimSpace(fee.GetCurrency()) - if amount == "" || feeCurrency == "" { - return decimal.Zero, "", merrors.InvalidArgument("network fee is required") - } - value, err := decimalFromMoney(fee) - if err != nil { - return decimal.Zero, "", err - } - if currency == "" { - currency = feeCurrency - } else if !strings.EqualFold(currency, feeCurrency) { - return decimal.Zero, "", merrors.InvalidArgument("network fee currency mismatch") - } - total = total.Add(value) - } - return total, currency, nil -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/card_payout_helpers.go b/api/payments/orchestrator/internal/service/orchestrator/card_payout_helpers.go deleted file mode 100644 index 86554e87..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/card_payout_helpers.go +++ /dev/null @@ -1,80 +0,0 @@ -package orchestrator - -import ( - "strings" - - "github.com/shopspring/decimal" - "github.com/tech/sendico/payments/storage/model" - paymenttypes "github.com/tech/sendico/pkg/payments/types" -) - -func ensureExecutionPlan(payment *model.Payment) *model.ExecutionPlan { - if payment == nil { - return nil - } - if payment.ExecutionPlan == nil { - payment.ExecutionPlan = &model.ExecutionPlan{} - } - return payment.ExecutionPlan -} - -func ensureExecutionStep(plan *model.ExecutionPlan, code string) *model.ExecutionStep { - if plan == nil { - return nil - } - code = strings.TrimSpace(code) - if code == "" { - return nil - } - for _, step := range plan.Steps { - if step == nil { - continue - } - if strings.EqualFold(step.Code, code) { - if step.Code == "" { - step.Code = code - } - return step - } - } - step := &model.ExecutionStep{Code: code} - plan.Steps = append(plan.Steps, step) - return step -} - -func updateExecutionPlanTotalNetworkFee(plan *model.ExecutionPlan) { - if plan == nil { - return - } - total := decimal.Zero - currency := "" - hasFee := false - for _, step := range plan.Steps { - if step == nil || step.NetworkFee == nil { - continue - } - fee := step.NetworkFee - if strings.TrimSpace(fee.GetAmount()) == "" || strings.TrimSpace(fee.GetCurrency()) == "" { - continue - } - if currency == "" { - currency = strings.TrimSpace(fee.GetCurrency()) - } else if !strings.EqualFold(currency, fee.GetCurrency()) { - continue - } - value, err := decimalFromMoney(fee) - if err != nil { - continue - } - total = total.Add(value) - hasFee = true - } - if !hasFee || currency == "" { - plan.TotalNetworkFee = nil - return - } - plan.TotalNetworkFee = &paymenttypes.Money{ - Currency: currency, - Amount: total.String(), - } -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/card_payout_routes.go b/api/payments/orchestrator/internal/service/orchestrator/card_payout_routes.go deleted file mode 100644 index 927e1d29..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/card_payout_routes.go +++ /dev/null @@ -1,29 +0,0 @@ -package orchestrator - -import ( - "strings" - - "github.com/tech/sendico/pkg/merrors" - "go.uber.org/zap" -) - -func (s *Service) cardRoute(gateway string) (CardGatewayRoute, error) { - if len(s.deps.cardRoutes) == 0 { - s.logger.Warn("Card routing not configured", zap.String("gateway", gateway)) - return CardGatewayRoute{}, merrors.InvalidArgument("card routing not configured") - } - key := strings.ToLower(strings.TrimSpace(gateway)) - if key == "" { - key = defaultCardGateway - } - route, ok := s.deps.cardRoutes[key] - if !ok { - s.logger.Warn("Card routing missing for gateway", zap.String("gateway", key)) - return CardGatewayRoute{}, merrors.InvalidArgument("card routing missing for gateway " + key) - } - if strings.TrimSpace(route.FundingAddress) == "" { - s.logger.Warn("Card routing missing funding address", zap.String("gateway", key)) - return CardGatewayRoute{}, merrors.InvalidArgument("card funding address is required for gateway " + key) - } - return route, nil -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/card_payout_submit.go b/api/payments/orchestrator/internal/service/orchestrator/card_payout_submit.go deleted file mode 100644 index 343ca1a6..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/card_payout_submit.go +++ /dev/null @@ -1,351 +0,0 @@ -package orchestrator - -import ( - "context" - "strings" - - "github.com/google/uuid" - "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" - mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" - "go.uber.org/zap" -) - -func (s *Service) submitCardPayout(ctx context.Context, operationRef string, payment *model.Payment) error { - if payment == nil { - return merrors.InvalidArgument("payment is required") - } - if payment.Execution != nil && strings.TrimSpace(payment.Execution.CardPayoutRef) != "" { - return nil - } - intent := payment.Intent - card := intent.Destination.Card - if card == nil { - return merrors.InvalidArgument("card payout: card endpoint is required") - } - amount, err := cardPayoutAmount(payment) - if err != nil { - return err - } - amtDec, err := decimalFromMoney(amount) - if err != nil { - return err - } - minor := amtDec.Mul(decimal.NewFromInt(100)).IntPart() - - payoutID := payment.PaymentRef - currency := strings.TrimSpace(amount.GetCurrency()) - holder := strings.TrimSpace(card.Cardholder) - meta := cloneMetadata(payment.Metadata) - customer := intent.Customer - customerID := "" - customerFirstName := "" - customerMiddleName := "" - customerLastName := "" - customerIP := "" - customerZip := "" - customerCountry := "" - customerState := "" - customerCity := "" - customerAddress := "" - if customer != nil { - customerID = strings.TrimSpace(customer.ID) - customerFirstName = strings.TrimSpace(customer.FirstName) - customerMiddleName = strings.TrimSpace(customer.MiddleName) - customerLastName = strings.TrimSpace(customer.LastName) - customerIP = strings.TrimSpace(customer.IP) - customerZip = strings.TrimSpace(customer.Zip) - customerCountry = strings.TrimSpace(customer.Country) - customerState = strings.TrimSpace(customer.State) - customerCity = strings.TrimSpace(customer.City) - customerAddress = strings.TrimSpace(customer.Address) - } - if customerFirstName == "" { - customerFirstName = strings.TrimSpace(card.Cardholder) - } - if customerLastName == "" { - customerLastName = strings.TrimSpace(card.CardholderSurname) - } - if customerID == "" { - return merrors.InvalidArgument("card payout: customer id is required") - } - if customerFirstName == "" { - return merrors.InvalidArgument("card payout: customer first name is required") - } - if customerLastName == "" { - return merrors.InvalidArgument("card payout: customer last name is required") - } - if customerIP == "" { - return merrors.InvalidArgument("card payout: customer ip is required") - } - - var ( - state *mntxv1.CardPayoutState - ) - - if token := strings.TrimSpace(card.Token); token != "" { - req := &mntxv1.CardTokenPayoutRequest{ - PayoutId: payoutID, - IdempotencyKey: payment.IdempotencyKey, - CustomerId: customerID, - CustomerFirstName: customerFirstName, - CustomerMiddleName: customerMiddleName, - CustomerLastName: customerLastName, - CustomerIp: customerIP, - CustomerZip: customerZip, - CustomerCountry: customerCountry, - CustomerState: customerState, - CustomerCity: customerCity, - CustomerAddress: customerAddress, - AmountMinor: minor, - Currency: currency, - CardToken: token, - CardHolder: holder, - MaskedPan: strings.TrimSpace(card.MaskedPan), - Metadata: meta, - } - resp, err := s.deps.mntx.client.CreateCardTokenPayout(ctx, req) - if err != nil { - s.logger.Warn("Card token payout failed", zap.Error(err), zap.String("payment_ref", payment.PaymentRef)) - return err - } - state = resp.GetPayout() - } else if pan := strings.TrimSpace(card.Pan); pan != "" { - req := &mntxv1.CardPayoutRequest{ - PayoutId: payoutID, - IdempotencyKey: payment.IdempotencyKey, - CustomerId: customerID, - CustomerFirstName: customerFirstName, - CustomerMiddleName: customerMiddleName, - CustomerLastName: customerLastName, - CustomerIp: customerIP, - CustomerZip: customerZip, - CustomerCountry: customerCountry, - CustomerState: customerState, - CustomerCity: customerCity, - CustomerAddress: customerAddress, - AmountMinor: minor, - Currency: currency, - CardPan: pan, - CardExpYear: card.ExpYear, - CardExpMonth: card.ExpMonth, - CardHolder: holder, - Metadata: meta, - IntentRef: payment.Intent.Ref, - OperationRef: operationRef, - } - resp, err := s.deps.mntx.client.CreateCardPayout(ctx, req) - if err != nil { - s.logger.Warn("Card payout failed", zap.Error(err), zap.String("payment_ref", payment.PaymentRef)) - return err - } - state = resp.GetPayout() - } else { - return merrors.InvalidArgument("card payout: either token or pan must be provided") - } - - if state == nil { - return merrors.Internal("card payout: missing payout state") - } - recordCardPayoutState(payment, state) - exec := payment.Execution - if exec == nil { - exec = &model.ExecutionRefs{} - } - if exec.CardPayoutRef == "" { - exec.CardPayoutRef = strings.TrimSpace(state.GetPayoutId()) - } - payment.Execution = exec - - plan := ensureExecutionPlan(payment) - if plan != nil { - step := ensureExecutionStep(plan, stepCodeCardPayout) - setExecutionStepRole(step, executionStepRoleConsumer) - step.Description = "Submit card payout" - step.Amount = cloneMoney(amount) - if masked := strings.TrimSpace(card.MaskedPan); masked != "" { - step.DestinationRef = masked - } - if exec.CardPayoutRef != "" { - step.TransferRef = exec.CardPayoutRef - } - setExecutionStepStatus(step, model.OperationStateWaiting) - updateExecutionPlanTotalNetworkFee(plan) - } - - s.logger.Info("Card payout submitted", zap.String("payment_ref", payment.PaymentRef), - zap.String("payout_id", exec.CardPayoutRef), zap.String("operation_ref", state.OperationRef)) - - return nil -} - -func recordCardPayoutState(payment *model.Payment, state *mntxv1.CardPayoutState) { - if payment == nil || state == nil { - return - } - if payment.CardPayout == nil { - payment.CardPayout = &model.CardPayout{} - } - payment.CardPayout.PayoutRef = strings.TrimSpace(state.GetPayoutId()) - payment.CardPayout.ProviderPaymentID = strings.TrimSpace(state.GetProviderPaymentId()) - payment.CardPayout.Status = state.GetStatus().String() - payment.CardPayout.FailureReason = strings.TrimSpace(state.GetProviderMessage()) - payment.CardPayout.ProviderCode = strings.TrimSpace(state.GetProviderCode()) - if payment.CardPayout.CardCountry == "" && payment.Intent.Destination.Card != nil { - payment.CardPayout.CardCountry = strings.TrimSpace(payment.Intent.Destination.Card.Country) - } - if payment.CardPayout.MaskedPan == "" && payment.Intent.Destination.Card != nil { - payment.CardPayout.MaskedPan = strings.TrimSpace(payment.Intent.Destination.Card.MaskedPan) - } - payment.CardPayout.GatewayReference = strings.TrimSpace(state.GetPayoutId()) -} - -func updateCardPayoutPlanSteps(payment *model.Payment, payout *mntxv1.CardPayoutState) bool { - if payment == nil || payout == nil || payment.PaymentPlan == nil { - return false - } - plan := ensureExecutionPlanForPlan(payment, payment.PaymentPlan) - if plan == nil { - return false - } - payoutID := strings.TrimSpace(payout.GetPayoutId()) - if payoutID == "" { - return false - } - - updated := false - for idx, planStep := range payment.PaymentPlan.Steps { - if planStep == nil { - continue - } - if planStep.Rail != model.RailCardPayout { - continue - } - if planStep.Action != model.RailOperationSend && planStep.Action != model.RailOperationObserveConfirm { - continue - } - if idx >= len(plan.Steps) { - continue - } - execStep := plan.Steps[idx] - if execStep == nil { - execStep = &model.ExecutionStep{ - Code: planStepID(planStep, idx), - Description: describePlanStep(planStep), - OperationRef: uuid.New().String(), - State: model.OperationStateCreated, - } - plan.Steps[idx] = execStep - } - if execStep.TransferRef == "" { - execStep.TransferRef = payoutID - } - switch payout.GetStatus() { - - case mntxv1.PayoutStatus_PAYOUT_STATUS_CREATED: - setExecutionStepStatus(execStep, model.OperationStateCreated) - - case mntxv1.PayoutStatus_PAYOUT_STATUS_WAITING: - setExecutionStepStatus(execStep, model.OperationStateWaiting) - - case mntxv1.PayoutStatus_PAYOUT_STATUS_SUCCESS: - setExecutionStepStatus(execStep, model.OperationStateSuccess) - - case mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED: - setExecutionStepStatus(execStep, model.OperationStateFailed) - - case mntxv1.PayoutStatus_PAYOUT_STATUS_CANCELLED: - setExecutionStepStatus(execStep, model.OperationStateCancelled) - - default: - setExecutionStepStatus(execStep, model.OperationStatePlanned) - } - updated = true - } - return updated -} - -func applyCardPayoutUpdate(payment *model.Payment, payout *mntxv1.CardPayoutState) { - if payment == nil || payout == nil { - return - } - recordCardPayoutState(payment, payout) - - if payment.Execution == nil { - payment.Execution = &model.ExecutionRefs{} - } - if payment.Execution.CardPayoutRef == "" { - payment.Execution.CardPayoutRef = strings.TrimSpace(payout.GetPayoutId()) - } - - updated := updateCardPayoutPlanSteps(payment, payout) - plan := ensureExecutionPlan(payment) - if plan != nil && !updated { - step := findExecutionStepByTransferRef(plan, strings.TrimSpace(payout.GetPayoutId())) - if step == nil { - step = ensureExecutionStep(plan, stepCodeCardPayout) - setExecutionStepRole(step, executionStepRoleConsumer) - if step.TransferRef == "" { - step.TransferRef = payment.Execution.CardPayoutRef - } - } - switch payout.GetStatus() { - - case mntxv1.PayoutStatus_PAYOUT_STATUS_CREATED: - setExecutionStepStatus(step, model.OperationStatePlanned) - - case mntxv1.PayoutStatus_PAYOUT_STATUS_WAITING: - setExecutionStepStatus(step, model.OperationStateWaiting) - - case mntxv1.PayoutStatus_PAYOUT_STATUS_SUCCESS: - setExecutionStepStatus(step, model.OperationStateSuccess) - - case mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED: - setExecutionStepStatus(step, model.OperationStateFailed) - - case mntxv1.PayoutStatus_PAYOUT_STATUS_CANCELLED: - setExecutionStepStatus(step, model.OperationStateCancelled) - - default: - setExecutionStepStatus(step, model.OperationStatePlanned) - } - - } - - switch payout.GetStatus() { - - case mntxv1.PayoutStatus_PAYOUT_STATUS_SUCCESS: - payment.FailureCode = model.PaymentFailureCodeUnspecified - payment.FailureReason = "" - - case mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED: - payment.FailureCode = model.PaymentFailureCodePolicy - payment.FailureReason = strings.TrimSpace(payout.GetProviderMessage()) - - case mntxv1.PayoutStatus_PAYOUT_STATUS_CANCELLED: - payment.FailureCode = model.PaymentFailureCodePolicy - payment.FailureReason = "payout cancelled" - - default: - // CREATED / WAITING — keep as is - } -} - -func cardPayoutAmount(payment *model.Payment) (*paymenttypes.Money, error) { - if payment == nil { - return nil, merrors.InvalidArgument("payment is required") - } - amount := cloneMoney(payment.Intent.Amount) - if payment.LastQuote != nil { - settlement := payment.LastQuote.ExpectedSettlementAmount - if settlement != nil && strings.TrimSpace(settlement.GetAmount()) != "" && strings.TrimSpace(settlement.GetCurrency()) != "" { - amount = cloneMoney(settlement) - } - } - if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" { - return nil, merrors.InvalidArgument("card payout: amount is required") - } - return amount, nil -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/card_payout_test.go b/api/payments/orchestrator/internal/service/orchestrator/card_payout_test.go deleted file mode 100644 index bfbdf623..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/card_payout_test.go +++ /dev/null @@ -1,399 +0,0 @@ -package orchestrator - -import ( - "context" - "strings" - "testing" - - chainclient "github.com/tech/sendico/gateway/chain/client" - mntxclient "github.com/tech/sendico/gateway/mntx/client" - "github.com/tech/sendico/payments/storage/model" - mo "github.com/tech/sendico/pkg/model" - paymenttypes "github.com/tech/sendico/pkg/payments/types" - moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" - chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" - mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" - "go.mongodb.org/mongo-driver/v2/bson" - "go.uber.org/zap" -) - -func TestSubmitCardFundingTransfers_PlansTopUpAndFunding(t *testing.T) { - ctx := context.Background() - - const ( - sourceWalletRef = "wallet-src" - feeWalletRef = "wallet-fee" - fundingAddress = "0xfunding" - ) - - var estimateCalls []*chainv1.EstimateTransferFeeRequest - var computeCalls []*chainv1.ComputeGasTopUpRequest - var ensureCalls []*chainv1.EnsureGasTopUpRequest - var submitCalls []*chainv1.SubmitTransferRequest - - gateway := &chainclient.Fake{ - EstimateTransferFeeFn: func(ctx context.Context, req *chainv1.EstimateTransferFeeRequest) (*chainv1.EstimateTransferFeeResponse, error) { - estimateCalls = append(estimateCalls, req) - dest := req.GetDestination() - if req.GetSourceWalletRef() == feeWalletRef { - return &chainv1.EstimateTransferFeeResponse{ - NetworkFee: &moneyv1.Money{Currency: "ETH", Amount: "0.005"}, - }, nil - } - if dest != nil && strings.TrimSpace(dest.GetExternalAddress()) != "" { - return &chainv1.EstimateTransferFeeResponse{ - NetworkFee: &moneyv1.Money{Currency: "ETH", Amount: "0.01"}, - }, nil - } - return &chainv1.EstimateTransferFeeResponse{ - NetworkFee: &moneyv1.Money{Currency: "ETH", Amount: "0.02"}, - }, nil - }, - ComputeGasTopUpFn: func(ctx context.Context, req *chainv1.ComputeGasTopUpRequest) (*chainv1.ComputeGasTopUpResponse, error) { - computeCalls = append(computeCalls, req) - return &chainv1.ComputeGasTopUpResponse{ - TopupAmount: &moneyv1.Money{Currency: "ETH", Amount: "0.025"}, - }, nil - }, - EnsureGasTopUpFn: func(ctx context.Context, req *chainv1.EnsureGasTopUpRequest) (*chainv1.EnsureGasTopUpResponse, error) { - ensureCalls = append(ensureCalls, req) - return &chainv1.EnsureGasTopUpResponse{ - TopupAmount: &moneyv1.Money{Currency: "ETH", Amount: "0.025"}, - Transfer: &chainv1.Transfer{TransferRef: req.GetIdempotencyKey()}, - }, nil - }, - SubmitTransferFn: func(ctx context.Context, req *chainv1.SubmitTransferRequest) (*chainv1.SubmitTransferResponse, error) { - submitCalls = append(submitCalls, req) - return &chainv1.SubmitTransferResponse{ - Transfer: &chainv1.Transfer{TransferRef: req.GetIdempotencyKey()}, - }, nil - }, - } - - svc := &Service{ - logger: zap.NewNop(), - deps: serviceDependencies{ - gateway: gatewayDependency{resolver: staticChainGatewayResolver{client: gateway}}, - cardRoutes: map[string]CardGatewayRoute{ - defaultCardGateway: { - FundingAddress: fundingAddress, - FeeWalletRef: feeWalletRef, - }, - }, - }, - } - - payment := &model.Payment{ - PaymentRef: "pay-1", - IdempotencyKey: "pay-1", - OrganizationBoundBase: mo.OrganizationBoundBase{OrganizationRef: bson.NewObjectID()}, - Intent: model.PaymentIntent{ - Ref: "ref-1", - Kind: model.PaymentKindPayout, - Source: model.PaymentEndpoint{ - Type: model.EndpointTypeManagedWallet, - ManagedWallet: &model.ManagedWalletEndpoint{ - ManagedWalletRef: sourceWalletRef, - }, - }, - Destination: model.PaymentEndpoint{ - Type: model.EndpointTypeCard, - Card: &model.CardEndpoint{ - MaskedPan: "4111", - }, - }, - Amount: &paymenttypes.Money{Currency: "USDT", Amount: "5"}, - }, - } - - quote := &sharedv1.PaymentQuote{ - ExpectedFeeTotal: &moneyv1.Money{Currency: "USDT", Amount: "0.35"}, - } - - if err := svc.submitCardFundingTransfers(ctx, payment, quote); err != nil { - t.Fatalf("submitCardFundingTransfers error: %v", err) - } - - if len(estimateCalls) != 4 { - t.Fatalf("expected 4 fee estimates, got %d", len(estimateCalls)) - } - if len(computeCalls) != 1 { - t.Fatalf("expected 1 gas top-up compute call, got %d", len(computeCalls)) - } - if len(ensureCalls) != 1 { - t.Fatalf("expected 1 gas top-up ensure call, got %d", len(ensureCalls)) - } - if len(submitCalls) != 2 { - t.Fatalf("expected 2 transfer submissions, got %d", len(submitCalls)) - } - - computeCall := computeCalls[0] - if computeCall.GetWalletRef() != sourceWalletRef { - t.Fatalf("gas top-up compute wallet mismatch: %s", computeCall.GetWalletRef()) - } - if computeCall.GetEstimatedTotalFee().GetCurrency() != "ETH" || computeCall.GetEstimatedTotalFee().GetAmount() != "0.03" { - t.Fatalf("gas top-up compute fee mismatch: %s %s", computeCall.GetEstimatedTotalFee().GetCurrency(), computeCall.GetEstimatedTotalFee().GetAmount()) - } - - ensureCall := ensureCalls[0] - if ensureCall.GetSourceWalletRef() != feeWalletRef { - t.Fatalf("gas top-up source wallet mismatch: %s", ensureCall.GetSourceWalletRef()) - } - if ensureCall.GetTargetWalletRef() != sourceWalletRef { - t.Fatalf("gas top-up destination mismatch: %s", ensureCall.GetTargetWalletRef()) - } - if ensureCall.GetEstimatedTotalFee().GetCurrency() != "ETH" || ensureCall.GetEstimatedTotalFee().GetAmount() != "0.03" { - t.Fatalf("gas top-up ensure fee mismatch: %s %s", ensureCall.GetEstimatedTotalFee().GetCurrency(), ensureCall.GetEstimatedTotalFee().GetAmount()) - } - - fundCall := findSubmitCall(t, submitCalls, "pay-1:card:fund") - if fundCall.GetDestination().GetExternalAddress() != fundingAddress { - t.Fatalf("funding destination mismatch: %s", fundCall.GetDestination().GetExternalAddress()) - } - if fundCall.GetAmount().GetCurrency() != "USDT" || fundCall.GetAmount().GetAmount() != "5" { - t.Fatalf("funding amount mismatch: %s %s", fundCall.GetAmount().GetCurrency(), fundCall.GetAmount().GetAmount()) - } - - feeCall := findSubmitCall(t, submitCalls, "pay-1:card:fee") - if feeCall.GetDestination().GetManagedWalletRef() != feeWalletRef { - t.Fatalf("fee destination mismatch: %s", feeCall.GetDestination().GetManagedWalletRef()) - } - if feeCall.GetAmount().GetCurrency() != "USDT" || feeCall.GetAmount().GetAmount() != "0.35" { - t.Fatalf("fee amount mismatch: %s %s", feeCall.GetAmount().GetCurrency(), feeCall.GetAmount().GetAmount()) - } - - if payment.Execution == nil || payment.Execution.ChainTransferRef != "pay-1:card:fund" || payment.Execution.FeeTransferRef != "pay-1:card:fee" { - t.Fatalf("expected funding transfer ref recorded, got %v", payment.Execution) - } - - plan := payment.ExecutionPlan - if plan == nil { - t.Fatal("expected execution plan to be populated") - } - gasStep := findExecutionStep(t, plan, stepCodeGasTopUp) - if gasStep.Amount.GetAmount() != "0.025" || gasStep.Amount.GetCurrency() != "ETH" { - t.Fatalf("gas step amount mismatch: %s %s", gasStep.Amount.GetCurrency(), gasStep.Amount.GetAmount()) - } - if gasStep.NetworkFee.GetAmount() != "0.005" || gasStep.NetworkFee.GetCurrency() != "ETH" { - t.Fatalf("gas step fee mismatch: %s %s", gasStep.NetworkFee.GetCurrency(), gasStep.NetworkFee.GetAmount()) - } - if gasStep.TransferRef != "pay-1:card:gas" { - t.Fatalf("expected gas step transfer ref to be set, got %s", gasStep.TransferRef) - } - - fundStep := findExecutionStep(t, plan, stepCodeFundingTransfer) - if fundStep.NetworkFee.GetAmount() != "0.01" || fundStep.NetworkFee.GetCurrency() != "ETH" { - t.Fatalf("funding step fee mismatch: %s %s", fundStep.NetworkFee.GetCurrency(), fundStep.NetworkFee.GetAmount()) - } - if fundStep.TransferRef != "pay-1:card:fund" { - t.Fatalf("funding step transfer ref mismatch: %s", fundStep.TransferRef) - } - - cardStep := findExecutionStep(t, plan, stepCodeCardPayout) - if cardStep.Amount.GetAmount() != "5" || cardStep.Amount.GetCurrency() != "USDT" { - t.Fatalf("card step amount mismatch: %s %s", cardStep.Amount.GetCurrency(), cardStep.Amount.GetAmount()) - } - - feeStep := findExecutionStep(t, plan, stepCodeFeeTransfer) - if feeStep.Amount.GetAmount() != "0.35" || feeStep.Amount.GetCurrency() != "USDT" { - t.Fatalf("fee step amount mismatch: %s %s", feeStep.Amount.GetCurrency(), feeStep.Amount.GetAmount()) - } - if feeStep.NetworkFee.GetAmount() != "0.02" || feeStep.NetworkFee.GetCurrency() != "ETH" { - t.Fatalf("fee step network fee mismatch: %s %s", feeStep.NetworkFee.GetCurrency(), feeStep.NetworkFee.GetAmount()) - } - if feeStep.TransferRef != "pay-1:card:fee" { - t.Fatalf("fee step transfer ref mismatch: %s", feeStep.TransferRef) - } - - if plan.TotalNetworkFee == nil || plan.TotalNetworkFee.GetAmount() != "0.035" || plan.TotalNetworkFee.GetCurrency() != "ETH" { - t.Fatalf("total network fee mismatch: %v", plan.TotalNetworkFee) - } -} - -func TestSubmitCardPayout_UsesSettlementAmount(t *testing.T) { - ctx := context.Background() - - const sourceWalletRef = "wallet-src" - - var payoutReq *mntxv1.CardPayoutRequest - var submitCalls []*chainv1.SubmitTransferRequest - - gateway := &chainclient.Fake{ - SubmitTransferFn: func(ctx context.Context, req *chainv1.SubmitTransferRequest) (*chainv1.SubmitTransferResponse, error) { - submitCalls = append(submitCalls, req) - return &chainv1.SubmitTransferResponse{ - Transfer: &chainv1.Transfer{TransferRef: "fee-transfer"}, - }, nil - }, - } - mntx := &mntxclient.Fake{ - CreateCardPayoutFn: func(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error) { - payoutReq = req - return &mntxv1.CardPayoutResponse{ - Payout: &mntxv1.CardPayoutState{ - PayoutId: "payout-1", - Status: mntxv1.PayoutStatus_PAYOUT_STATUS_WAITING, - }, - }, nil - }, - } - - svc := &Service{ - logger: zap.NewNop(), - deps: serviceDependencies{ - gateway: gatewayDependency{resolver: staticChainGatewayResolver{client: gateway}}, - mntx: mntxDependency{client: mntx}, - }, - } - - payment := &model.Payment{ - PaymentRef: "pay-2", - IdempotencyKey: "pay-2", - OrganizationBoundBase: mo.OrganizationBoundBase{OrganizationRef: bson.NewObjectID()}, - Intent: model.PaymentIntent{ - Ref: "ref-2", - Kind: model.PaymentKindPayout, - Source: model.PaymentEndpoint{ - Type: model.EndpointTypeManagedWallet, - ManagedWallet: &model.ManagedWalletEndpoint{ - ManagedWalletRef: sourceWalletRef, - }, - }, - Destination: model.PaymentEndpoint{ - Type: model.EndpointTypeCard, - Card: &model.CardEndpoint{ - Pan: "5536913762657597", - Cardholder: "Stephan", - }, - }, - Amount: &paymenttypes.Money{Currency: "USDT", Amount: "5"}, - Customer: &model.Customer{ - ID: "recipient-1", - FirstName: "Stephan", - LastName: "Tester", - IP: "198.51.100.10", - }, - }, - LastQuote: &model.PaymentQuoteSnapshot{ - ExpectedSettlementAmount: &paymenttypes.Money{Currency: "RUB", Amount: "392.30"}, - ExpectedFeeTotal: &paymenttypes.Money{Currency: "USDT", Amount: "0.35"}, - }, - } - - if err := svc.submitCardPayout(ctx, "op-ref", payment); err != nil { - t.Fatalf("submitCardPayout error: %v", err) - } - - if payoutReq == nil { - t.Fatal("expected card payout request to be sent") - } - if payoutReq.GetCurrency() != "RUB" || payoutReq.GetAmountMinor() != 39230 { - t.Fatalf("payout request amount mismatch: %s %d", payoutReq.GetCurrency(), payoutReq.GetAmountMinor()) - } - - if payment.Execution == nil || payment.Execution.CardPayoutRef != "payout-1" { - t.Fatalf("expected card payout ref recorded, got %v", payment.Execution) - } - - if len(submitCalls) != 0 { - t.Fatalf("expected 0 fee transfer submissions, got %d", len(submitCalls)) - } - - plan := payment.ExecutionPlan - if plan == nil { - t.Fatal("expected execution plan to be populated") - } - cardStep := findExecutionStep(t, plan, stepCodeCardPayout) - if cardStep.TransferRef != "payout-1" { - t.Fatalf("card step transfer ref mismatch: %s", cardStep.TransferRef) - } - if cardStep.Amount.GetAmount() != "392.30" || cardStep.Amount.GetCurrency() != "RUB" { - t.Fatalf("card step amount mismatch: %s %s", cardStep.Amount.GetCurrency(), cardStep.Amount.GetAmount()) - } - -} - -func TestSubmitCardFundingTransfers_RequiresFeeWalletRef(t *testing.T) { - ctx := context.Background() - - gateway := &chainclient.Fake{ - EstimateTransferFeeFn: func(ctx context.Context, req *chainv1.EstimateTransferFeeRequest) (*chainv1.EstimateTransferFeeResponse, error) { - return &chainv1.EstimateTransferFeeResponse{ - NetworkFee: &moneyv1.Money{Currency: "ETH", Amount: "0.01"}, - }, nil - }, - } - - svc := &Service{ - logger: zap.NewNop(), - deps: serviceDependencies{ - gateway: gatewayDependency{resolver: staticChainGatewayResolver{client: gateway}}, - cardRoutes: map[string]CardGatewayRoute{ - defaultCardGateway: { - FundingAddress: "0xfunding", - }, - }, - }, - } - - payment := &model.Payment{ - PaymentRef: "pay-3", - IdempotencyKey: "pay-3", - OrganizationBoundBase: mo.OrganizationBoundBase{OrganizationRef: bson.NewObjectID()}, - Intent: model.PaymentIntent{ - Ref: "ref-3", - Kind: model.PaymentKindPayout, - Source: model.PaymentEndpoint{ - Type: model.EndpointTypeManagedWallet, - ManagedWallet: &model.ManagedWalletEndpoint{ - ManagedWalletRef: "wallet-src", - }, - }, - Destination: model.PaymentEndpoint{ - Type: model.EndpointTypeCard, - Card: &model.CardEndpoint{ - MaskedPan: "4111", - }, - }, - Amount: &paymenttypes.Money{Currency: "USDT", Amount: "5"}, - }, - } - - quote := &sharedv1.PaymentQuote{ - ExpectedFeeTotal: &moneyv1.Money{Currency: "USDT", Amount: "0.35"}, - } - - err := svc.submitCardFundingTransfers(ctx, payment, quote) - if err == nil { - t.Fatal("expected error for missing fee wallet ref") - } - if !strings.Contains(err.Error(), "fee wallet ref") { - t.Fatalf("unexpected error: %v", err) - } -} - -func findSubmitCall(t *testing.T, calls []*chainv1.SubmitTransferRequest, idempotencyKey string) *chainv1.SubmitTransferRequest { - t.Helper() - for _, call := range calls { - if call.GetIdempotencyKey() == idempotencyKey { - return call - } - } - t.Fatalf("missing submit transfer call for %s", idempotencyKey) - return nil -} - -func findExecutionStep(t *testing.T, plan *model.ExecutionPlan, code string) *model.ExecutionStep { - t.Helper() - if plan == nil { - t.Fatal("execution plan is nil") - } - for _, step := range plan.Steps { - if step != nil && strings.EqualFold(step.Code, code) { - return step - } - } - t.Fatalf("missing execution step %s", code) - return nil -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/command_factory.go b/api/payments/orchestrator/internal/service/orchestrator/command_factory.go deleted file mode 100644 index aedc0be8..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/command_factory.go +++ /dev/null @@ -1,77 +0,0 @@ -package orchestrator - -import ( - "context" - - "github.com/tech/sendico/payments/storage" - "github.com/tech/sendico/payments/storage/model" - "github.com/tech/sendico/pkg/mlogger" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" -) - -type paymentEngine interface { - EnsureRepository(ctx context.Context) error - ResolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*sharedv1.PaymentQuote, *sharedv1.PaymentIntent, *model.PaymentPlan, error) - ExecutePayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, quote *sharedv1.PaymentQuote) error - Repository() storage.Repository -} - -type defaultPaymentEngine struct { - svc *Service -} - -func (e defaultPaymentEngine) EnsureRepository(ctx context.Context) error { - return e.svc.ensureRepository(ctx) -} - -func (e defaultPaymentEngine) ResolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*sharedv1.PaymentQuote, *sharedv1.PaymentIntent, *model.PaymentPlan, error) { - return e.svc.resolvePaymentQuote(ctx, in) -} - -func (e defaultPaymentEngine) ExecutePayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, quote *sharedv1.PaymentQuote) error { - return e.svc.executePayment(ctx, store, payment, quote) -} - -func (e defaultPaymentEngine) Repository() storage.Repository { - return e.svc.storage -} - -type paymentCommandFactory struct { - engine paymentEngine - logger mlogger.Logger -} - -func newPaymentCommandFactory(engine paymentEngine, logger mlogger.Logger) *paymentCommandFactory { - return &paymentCommandFactory{ - engine: engine, - logger: logger.Named("commands"), - } -} - -func (f *paymentCommandFactory) InitiatePayment() *initiatePaymentCommand { - return &initiatePaymentCommand{ - engine: f.engine, - logger: f.logger.Named("initiate_payment"), - } -} - -func (f *paymentCommandFactory) InitiatePayments() *initiatePaymentsCommand { - return &initiatePaymentsCommand{ - engine: f.engine, - logger: f.logger.Named("initiate_payments"), - } -} - -func (f *paymentCommandFactory) CancelPayment() *cancelPaymentCommand { - return &cancelPaymentCommand{ - engine: f.engine, - logger: f.logger.Named("cancel_payment"), - } -} - -func (f *paymentCommandFactory) InitiateConversion() *initiateConversionCommand { - return &initiateConversionCommand{ - engine: f.engine, - logger: f.logger.Named("initiate_conversion"), - } -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/composite_gateway_registry.go b/api/payments/orchestrator/internal/service/orchestrator/composite_gateway_registry.go deleted file mode 100644 index 5e3f3609..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/composite_gateway_registry.go +++ /dev/null @@ -1,65 +0,0 @@ -package orchestrator - -import ( - "context" - "sort" - - "github.com/tech/sendico/payments/storage/model" - "github.com/tech/sendico/pkg/mlogger" - "go.uber.org/zap" -) - -type compositeGatewayRegistry struct { - logger mlogger.Logger - registries []GatewayRegistry -} - -func NewCompositeGatewayRegistry(logger mlogger.Logger, registries ...GatewayRegistry) GatewayRegistry { - items := make([]GatewayRegistry, 0, len(registries)) - for _, registry := range registries { - if registry != nil { - items = append(items, registry) - } - } - if len(items) == 0 { - return nil - } - if logger != nil { - logger = logger.Named("gateway_registry") - } - return &compositeGatewayRegistry{ - logger: logger, - registries: items, - } -} - -func (r *compositeGatewayRegistry) List(ctx context.Context) ([]*model.GatewayInstanceDescriptor, error) { - if r == nil || len(r.registries) == 0 { - return nil, nil - } - items := map[string]*model.GatewayInstanceDescriptor{} - for _, registry := range r.registries { - list, err := registry.List(ctx) - if err != nil { - if r.logger != nil { - r.logger.Warn("Failed to list gateway registry", zap.Error(err)) - } - continue - } - for _, entry := range list { - key := model.GatewayDescriptorIdentityKey(entry) - if key == "" { - continue - } - items[key] = entry - } - } - result := make([]*model.GatewayInstanceDescriptor, 0, len(items)) - for _, entry := range items { - result = append(result, entry) - } - sort.Slice(result, func(i, j int) bool { - return model.LessGatewayDescriptor(result[i], result[j]) - }) - return result, nil -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/convert.go b/api/payments/orchestrator/internal/service/orchestrator/convert.go deleted file mode 100644 index 12e6b3ce..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/convert.go +++ /dev/null @@ -1,959 +0,0 @@ -package orchestrator - -import ( - "strings" - "time" - - "github.com/tech/sendico/payments/storage/model" - chainasset "github.com/tech/sendico/pkg/chain" - 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" - gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1" - moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" - paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1" - chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" - oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" - orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestration/v1" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" - "google.golang.org/protobuf/types/known/timestamppb" -) - -func intentFromProto(src *sharedv1.PaymentIntent) model.PaymentIntent { - if src == nil { - return model.PaymentIntent{} - } - intent := model.PaymentIntent{ - Ref: src.GetRef(), - Kind: modelKindFromProto(src.GetKind()), - Source: endpointFromProto(src.GetSource()), - Destination: endpointFromProto(src.GetDestination()), - Amount: moneyFromProto(src.GetAmount()), - RequiresFX: src.GetRequiresFx(), - FeePolicy: feePolicyFromProto(src.GetFeePolicy()), - SettlementMode: settlementModeFromProto(src.GetSettlementMode()), - SettlementCurrency: strings.TrimSpace(src.GetSettlementCurrency()), - Attributes: cloneMetadata(src.GetAttributes()), - Customer: customerFromProto(src.GetCustomer()), - } - if src.GetFx() != nil { - intent.FX = fxIntentFromProto(src.GetFx()) - } - return intent -} - -func endpointFromProto(src *sharedv1.PaymentEndpoint) model.PaymentEndpoint { - if src == nil { - return model.PaymentEndpoint{Type: model.EndpointTypeUnspecified} - } - result := model.PaymentEndpoint{ - Type: model.EndpointTypeUnspecified, - InstanceID: strings.TrimSpace(src.GetInstanceId()), - Metadata: cloneMetadata(src.GetMetadata()), - } - if ledger := src.GetLedger(); ledger != nil { - result.Type = model.EndpointTypeLedger - result.Ledger = &model.LedgerEndpoint{ - LedgerAccountRef: strings.TrimSpace(ledger.GetLedgerAccountRef()), - ContraLedgerAccountRef: strings.TrimSpace(ledger.GetContraLedgerAccountRef()), - } - return result - } - if managed := src.GetManagedWallet(); managed != nil { - result.Type = model.EndpointTypeManagedWallet - result.ManagedWallet = &model.ManagedWalletEndpoint{ - ManagedWalletRef: strings.TrimSpace(managed.GetManagedWalletRef()), - Asset: assetFromProto(managed.GetAsset()), - } - return result - } - if external := src.GetExternalChain(); external != nil { - result.Type = model.EndpointTypeExternalChain - result.ExternalChain = &model.ExternalChainEndpoint{ - Asset: assetFromProto(external.GetAsset()), - Address: strings.TrimSpace(external.GetAddress()), - Memo: strings.TrimSpace(external.GetMemo()), - } - return result - } - if card := src.GetCard(); card != nil { - result.Type = model.EndpointTypeCard - result.Card = &model.CardEndpoint{ - Pan: strings.TrimSpace(card.GetPan()), - Token: strings.TrimSpace(card.GetToken()), - Cardholder: strings.TrimSpace(card.GetCardholderName()), - CardholderSurname: strings.TrimSpace(card.GetCardholderSurname()), - ExpMonth: card.GetExpMonth(), - ExpYear: card.GetExpYear(), - Country: strings.TrimSpace(card.GetCountry()), - MaskedPan: strings.TrimSpace(card.GetMaskedPan()), - } - return result - } - return result -} - -func fxIntentFromProto(src *sharedv1.FXIntent) *model.FXIntent { - if src == nil { - return nil - } - return &model.FXIntent{ - Pair: pairFromProto(src.GetPair()), - Side: fxSideFromProto(src.GetSide()), - Firm: src.GetFirm(), - TTLMillis: src.GetTtlMs(), - PreferredProvider: strings.TrimSpace(src.GetPreferredProvider()), - MaxAgeMillis: src.GetMaxAgeMs(), - } -} - -func quoteSnapshotToModel(src *sharedv1.PaymentQuote) *model.PaymentQuoteSnapshot { - if src == nil { - return nil - } - return &model.PaymentQuoteSnapshot{ - DebitAmount: moneyFromProto(src.GetDebitAmount()), - DebitSettlementAmount: moneyFromProto(src.GetDebitSettlementAmount()), - ExpectedSettlementAmount: moneyFromProto(src.GetExpectedSettlementAmount()), - ExpectedFeeTotal: moneyFromProto(src.GetExpectedFeeTotal()), - FeeLines: feeLinesFromProto(src.GetFeeLines()), - FeeRules: feeRulesFromProto(src.GetFeeRules()), - FXQuote: fxQuoteFromProto(src.GetFxQuote()), - NetworkFee: networkFeeFromProto(src.GetNetworkFee()), - QuoteRef: strings.TrimSpace(src.GetQuoteRef()), - } -} - -func toProtoPayment(src *model.Payment) *sharedv1.Payment { - if src == nil { - return nil - } - payment := &sharedv1.Payment{ - PaymentRef: src.PaymentRef, - IdempotencyKey: src.IdempotencyKey, - Intent: protoIntentFromModel(src.Intent), - State: protoStateFromModel(src.State), - FailureCode: protoFailureFromModel(src.FailureCode), - FailureReason: src.FailureReason, - LastQuote: modelQuoteToProto(src.LastQuote), - Execution: protoExecutionFromModel(src.Execution), - ExecutionPlan: protoExecutionPlanFromModel(src.ExecutionPlan), - PaymentPlan: protoPaymentPlanFromModel(src.PaymentPlan), - Metadata: cloneMetadata(src.Metadata), - } - if src.CardPayout != nil { - payment.CardPayout = &sharedv1.CardPayout{ - PayoutRef: src.CardPayout.PayoutRef, - ProviderPaymentId: src.CardPayout.ProviderPaymentID, - Status: src.CardPayout.Status, - FailureReason: src.CardPayout.FailureReason, - CardCountry: src.CardPayout.CardCountry, - MaskedPan: src.CardPayout.MaskedPan, - ProviderCode: src.CardPayout.ProviderCode, - GatewayReference: src.CardPayout.GatewayReference, - } - } - if src.CreatedAt.IsZero() { - payment.CreatedAt = timestamppb.New(time.Now().UTC()) - } else { - payment.CreatedAt = timestamppb.New(src.CreatedAt.UTC()) - } - if src.UpdatedAt != (time.Time{}) { - payment.UpdatedAt = timestamppb.New(src.UpdatedAt.UTC()) - } - return payment -} - -func protoIntentFromModel(src model.PaymentIntent) *sharedv1.PaymentIntent { - intent := &sharedv1.PaymentIntent{ - Ref: src.Ref, - Kind: protoKindFromModel(src.Kind), - Source: protoEndpointFromModel(src.Source), - Destination: protoEndpointFromModel(src.Destination), - Amount: protoMoney(src.Amount), - RequiresFx: src.RequiresFX, - FeePolicy: feePolicyToProto(src.FeePolicy), - SettlementMode: settlementModeToProto(src.SettlementMode), - SettlementCurrency: strings.TrimSpace(src.SettlementCurrency), - Attributes: cloneMetadata(src.Attributes), - Customer: protoCustomerFromModel(src.Customer), - } - if src.FX != nil { - intent.Fx = protoFXIntentFromModel(src.FX) - } - return intent -} - -func customerFromProto(src *sharedv1.Customer) *model.Customer { - if src == nil { - return nil - } - return &model.Customer{ - ID: strings.TrimSpace(src.GetId()), - FirstName: strings.TrimSpace(src.GetFirstName()), - MiddleName: strings.TrimSpace(src.GetMiddleName()), - LastName: strings.TrimSpace(src.GetLastName()), - IP: strings.TrimSpace(src.GetIp()), - Zip: strings.TrimSpace(src.GetZip()), - Country: strings.TrimSpace(src.GetCountry()), - State: strings.TrimSpace(src.GetState()), - City: strings.TrimSpace(src.GetCity()), - Address: strings.TrimSpace(src.GetAddress()), - } -} - -func protoCustomerFromModel(src *model.Customer) *sharedv1.Customer { - if src == nil { - return nil - } - return &sharedv1.Customer{ - Id: strings.TrimSpace(src.ID), - FirstName: strings.TrimSpace(src.FirstName), - MiddleName: strings.TrimSpace(src.MiddleName), - LastName: strings.TrimSpace(src.LastName), - Ip: strings.TrimSpace(src.IP), - Zip: strings.TrimSpace(src.Zip), - Country: strings.TrimSpace(src.Country), - State: strings.TrimSpace(src.State), - City: strings.TrimSpace(src.City), - Address: strings.TrimSpace(src.Address), - } -} - -func protoEndpointFromModel(src model.PaymentEndpoint) *sharedv1.PaymentEndpoint { - endpoint := &sharedv1.PaymentEndpoint{ - Metadata: cloneMetadata(src.Metadata), - InstanceId: strings.TrimSpace(src.InstanceID), - } - switch src.Type { - case model.EndpointTypeLedger: - if src.Ledger != nil { - endpoint.Endpoint = &sharedv1.PaymentEndpoint_Ledger{ - Ledger: &sharedv1.LedgerEndpoint{ - LedgerAccountRef: src.Ledger.LedgerAccountRef, - ContraLedgerAccountRef: src.Ledger.ContraLedgerAccountRef, - }, - } - } - case model.EndpointTypeManagedWallet: - if src.ManagedWallet != nil { - endpoint.Endpoint = &sharedv1.PaymentEndpoint_ManagedWallet{ - ManagedWallet: &sharedv1.ManagedWalletEndpoint{ - ManagedWalletRef: src.ManagedWallet.ManagedWalletRef, - Asset: assetToProto(src.ManagedWallet.Asset), - }, - } - } - case model.EndpointTypeExternalChain: - if src.ExternalChain != nil { - endpoint.Endpoint = &sharedv1.PaymentEndpoint_ExternalChain{ - ExternalChain: &sharedv1.ExternalChainEndpoint{ - Asset: assetToProto(src.ExternalChain.Asset), - Address: src.ExternalChain.Address, - Memo: src.ExternalChain.Memo, - }, - } - } - case model.EndpointTypeCard: - if src.Card != nil { - card := &sharedv1.CardEndpoint{ - CardholderName: src.Card.Cardholder, - CardholderSurname: src.Card.CardholderSurname, - ExpMonth: src.Card.ExpMonth, - ExpYear: src.Card.ExpYear, - Country: src.Card.Country, - MaskedPan: src.Card.MaskedPan, - } - if pan := strings.TrimSpace(src.Card.Pan); pan != "" { - card.Card = &sharedv1.CardEndpoint_Pan{Pan: pan} - } - if token := strings.TrimSpace(src.Card.Token); token != "" { - card.Card = &sharedv1.CardEndpoint_Token{Token: token} - } - endpoint.Endpoint = &sharedv1.PaymentEndpoint_Card{Card: card} - } - default: - // leave unspecified - } - return endpoint -} - -func protoFXIntentFromModel(src *model.FXIntent) *sharedv1.FXIntent { - if src == nil { - return nil - } - return &sharedv1.FXIntent{ - Pair: pairToProto(src.Pair), - Side: fxSideToProto(src.Side), - Firm: src.Firm, - TtlMs: src.TTLMillis, - PreferredProvider: src.PreferredProvider, - MaxAgeMs: src.MaxAgeMillis, - } -} - -func protoExecutionFromModel(src *model.ExecutionRefs) *sharedv1.ExecutionRefs { - if src == nil { - return nil - } - return &sharedv1.ExecutionRefs{ - DebitEntryRef: src.DebitEntryRef, - CreditEntryRef: src.CreditEntryRef, - FxEntryRef: src.FXEntryRef, - ChainTransferRef: src.ChainTransferRef, - CardPayoutRef: src.CardPayoutRef, - FeeTransferRef: src.FeeTransferRef, - } -} - -func protoExecutionStepFromModel(src *model.ExecutionStep) *sharedv1.ExecutionStep { - if src == nil { - return nil - } - return &sharedv1.ExecutionStep{ - Code: src.Code, - Description: src.Description, - Amount: protoMoney(src.Amount), - NetworkFee: protoMoney(src.NetworkFee), - SourceWalletRef: src.SourceWalletRef, - DestinationRef: src.DestinationRef, - TransferRef: src.TransferRef, - Metadata: cloneMetadata(src.Metadata), - OperationRef: src.OperationRef, - } -} - -func protoExecutionPlanFromModel(src *model.ExecutionPlan) *sharedv1.ExecutionPlan { - if src == nil { - return nil - } - steps := make([]*sharedv1.ExecutionStep, 0, len(src.Steps)) - for _, step := range src.Steps { - if protoStep := protoExecutionStepFromModel(step); protoStep != nil { - steps = append(steps, protoStep) - } - } - if len(steps) == 0 { - steps = nil - } - return &sharedv1.ExecutionPlan{ - Steps: steps, - TotalNetworkFee: protoMoney(src.TotalNetworkFee), - } -} - -func protoPaymentStepFromModel(src *model.PaymentStep) *sharedv1.PaymentStep { - if src == nil { - return nil - } - return &sharedv1.PaymentStep{ - Rail: protoRailFromModel(src.Rail), - GatewayId: strings.TrimSpace(src.GatewayID), - Action: protoRailOperationFromModel(src.Action), - Amount: protoMoney(src.Amount), - StepId: strings.TrimSpace(src.StepID), - InstanceId: strings.TrimSpace(src.InstanceID), - DependsOn: cloneStringList(src.DependsOn), - CommitPolicy: strings.TrimSpace(string(src.CommitPolicy)), - CommitAfter: cloneStringList(src.CommitAfter), - } -} - -func protoPaymentPlanFromModel(src *model.PaymentPlan) *sharedv1.PaymentPlan { - if src == nil { - return nil - } - steps := make([]*sharedv1.PaymentStep, 0, len(src.Steps)) - for _, step := range src.Steps { - if protoStep := protoPaymentStepFromModel(step); protoStep != nil { - steps = append(steps, protoStep) - } - } - if len(steps) == 0 { - steps = nil - } - plan := &sharedv1.PaymentPlan{ - Id: strings.TrimSpace(src.ID), - Steps: steps, - IdempotencyKey: strings.TrimSpace(src.IdempotencyKey), - FxQuote: fxQuoteToProto(src.FXQuote), - Fees: feeLinesToProto(src.Fees), - } - if !src.CreatedAt.IsZero() { - plan.CreatedAt = timestamppb.New(src.CreatedAt.UTC()) - } - return plan -} - -func modelQuoteToProto(src *model.PaymentQuoteSnapshot) *sharedv1.PaymentQuote { - if src == nil { - return nil - } - return &sharedv1.PaymentQuote{ - DebitAmount: protoMoney(src.DebitAmount), - DebitSettlementAmount: protoMoney(src.DebitSettlementAmount), - ExpectedSettlementAmount: protoMoney(src.ExpectedSettlementAmount), - ExpectedFeeTotal: protoMoney(src.ExpectedFeeTotal), - FeeLines: feeLinesToProto(src.FeeLines), - FeeRules: feeRulesToProto(src.FeeRules), - FxQuote: fxQuoteToProto(src.FXQuote), - NetworkFee: networkFeeToProto(src.NetworkFee), - QuoteRef: strings.TrimSpace(src.QuoteRef), - } -} - -func filterFromProto(req *orchestratorv1.ListPaymentsRequest) *model.PaymentFilter { - if req == nil { - return &model.PaymentFilter{} - } - filter := &model.PaymentFilter{ - SourceRef: strings.TrimSpace(req.GetSourceRef()), - DestinationRef: strings.TrimSpace(req.GetDestinationRef()), - OrganizationRef: strings.TrimSpace(req.GetOrganizationRef()), - } - if req.GetPage() != nil { - filter.Cursor = strings.TrimSpace(req.GetPage().GetCursor()) - filter.Limit = req.GetPage().GetLimit() - } - if len(req.GetFilterStates()) > 0 { - filter.States = make([]model.PaymentState, 0, len(req.GetFilterStates())) - for _, st := range req.GetFilterStates() { - filter.States = append(filter.States, modelStateFromProto(st)) - } - } - return filter -} - -func protoKindFromModel(kind model.PaymentKind) sharedv1.PaymentKind { - switch kind { - case model.PaymentKindPayout: - return sharedv1.PaymentKind_PAYMENT_KIND_PAYOUT - case model.PaymentKindInternalTransfer: - return sharedv1.PaymentKind_PAYMENT_KIND_INTERNAL_TRANSFER - case model.PaymentKindFXConversion: - return sharedv1.PaymentKind_PAYMENT_KIND_FX_CONVERSION - default: - return sharedv1.PaymentKind_PAYMENT_KIND_UNSPECIFIED - } -} - -func modelKindFromProto(kind sharedv1.PaymentKind) model.PaymentKind { - switch kind { - case sharedv1.PaymentKind_PAYMENT_KIND_PAYOUT: - return model.PaymentKindPayout - case sharedv1.PaymentKind_PAYMENT_KIND_INTERNAL_TRANSFER: - return model.PaymentKindInternalTransfer - case sharedv1.PaymentKind_PAYMENT_KIND_FX_CONVERSION: - return model.PaymentKindFXConversion - default: - return model.PaymentKindUnspecified - } -} - -func protoRailFromModel(rail model.Rail) gatewayv1.Rail { - switch strings.ToUpper(strings.TrimSpace(string(rail))) { - case string(model.RailCrypto): - return gatewayv1.Rail_RAIL_CRYPTO - case string(model.RailProviderSettlement): - return gatewayv1.Rail_RAIL_PROVIDER_SETTLEMENT - case string(model.RailLedger): - return gatewayv1.Rail_RAIL_LEDGER - case string(model.RailCardPayout): - return gatewayv1.Rail_RAIL_CARD_PAYOUT - case string(model.RailFiatOnRamp): - return gatewayv1.Rail_RAIL_FIAT_ONRAMP - default: - return gatewayv1.Rail_RAIL_UNSPECIFIED - } -} - -func protoRailOperationFromModel(action model.RailOperation) gatewayv1.RailOperation { - switch strings.ToUpper(strings.TrimSpace(string(action))) { - case string(model.RailOperationDebit): - return gatewayv1.RailOperation_RAIL_OPERATION_DEBIT - case string(model.RailOperationCredit): - return gatewayv1.RailOperation_RAIL_OPERATION_CREDIT - case string(model.RailOperationExternalDebit): - return gatewayv1.RailOperation_RAIL_OPERATION_DEBIT - case string(model.RailOperationExternalCredit): - return gatewayv1.RailOperation_RAIL_OPERATION_CREDIT - case string(model.RailOperationMove): - return gatewayv1.RailOperation_RAIL_OPERATION_MOVE - case string(model.RailOperationSend): - return gatewayv1.RailOperation_RAIL_OPERATION_SEND - case string(model.RailOperationFee): - return gatewayv1.RailOperation_RAIL_OPERATION_FEE - case string(model.RailOperationObserveConfirm): - return gatewayv1.RailOperation_RAIL_OPERATION_OBSERVE_CONFIRM - case string(model.RailOperationFXConvert): - return gatewayv1.RailOperation_RAIL_OPERATION_FX_CONVERT - case string(model.RailOperationBlock): - return gatewayv1.RailOperation_RAIL_OPERATION_BLOCK - case string(model.RailOperationRelease): - return gatewayv1.RailOperation_RAIL_OPERATION_RELEASE - default: - return gatewayv1.RailOperation_RAIL_OPERATION_UNSPECIFIED - } -} - -func protoStateFromModel(state model.PaymentState) sharedv1.PaymentState { - switch state { - case model.PaymentStateAccepted: - return sharedv1.PaymentState_PAYMENT_STATE_ACCEPTED - case model.PaymentStateFundsReserved: - return sharedv1.PaymentState_PAYMENT_STATE_FUNDS_RESERVED - case model.PaymentStateSubmitted: - return sharedv1.PaymentState_PAYMENT_STATE_SUBMITTED - case model.PaymentStateSettled: - return sharedv1.PaymentState_PAYMENT_STATE_SETTLED - case model.PaymentStateFailed: - return sharedv1.PaymentState_PAYMENT_STATE_FAILED - case model.PaymentStateCancelled: - return sharedv1.PaymentState_PAYMENT_STATE_CANCELLED - default: - return sharedv1.PaymentState_PAYMENT_STATE_UNSPECIFIED - } -} - -func modelStateFromProto(state sharedv1.PaymentState) model.PaymentState { - switch state { - case sharedv1.PaymentState_PAYMENT_STATE_ACCEPTED: - return model.PaymentStateAccepted - case sharedv1.PaymentState_PAYMENT_STATE_FUNDS_RESERVED: - return model.PaymentStateFundsReserved - case sharedv1.PaymentState_PAYMENT_STATE_SUBMITTED: - return model.PaymentStateSubmitted - case sharedv1.PaymentState_PAYMENT_STATE_SETTLED: - return model.PaymentStateSettled - case sharedv1.PaymentState_PAYMENT_STATE_FAILED: - return model.PaymentStateFailed - case sharedv1.PaymentState_PAYMENT_STATE_CANCELLED: - return model.PaymentStateCancelled - default: - return model.PaymentStateUnspecified - } -} - -func protoFailureFromModel(code model.PaymentFailureCode) sharedv1.PaymentFailureCode { - switch code { - case model.PaymentFailureCodeBalance: - return sharedv1.PaymentFailureCode_FAILURE_BALANCE - case model.PaymentFailureCodeLedger: - return sharedv1.PaymentFailureCode_FAILURE_LEDGER - case model.PaymentFailureCodeFX: - return sharedv1.PaymentFailureCode_FAILURE_FX - case model.PaymentFailureCodeChain: - return sharedv1.PaymentFailureCode_FAILURE_CHAIN - case model.PaymentFailureCodeFees: - return sharedv1.PaymentFailureCode_FAILURE_FEES - case model.PaymentFailureCodePolicy: - return sharedv1.PaymentFailureCode_FAILURE_POLICY - default: - return sharedv1.PaymentFailureCode_FAILURE_UNSPECIFIED - } -} - -func settlementModeFromProto(mode paymentv1.SettlementMode) model.SettlementMode { - switch mode { - case paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE: - return model.SettlementModeFixSource - case paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED: - return model.SettlementModeFixReceived - default: - return model.SettlementModeUnspecified - } -} - -func settlementModeToProto(mode model.SettlementMode) paymentv1.SettlementMode { - switch mode { - case model.SettlementModeFixSource: - return paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE - case model.SettlementModeFixReceived: - return paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED - default: - return paymentv1.SettlementMode_SETTLEMENT_UNSPECIFIED - } -} - -func moneyFromProto(m *moneyv1.Money) *paymenttypes.Money { - if m == nil { - return nil - } - return &paymenttypes.Money{ - Currency: m.GetCurrency(), - Amount: m.GetAmount(), - } -} - -func protoMoney(m *paymenttypes.Money) *moneyv1.Money { - if m == nil { - return nil - } - return &moneyv1.Money{ - Currency: m.GetCurrency(), - Amount: m.GetAmount(), - } -} - -func feePolicyFromProto(src *feesv1.PolicyOverrides) *paymenttypes.FeePolicy { - if src == nil { - return nil - } - return &paymenttypes.FeePolicy{ - InsufficientNet: insufficientPolicyFromProto(src.GetInsufficientNet()), - } -} - -func feePolicyToProto(src *paymenttypes.FeePolicy) *feesv1.PolicyOverrides { - if src == nil { - return nil - } - return &feesv1.PolicyOverrides{ - InsufficientNet: insufficientPolicyToProto(src.InsufficientNet), - } -} - -func insufficientPolicyFromProto(policy feesv1.InsufficientNetPolicy) paymenttypes.InsufficientNetPolicy { - switch policy { - case feesv1.InsufficientNetPolicy_BLOCK_POSTING: - return paymenttypes.InsufficientNetBlockPosting - case feesv1.InsufficientNetPolicy_SWEEP_ORG_CASH: - return paymenttypes.InsufficientNetSweepOrgCash - case feesv1.InsufficientNetPolicy_INVOICE_LATER: - return paymenttypes.InsufficientNetInvoiceLater - default: - return paymenttypes.InsufficientNetUnspecified - } -} - -func insufficientPolicyToProto(policy paymenttypes.InsufficientNetPolicy) feesv1.InsufficientNetPolicy { - switch policy { - case paymenttypes.InsufficientNetBlockPosting: - return feesv1.InsufficientNetPolicy_BLOCK_POSTING - case paymenttypes.InsufficientNetSweepOrgCash: - return feesv1.InsufficientNetPolicy_SWEEP_ORG_CASH - case paymenttypes.InsufficientNetInvoiceLater: - return feesv1.InsufficientNetPolicy_INVOICE_LATER - default: - return feesv1.InsufficientNetPolicy_INSUFFICIENT_NET_UNSPECIFIED - } -} - -func pairFromProto(pair *fxv1.CurrencyPair) *paymenttypes.CurrencyPair { - if pair == nil { - return nil - } - return &paymenttypes.CurrencyPair{ - Base: pair.GetBase(), - Quote: pair.GetQuote(), - } -} - -func pairToProto(pair *paymenttypes.CurrencyPair) *fxv1.CurrencyPair { - if pair == nil { - return nil - } - return &fxv1.CurrencyPair{ - Base: pair.GetBase(), - Quote: pair.GetQuote(), - } -} - -func fxSideFromProto(side fxv1.Side) paymenttypes.FXSide { - switch side { - case fxv1.Side_BUY_BASE_SELL_QUOTE: - return paymenttypes.FXSideBuyBaseSellQuote - case fxv1.Side_SELL_BASE_BUY_QUOTE: - return paymenttypes.FXSideSellBaseBuyQuote - default: - return paymenttypes.FXSideUnspecified - } -} - -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 fxQuoteFromProto(quote *oraclev1.Quote) *paymenttypes.FXQuote { - if quote == nil { - return nil - } - pricedAtUnixMs := int64(0) - if ts := quote.GetPricedAt(); ts != nil { - pricedAtUnixMs = ts.AsTime().UnixMilli() - } - return &paymenttypes.FXQuote{ - QuoteRef: strings.TrimSpace(quote.GetQuoteRef()), - Pair: pairFromProto(quote.GetPair()), - Side: fxSideFromProto(quote.GetSide()), - Price: decimalFromProto(quote.GetPrice()), - BaseAmount: moneyFromProto(quote.GetBaseAmount()), - QuoteAmount: moneyFromProto(quote.GetQuoteAmount()), - ExpiresAtUnixMs: quote.GetExpiresAtUnixMs(), - PricedAtUnixMs: pricedAtUnixMs, - Provider: strings.TrimSpace(quote.GetProvider()), - RateRef: strings.TrimSpace(quote.GetRateRef()), - Firm: quote.GetFirm(), - } -} - -func fxQuoteToProto(quote *paymenttypes.FXQuote) *oraclev1.Quote { - if quote == nil { - return nil - } - var pricedAt *timestamppb.Timestamp - if quote.PricedAtUnixMs > 0 { - pricedAt = timestamppb.New(time.UnixMilli(quote.PricedAtUnixMs).UTC()) - } - return &oraclev1.Quote{ - QuoteRef: strings.TrimSpace(quote.QuoteRef), - Pair: pairToProto(quote.Pair), - Side: fxSideToProto(quote.Side), - Price: decimalToProto(quote.Price), - BaseAmount: protoMoney(quote.BaseAmount), - QuoteAmount: protoMoney(quote.QuoteAmount), - ExpiresAtUnixMs: quote.ExpiresAtUnixMs, - PricedAt: pricedAt, - Provider: strings.TrimSpace(quote.Provider), - RateRef: strings.TrimSpace(quote.RateRef), - Firm: quote.Firm, - } -} - -func decimalFromProto(value *moneyv1.Decimal) *paymenttypes.Decimal { - if value == nil { - return nil - } - return &paymenttypes.Decimal{Value: value.GetValue()} -} - -func decimalToProto(value *paymenttypes.Decimal) *moneyv1.Decimal { - if value == nil { - return nil - } - return &moneyv1.Decimal{Value: value.GetValue()} -} - -func assetFromProto(asset *chainv1.Asset) *paymenttypes.Asset { - if asset == nil { - return nil - } - return &paymenttypes.Asset{ - Chain: chainasset.NetworkAlias(asset.GetChain()), - TokenSymbol: asset.GetTokenSymbol(), - ContractAddress: asset.GetContractAddress(), - } -} - -func assetToProto(asset *paymenttypes.Asset) *chainv1.Asset { - if asset == nil { - return nil - } - return &chainv1.Asset{ - Chain: chainasset.NetworkFromString(asset.Chain), - TokenSymbol: asset.TokenSymbol, - ContractAddress: asset.ContractAddress, - } -} - -func networkFeeFromProto(resp *chainv1.EstimateTransferFeeResponse) *paymenttypes.NetworkFeeEstimate { - if resp == nil { - return nil - } - return &paymenttypes.NetworkFeeEstimate{ - NetworkFee: moneyFromProto(resp.GetNetworkFee()), - EstimationContext: strings.TrimSpace(resp.GetEstimationContext()), - } -} - -func networkFeeToProto(resp *paymenttypes.NetworkFeeEstimate) *chainv1.EstimateTransferFeeResponse { - if resp == nil { - return nil - } - return &chainv1.EstimateTransferFeeResponse{ - NetworkFee: protoMoney(resp.NetworkFee), - EstimationContext: strings.TrimSpace(resp.EstimationContext), - } -} - -func feeLinesFromProto(lines []*feesv1.DerivedPostingLine) []*paymenttypes.FeeLine { - if len(lines) == 0 { - return nil - } - result := make([]*paymenttypes.FeeLine, 0, len(lines)) - for _, line := range lines { - if line == nil { - continue - } - result = append(result, &paymenttypes.FeeLine{ - LedgerAccountRef: strings.TrimSpace(line.GetLedgerAccountRef()), - Money: moneyFromProto(line.GetMoney()), - LineType: postingLineTypeFromProto(line.GetLineType()), - Side: entrySideFromProto(line.GetSide()), - Meta: cloneMetadata(line.GetMeta()), - }) - } - if len(result) == 0 { - return nil - } - return result -} - -func feeLinesToProto(lines []*paymenttypes.FeeLine) []*feesv1.DerivedPostingLine { - if len(lines) == 0 { - return nil - } - result := make([]*feesv1.DerivedPostingLine, 0, len(lines)) - for _, line := range lines { - if line == nil { - continue - } - result = append(result, &feesv1.DerivedPostingLine{ - LedgerAccountRef: strings.TrimSpace(line.LedgerAccountRef), - Money: protoMoney(line.Money), - LineType: postingLineTypeToProto(line.LineType), - Side: entrySideToProto(line.Side), - Meta: cloneMetadata(line.Meta), - }) - } - if len(result) == 0 { - return nil - } - return result -} - -func feeRulesFromProto(rules []*feesv1.AppliedRule) []*paymenttypes.AppliedRule { - if len(rules) == 0 { - return nil - } - result := make([]*paymenttypes.AppliedRule, 0, len(rules)) - for _, rule := range rules { - if rule == nil { - continue - } - result = append(result, &paymenttypes.AppliedRule{ - RuleID: strings.TrimSpace(rule.GetRuleId()), - RuleVersion: strings.TrimSpace(rule.GetRuleVersion()), - Formula: strings.TrimSpace(rule.GetFormula()), - Rounding: roundingModeFromProto(rule.GetRounding()), - TaxCode: strings.TrimSpace(rule.GetTaxCode()), - TaxRate: strings.TrimSpace(rule.GetTaxRate()), - Parameters: cloneMetadata(rule.GetParameters()), - }) - } - if len(result) == 0 { - return nil - } - return result -} - -func feeRulesToProto(rules []*paymenttypes.AppliedRule) []*feesv1.AppliedRule { - if len(rules) == 0 { - return nil - } - result := make([]*feesv1.AppliedRule, 0, len(rules)) - for _, rule := range rules { - if rule == nil { - continue - } - result = append(result, &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: cloneMetadata(rule.Parameters), - }) - } - if len(result) == 0 { - return nil - } - return result -} - -func entrySideFromProto(side accountingv1.EntrySide) paymenttypes.EntrySide { - switch side { - case accountingv1.EntrySide_ENTRY_SIDE_DEBIT: - return paymenttypes.EntrySideDebit - case accountingv1.EntrySide_ENTRY_SIDE_CREDIT: - return paymenttypes.EntrySideCredit - default: - return paymenttypes.EntrySideUnspecified - } -} - -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 postingLineTypeFromProto(lineType accountingv1.PostingLineType) paymenttypes.PostingLineType { - switch lineType { - case accountingv1.PostingLineType_POSTING_LINE_FEE: - return paymenttypes.PostingLineTypeFee - case accountingv1.PostingLineType_POSTING_LINE_TAX: - return paymenttypes.PostingLineTypeTax - case accountingv1.PostingLineType_POSTING_LINE_SPREAD: - return paymenttypes.PostingLineTypeSpread - case accountingv1.PostingLineType_POSTING_LINE_REVERSAL: - return paymenttypes.PostingLineTypeReversal - default: - return paymenttypes.PostingLineTypeUnspecified - } -} - -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 roundingModeFromProto(mode moneyv1.RoundingMode) paymenttypes.RoundingMode { - switch mode { - case moneyv1.RoundingMode_ROUND_HALF_EVEN: - return paymenttypes.RoundingModeHalfEven - case moneyv1.RoundingMode_ROUND_HALF_UP: - return paymenttypes.RoundingModeHalfUp - case moneyv1.RoundingMode_ROUND_DOWN: - return paymenttypes.RoundingModeDown - default: - return paymenttypes.RoundingModeUnspecified - } -} - -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 - } -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/convert_card_test.go b/api/payments/orchestrator/internal/service/orchestrator/convert_card_test.go deleted file mode 100644 index 7d5c1e7c..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/convert_card_test.go +++ /dev/null @@ -1,71 +0,0 @@ -package orchestrator - -import ( - "testing" - - "github.com/tech/sendico/payments/storage/model" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" -) - -func TestEndpointFromProtoCard(t *testing.T) { - protoEndpoint := &sharedv1.PaymentEndpoint{ - Endpoint: &sharedv1.PaymentEndpoint_Card{ - Card: &sharedv1.CardEndpoint{ - Card: &sharedv1.CardEndpoint_Pan{Pan: " 411111 "}, - CardholderName: " Jane ", - CardholderSurname: " Doe ", - ExpMonth: 12, - ExpYear: 2030, - Country: " US ", - MaskedPan: " ****1111 ", - }, - }, - Metadata: map[string]string{"k": "v"}, - } - - modelEndpoint := endpointFromProto(protoEndpoint) - if modelEndpoint.Type != model.EndpointTypeCard { - t.Fatalf("expected card type, got %s", modelEndpoint.Type) - } - if modelEndpoint.Card == nil { - t.Fatalf("card payload missing") - } - if modelEndpoint.Card.Pan != "411111" || modelEndpoint.Card.Cardholder != "Jane" || modelEndpoint.Card.CardholderSurname != "Doe" || modelEndpoint.Card.Country != "US" || modelEndpoint.Card.MaskedPan != "****1111" { - t.Fatalf("card payload not trimmed as expected: %#v", modelEndpoint.Card) - } - if modelEndpoint.Metadata["k"] != "v" { - t.Fatalf("metadata not preserved") - } -} - -func TestProtoEndpointFromModelCard(t *testing.T) { - modelEndpoint := model.PaymentEndpoint{ - Type: model.EndpointTypeCard, - Card: &model.CardEndpoint{ - Token: "tok_123", - Cardholder: "Jane", - CardholderSurname: "Doe", - ExpMonth: 1, - ExpYear: 2028, - Country: "GB", - MaskedPan: "****1234", - }, - Metadata: map[string]string{"k": "v"}, - } - - protoEndpoint := protoEndpointFromModel(modelEndpoint) - card := protoEndpoint.GetCard() - if card == nil { - t.Fatalf("card payload missing in proto") - } - token, ok := card.Card.(*sharedv1.CardEndpoint_Token) - if !ok || token.Token != "tok_123" { - t.Fatalf("expected token payload, got %T %#v", card.Card, card.Card) - } - if card.GetCardholderName() != "Jane" || card.GetCardholderSurname() != "Doe" || card.GetCountry() != "GB" || card.GetMaskedPan() != "****1234" { - t.Fatalf("card details mismatch: %#v", card) - } - if protoEndpoint.GetMetadata()["k"] != "v" { - t.Fatalf("metadata not preserved in proto endpoint") - } -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/convert_types_test.go b/api/payments/orchestrator/internal/service/orchestrator/convert_types_test.go deleted file mode 100644 index 40fc62b5..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/convert_types_test.go +++ /dev/null @@ -1,124 +0,0 @@ -package orchestrator - -import ( - "testing" - "time" - - 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" - chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" - oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" - "google.golang.org/protobuf/types/known/timestamppb" -) - -func TestMoneyConversionRoundTrip(t *testing.T) { - proto := &moneyv1.Money{Currency: "USD", Amount: "12.34"} - model := moneyFromProto(proto) - if model == nil || model.Currency != "USD" || model.Amount != "12.34" { - t.Fatalf("moneyFromProto mismatch: %#v", model) - } - back := protoMoney(model) - if back == nil || back.GetCurrency() != "USD" || back.GetAmount() != "12.34" { - t.Fatalf("protoMoney mismatch: %#v", back) - } -} - -func TestFeePolicyConversionRoundTrip(t *testing.T) { - proto := &feesv1.PolicyOverrides{InsufficientNet: feesv1.InsufficientNetPolicy_SWEEP_ORG_CASH} - model := feePolicyFromProto(proto) - if model == nil || model.InsufficientNet != paymenttypes.InsufficientNetSweepOrgCash { - t.Fatalf("feePolicyFromProto mismatch: %#v", model) - } - back := feePolicyToProto(model) - if back == nil || back.GetInsufficientNet() != feesv1.InsufficientNetPolicy_SWEEP_ORG_CASH { - t.Fatalf("feePolicyToProto mismatch: %#v", back) - } -} - -func TestFeeLineConversionRoundTrip(t *testing.T) { - protoLine := &feesv1.DerivedPostingLine{ - LedgerAccountRef: "ledger:fees", - Money: &moneyv1.Money{Currency: "EUR", Amount: "1.00"}, - LineType: accountingv1.PostingLineType_POSTING_LINE_FEE, - Side: accountingv1.EntrySide_ENTRY_SIDE_DEBIT, - Meta: map[string]string{"k": "v"}, - } - modelLines := feeLinesFromProto([]*feesv1.DerivedPostingLine{protoLine}) - if len(modelLines) != 1 { - t.Fatalf("expected 1 model line, got %d", len(modelLines)) - } - modelLine := modelLines[0] - if modelLine.LedgerAccountRef != "ledger:fees" || modelLine.Money.GetCurrency() != "EUR" || modelLine.Money.GetAmount() != "1.00" { - t.Fatalf("model line mismatch: %#v", modelLine) - } - if modelLine.LineType != paymenttypes.PostingLineTypeFee || modelLine.Side != paymenttypes.EntrySideDebit { - t.Fatalf("model line enums mismatch: %#v", modelLine) - } - back := feeLinesToProto(modelLines) - if len(back) != 1 { - t.Fatalf("expected 1 proto line, got %d", len(back)) - } - protoBack := back[0] - if protoBack.GetLedgerAccountRef() != "ledger:fees" || protoBack.GetMoney().GetCurrency() != "EUR" || protoBack.GetMoney().GetAmount() != "1.00" { - t.Fatalf("proto line mismatch: %#v", protoBack) - } - if protoBack.GetLineType() != accountingv1.PostingLineType_POSTING_LINE_FEE || protoBack.GetSide() != accountingv1.EntrySide_ENTRY_SIDE_DEBIT { - t.Fatalf("proto line enums mismatch: %#v", protoBack) - } -} - -func TestFXQuoteConversionRoundTrip(t *testing.T) { - pricedAt := int64(1700000000000) - proto := &oraclev1.Quote{ - QuoteRef: "q1", - Pair: &fxv1.CurrencyPair{Base: "USD", Quote: "EUR"}, - Side: fxv1.Side_SELL_BASE_BUY_QUOTE, - Price: &moneyv1.Decimal{Value: "0.9"}, - BaseAmount: &moneyv1.Money{Currency: "USD", Amount: "100"}, - QuoteAmount: &moneyv1.Money{Currency: "EUR", Amount: "90"}, - ExpiresAtUnixMs: 1700000000000, - PricedAt: timestamppb.New(time.UnixMilli(pricedAt).UTC()), - Provider: "provider", - RateRef: "rate", - Firm: true, - } - model := fxQuoteFromProto(proto) - if model == nil || model.QuoteRef != "q1" || model.Pair.GetBase() != "USD" || model.Pair.GetQuote() != "EUR" { - t.Fatalf("fxQuoteFromProto mismatch: %#v", model) - } - if model.Side != paymenttypes.FXSideSellBaseBuyQuote || model.Price.GetValue() != "0.9" { - t.Fatalf("fxQuoteFromProto enums mismatch: %#v", model) - } - if model.PricedAtUnixMs != pricedAt { - t.Fatalf("fxQuoteFromProto priced_at mismatch: %#v", model) - } - back := fxQuoteToProto(model) - if back == nil || back.GetQuoteRef() != "q1" || back.GetPair().GetBase() != "USD" || back.GetPair().GetQuote() != "EUR" { - t.Fatalf("fxQuoteToProto mismatch: %#v", back) - } - if back.GetSide() != fxv1.Side_SELL_BASE_BUY_QUOTE || back.GetPrice().GetValue() != "0.9" { - t.Fatalf("fxQuoteToProto enums mismatch: %#v", back) - } - if got := back.GetPricedAt(); got == nil || got.AsTime().UnixMilli() != pricedAt { - t.Fatalf("fxQuoteToProto priced_at mismatch: %#v", back) - } -} - -func TestAssetConversionRoundTrip(t *testing.T) { - proto := &chainv1.Asset{ - Chain: chainv1.ChainNetwork_CHAIN_NETWORK_TRON_MAINNET, - TokenSymbol: "USDT", - ContractAddress: "0xabc", - } - model := assetFromProto(proto) - if model == nil || model.Chain != "TRON_MAINNET" || model.TokenSymbol != "USDT" || model.ContractAddress != "0xabc" { - t.Fatalf("assetFromProto mismatch: %#v", model) - } - back := assetToProto(model) - if back == nil || back.GetChain() != chainv1.ChainNetwork_CHAIN_NETWORK_TRON_MAINNET || back.GetTokenSymbol() != "USDT" || back.GetContractAddress() != "0xabc" { - t.Fatalf("assetToProto mismatch: %#v", back) - } -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/discovery_gateway_registry.go b/api/payments/orchestrator/internal/service/orchestrator/discovery_gateway_registry.go deleted file mode 100644 index 6485b4ef..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/discovery_gateway_registry.go +++ /dev/null @@ -1,212 +0,0 @@ -package orchestrator - -import ( - "context" - "sort" - "strings" - "time" - - "github.com/tech/sendico/payments/storage/model" - "github.com/tech/sendico/pkg/discovery" - "github.com/tech/sendico/pkg/mlogger" -) - -type discoveryGatewayRegistry struct { - logger mlogger.Logger - registry *discovery.Registry -} - -func NewDiscoveryGatewayRegistry(logger mlogger.Logger, registry *discovery.Registry) GatewayRegistry { - if registry == nil { - return nil - } - if logger != nil { - logger = logger.Named("discovery_gateway_registry") - } - return &discoveryGatewayRegistry{ - logger: logger, - registry: registry, - } -} - -func (r *discoveryGatewayRegistry) List(_ context.Context) ([]*model.GatewayInstanceDescriptor, error) { - if r == nil || r.registry == nil { - return nil, nil - } - entries := r.registry.List(time.Now(), true) - items := make([]*model.GatewayInstanceDescriptor, 0, len(entries)) - for _, entry := range entries { - if entry.Rail == "" { - continue - } - rail := railFromDiscovery(entry.Rail) - if rail == model.RailUnspecified { - continue - } - items = append(items, &model.GatewayInstanceDescriptor{ - ID: entry.ID, - InstanceID: entry.InstanceID, - Rail: rail, - Network: entry.Network, - InvokeURI: strings.TrimSpace(entry.InvokeURI), - Currencies: normalizeCurrencies(entry.Currencies), - Capabilities: capabilitiesFromOps(entry.Operations), - Limits: limitsFromDiscovery(entry.Limits, entry.CurrencyMeta), - Version: entry.Version, - IsEnabled: entry.Healthy, - }) - } - sort.Slice(items, func(i, j int) bool { - return model.LessGatewayDescriptor(items[i], items[j]) - }) - return items, nil -} - -func railFromDiscovery(value string) model.Rail { - switch strings.ToUpper(strings.TrimSpace(value)) { - case string(model.RailCrypto): - return model.RailCrypto - case string(model.RailProviderSettlement): - return model.RailProviderSettlement - case string(model.RailLedger): - return model.RailLedger - case string(model.RailCardPayout): - return model.RailCardPayout - case string(model.RailFiatOnRamp): - return model.RailFiatOnRamp - default: - return model.RailUnspecified - } -} - -func capabilitiesFromOps(ops []string) model.RailCapabilities { - var cap model.RailCapabilities - for _, op := range ops { - switch strings.ToLower(strings.TrimSpace(op)) { - case "payin.crypto", "payin.card", "payin.fiat": - cap.CanPayIn = true - case "payout.crypto", "payout.card", "payout.fiat": - cap.CanPayOut = true - case "balance.read": - cap.CanReadBalance = true - case "fee.send": - cap.CanSendFee = true - case "observe.confirm", "observe.confirmation": - cap.RequiresObserveConfirm = true - case "block", "funds.block", "balance.block", "ledger.block": - cap.CanBlock = true - case "release", "funds.release", "balance.release", "ledger.release": - cap.CanRelease = true - } - } - return cap -} - -func limitsFromDiscovery(src *discovery.Limits, currencies []discovery.CurrencyAnnouncement) model.Limits { - limits := model.Limits{ - VolumeLimit: map[string]string{}, - VelocityLimit: map[string]int{}, - CurrencyLimits: map[string]model.LimitsOverride{}, - } - if src != nil { - limits.MinAmount = strings.TrimSpace(src.MinAmount) - limits.MaxAmount = strings.TrimSpace(src.MaxAmount) - for key, value := range src.VolumeLimit { - k := strings.TrimSpace(key) - v := strings.TrimSpace(value) - if k == "" || v == "" { - continue - } - limits.VolumeLimit[k] = v - } - for key, value := range src.VelocityLimit { - k := strings.TrimSpace(key) - if k == "" { - continue - } - limits.VelocityLimit[k] = value - } - } - applyCurrencyTransferLimits(&limits, currencies) - if len(limits.VolumeLimit) == 0 { - limits.VolumeLimit = nil - } - if len(limits.VelocityLimit) == 0 { - limits.VelocityLimit = nil - } - if len(limits.CurrencyLimits) == 0 { - limits.CurrencyLimits = nil - } - return limits -} - -func applyCurrencyTransferLimits(dst *model.Limits, currencies []discovery.CurrencyAnnouncement) { - if dst == nil || len(currencies) == 0 { - return - } - var ( - commonMin string - commonMax string - commonMinInit bool - commonMaxInit bool - commonMinConsistent = true - commonMaxConsistent = true - ) - - for _, currency := range currencies { - code := strings.ToUpper(strings.TrimSpace(currency.Currency)) - if code == "" || currency.Limits == nil || currency.Limits.Amount == nil { - commonMinConsistent = false - commonMaxConsistent = false - continue - } - min := strings.TrimSpace(currency.Limits.Amount.Min) - max := strings.TrimSpace(currency.Limits.Amount.Max) - - if min != "" || max != "" { - override := dst.CurrencyLimits[code] - if min != "" { - override.MinAmount = min - } - if max != "" { - override.MaxAmount = max - } - if override.MinAmount != "" || override.MaxAmount != "" || override.MaxFee != "" || override.MaxOps > 0 || override.MaxVolume != "" { - dst.CurrencyLimits[code] = override - } - } - - if min == "" { - commonMinConsistent = false - } else if !commonMinInit { - commonMin = min - commonMinInit = true - } else if commonMin != min { - commonMinConsistent = false - } - - if max == "" { - commonMaxConsistent = false - } else if !commonMaxInit { - commonMax = max - commonMaxInit = true - } else if commonMax != max { - commonMaxConsistent = false - } - } - - if commonMinInit && commonMinConsistent { - dst.PerTxMinAmount = firstDiscoveryLimitValue(dst.PerTxMinAmount, commonMin) - } - if commonMaxInit && commonMaxConsistent { - dst.PerTxMaxAmount = firstDiscoveryLimitValue(dst.PerTxMaxAmount, commonMax) - } -} - -func firstDiscoveryLimitValue(primary, fallback string) string { - primary = strings.TrimSpace(primary) - if primary != "" { - return primary - } - return strings.TrimSpace(fallback) -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/discovery_gateway_registry_test.go b/api/payments/orchestrator/internal/service/orchestrator/discovery_gateway_registry_test.go deleted file mode 100644 index d5a15ef1..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/discovery_gateway_registry_test.go +++ /dev/null @@ -1,62 +0,0 @@ -package orchestrator - -import ( - "testing" - - "github.com/tech/sendico/pkg/discovery" -) - -func TestLimitsFromDiscovery_MapsPerTxMinimumFromCurrencyMeta(t *testing.T) { - limits := limitsFromDiscovery(nil, []discovery.CurrencyAnnouncement{ - { - Currency: "RUB", - Limits: &discovery.CurrencyLimits{ - Amount: &discovery.CurrencyAmount{ - Min: "100.00", - Max: "10000.00", - }, - }, - }, - }) - - if limits.PerTxMinAmount != "100.00" { - t.Fatalf("expected per tx min 100.00, got %q", limits.PerTxMinAmount) - } - if limits.PerTxMaxAmount != "10000.00" { - t.Fatalf("expected per tx max 10000.00, got %q", limits.PerTxMaxAmount) - } - override, ok := limits.CurrencyLimits["RUB"] - if !ok { - t.Fatalf("expected RUB currency override") - } - if override.MinAmount != "100.00" { - t.Fatalf("expected RUB min override 100.00, got %q", override.MinAmount) - } -} - -func TestLimitsFromDiscovery_DropsCommonPerTxMinimumWhenCurrenciesDiffer(t *testing.T) { - limits := limitsFromDiscovery(nil, []discovery.CurrencyAnnouncement{ - { - Currency: "USD", - Limits: &discovery.CurrencyLimits{ - Amount: &discovery.CurrencyAmount{Min: "10.00"}, - }, - }, - { - Currency: "EUR", - Limits: &discovery.CurrencyLimits{ - Amount: &discovery.CurrencyAmount{Min: "20.00"}, - }, - }, - }) - - if limits.PerTxMinAmount != "" { - t.Fatalf("expected empty common per tx min, got %q", limits.PerTxMinAmount) - } - if limits.CurrencyLimits["USD"].MinAmount != "10.00" { - t.Fatalf("expected USD min override 10.00, got %q", limits.CurrencyLimits["USD"].MinAmount) - } - if limits.CurrencyLimits["EUR"].MinAmount != "20.00" { - t.Fatalf("expected EUR min override 20.00, got %q", limits.CurrencyLimits["EUR"].MinAmount) - } -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/execution_compat.go b/api/payments/orchestrator/internal/service/orchestrator/execution_compat.go deleted file mode 100644 index c9d312e5..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/execution_compat.go +++ /dev/null @@ -1,103 +0,0 @@ -package orchestrator - -import ( - "github.com/tech/sendico/payments/orchestrator/internal/service/execution" - "github.com/tech/sendico/payments/storage/model" - "github.com/tech/sendico/pkg/mlogger" - "github.com/tech/sendico/pkg/model/account_role" - chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" -) - -type Liveness = execution.Liveness - -const ( - StepFinal Liveness = execution.StepFinal - StepRunnable Liveness = execution.StepRunnable - StepBlocked Liveness = execution.StepBlocked - StepDead Liveness = execution.StepDead - - executionStepRoleSource = execution.ExecutionStepRoleSource - executionStepRoleConsumer = execution.ExecutionStepRoleConsumer -) - -func setExecutionStepRole(step *model.ExecutionStep, role string) { - execution.SetExecutionStepRole(step, role) -} - -func setExecutionStepStatus(step *model.ExecutionStep, state model.OperationState) { - execution.SetExecutionStepStatus(step, state) -} - -func findExecutionStepByTransferRef(plan *model.ExecutionPlan, transferRef string) *model.ExecutionStep { - return execution.FindExecutionStepByTransferRef(plan, transferRef) -} - -func updateExecutionStepFromTransfer(plan *model.ExecutionPlan, event *chainv1.TransferStatusChangedEvent) *model.ExecutionStep { - return execution.UpdateExecutionStepFromTransfer(plan, event) -} - -func ensureExecutionRefs(payment *model.Payment) *model.ExecutionRefs { - return execution.EnsureExecutionRefs(payment) -} - -func executionQuote(payment *model.Payment, quote *sharedv1.PaymentQuote) *sharedv1.PaymentQuote { - return execution.ExecutionQuote(payment, quote, modelQuoteToProto) -} - -func ensureExecutionPlanForPlan(payment *model.Payment, plan *model.PaymentPlan) *model.ExecutionPlan { - return execution.EnsureExecutionPlanForPlan(payment, plan) -} - -func executionPlanComplete(plan *model.ExecutionPlan) bool { - return execution.ExecutionPlanComplete(plan) -} - -func blockStepConfirmed(plan *model.PaymentPlan, execPlan *model.ExecutionPlan) bool { - return execution.BlockStepConfirmed(plan, execPlan) -} - -func roleHintsForStep(plan *model.PaymentPlan, idx int) (*account_role.AccountRole, *account_role.AccountRole) { - return execution.RoleHintsForStep(plan, idx) -} - -func linkRailObservation(payment *model.Payment, rail model.Rail, referenceID, dependsOn string) { - execution.LinkRailObservation(payment, rail, referenceID, dependsOn) -} - -func planStepID(step *model.PaymentStep, idx int) string { - return execution.PlanStepID(step, idx) -} - -func describePlanStep(step *model.PaymentStep) string { - return execution.DescribePlanStep(step) -} - -func planStepIdempotencyKey(payment *model.Payment, idx int, step *model.PaymentStep) string { - return execution.PlanStepIdempotencyKey(payment, idx, step) -} - -func executionStepsByCode(plan *model.ExecutionPlan) map[string]*model.ExecutionStep { - return execution.ExecutionStepsByCode(plan) -} - -func planStepsByID(plan *model.PaymentPlan) map[string]*model.PaymentStep { - return execution.PlanStepsByID(plan) -} - -func stepDependenciesReady( - step *model.PaymentStep, - execSteps map[string]*model.ExecutionStep, - planSteps map[string]*model.PaymentStep, - requireSuccess bool, -) (ready bool, waiting bool, blocked bool, err error) { - return execution.StepDependenciesReady(step, execSteps, planSteps, requireSuccess) -} - -func cardPayoutDependenciesConfirmed(plan *model.PaymentPlan, execPlan *model.ExecutionPlan) bool { - return execution.CardPayoutDependenciesConfirmed(plan, execPlan) -} - -func analyzeExecutionPlan(logger mlogger.Logger, payment *model.Payment) (bool, bool, error) { - return execution.AnalyzeExecutionPlan(logger, payment) -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/gateway_execution_consumer.go b/api/payments/orchestrator/internal/service/orchestrator/gateway_execution_consumer.go deleted file mode 100644 index a28b0e93..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/gateway_execution_consumer.go +++ /dev/null @@ -1,295 +0,0 @@ -package orchestrator - -import ( - "context" - "fmt" - "strings" - - paymodel "github.com/tech/sendico/payments/storage/model" - "github.com/tech/sendico/pkg/merrors" - cons "github.com/tech/sendico/pkg/messaging/consumer" - paymentgateway "github.com/tech/sendico/pkg/messaging/notifications/paymentgateway" - np "github.com/tech/sendico/pkg/messaging/notifications/processor" - "github.com/tech/sendico/pkg/mlogger" - "github.com/tech/sendico/pkg/model" - "github.com/tech/sendico/pkg/payments/rail" - "go.uber.org/zap" -) - -func (s *Service) startGatewayConsumers() { - if s == nil || s.gatewayBroker == nil { - s.logger.Warn("Missing broker. Gateway feedback consumer has NOT started") - return - } - s.logger.Info("Gateway feedback consumer started") - processor := paymentgateway.NewPaymentGatewayExecutionProcessor(s.logger, s.onGatewayExecution) - s.consumeGatewayProcessor(processor) -} - -func (s *Service) consumeGatewayProcessor(processor np.EnvelopeProcessor) { - consumer, err := cons.NewConsumer(s.logger, s.gatewayBroker, processor.GetSubject()) - if err != nil { - s.logger.Warn("Failed to create payment gateway consumer", zap.Error(err), zap.String("event", processor.GetSubject().ToString())) - return - } - s.gatewayConsumers = append(s.gatewayConsumers, consumer) - go func() { - if err := consumer.ConsumeMessages(processor.Process); err != nil { - s.logger.Warn("Payment gateway consumer stopped", zap.Error(err), zap.String("event", processor.GetSubject().ToString())) - } - }() -} - -func executionPlanSucceeded(plan *paymodel.ExecutionPlan) bool { - for _, s := range plan.Steps { - if !s.IsTerminal() { - return false - } - if s.State != paymodel.OperationStateSuccess { - return false - } - } - return true -} - -func executionPlanFailed(plan *paymodel.ExecutionPlan) bool { - hasFailed := false - - for _, s := range plan.Steps { - if !s.IsTerminal() { - return false - } - if s.State == paymodel.OperationStateFailed { - hasFailed = true - } - } - - return hasFailed -} - -func (s *Service) onGatewayExecution(ctx context.Context, exec *model.PaymentGatewayExecution) error { - if exec == nil { - return merrors.InvalidArgument("payment gateway execution is nil", "execution") - } - - paymentRef := strings.TrimSpace(exec.PaymentRef) - if paymentRef == "" { - return merrors.InvalidArgument("payment_ref is required", "payment_ref") - } - - store := s.storage.Payments() - - payment, err := store.GetByPaymentRef(ctx, paymentRef) - if err != nil { - s.logger.Warn("Failed to fetch payment from database", zap.Error(err)) - return err - } - - // --- metadata - if payment.Metadata == nil { - payment.Metadata = map[string]string{} - } - payment.Metadata["gateway_operation_result"] = string(exec.Status) - payment.Metadata["gateway_operation_ref"] = exec.OperationRef - payment.Metadata["gateway_request_idempotency"] = exec.IdempotencyKey - - // --- update exactly ONE step - - if payment.State, err = updateExecutionStepsFromGatewayExecution(s.logger, payment, exec); err != nil { - s.logger.Warn("No execution step matched gateway result", - zap.String("payment_ref", paymentRef), - zap.String("operation_ref", exec.OperationRef), - zap.String("idempotency", exec.IdempotencyKey), - ) - } - - if err := store.Update(ctx, payment); err != nil { - return err - } - - // reload unified state - payment, err = store.GetByPaymentRef(ctx, paymentRef) - if err != nil { - return err - } - - // --- if plan can continue — continue - if payment.ExecutionPlan != nil && !executionPlanComplete(payment.ExecutionPlan) { - return s.resumePaymentPlan(ctx, store, payment) - } - - // --- plan is terminal: decide payment fate by aggregation - if payment.ExecutionPlan != nil && executionPlanComplete(payment.ExecutionPlan) { - switch { - case executionPlanSucceeded(payment.ExecutionPlan): - payment.State = paymodel.PaymentStateSettled - - case executionPlanFailed(payment.ExecutionPlan): - payment.State = paymodel.PaymentStateFailed - payment.FailureReason = "execution_plan_failed" - } - - return store.Update(ctx, payment) - } - - return nil -} - -func updateExecutionStepsFromGatewayExecution( - logger mlogger.Logger, - payment *paymodel.Payment, - exec *model.PaymentGatewayExecution, -) (paymodel.PaymentState, error) { - - log := logger.With( - zap.String("payment_ref", payment.PaymentRef), - zap.String("operation_ref", strings.TrimSpace(exec.OperationRef)), - zap.String("gateway_status", string(exec.Status)), - ) - - log.Debug("Gateway execution received") - - if payment == nil || payment.PaymentPlan == nil || exec == nil { - log.Warn("Invalid input: payment/plan/exec is nil") - return paymodel.PaymentStateSubmitted, - merrors.DataConflict("payment is missing plan or execution step") - } - - operationRef := strings.TrimSpace(exec.OperationRef) - if operationRef == "" { - log.Warn("Empty operation_ref from gateway") - return paymodel.PaymentStateSubmitted, - merrors.InvalidArgument("no operation reference provided") - } - - execPlan := ensureExecutionPlanForPlan(payment, payment.PaymentPlan) - if execPlan == nil { - log.Warn("Execution plan missing") - return paymodel.PaymentStateSubmitted, merrors.InvalidArgument("execution plan missing") - } - - status := executionStepStatusFromGatewayStatus(exec.Status) - if status == "" { - log.Warn("Unknown gateway status") - return paymodel.PaymentStateSubmitted, - merrors.DataConflict(fmt.Sprintf("unknown gateway status: %s", exec.Status)) - } - - var matched bool - - for idx, execStep := range execPlan.Steps { - if execStep == nil { - continue - } - - if strings.EqualFold(strings.TrimSpace(execStep.OperationRef), operationRef) { - - log.Debug("Execution step matched", - zap.Int("step_index", idx), - zap.String("step_code", execStep.Code), - zap.String("prev_state", string(execStep.State)), - ) - - if execStep.TransferRef == "" && exec.TransferRef != "" { - execStep.TransferRef = strings.TrimSpace(exec.TransferRef) - log.Debug("Transfer_ref attached to step", zap.String("transfer_ref", execStep.TransferRef)) - } - - setExecutionStepStatus(execStep, status) - if exec.Error != "" && execStep.Error == "" { - execStep.Error = strings.TrimSpace(exec.Error) - } - - log.Debug("Execution step state updated", - zap.Int("step_index", idx), - zap.String("step_code", execStep.Code), - zap.String("new_state", string(execStep.State)), - ) - - matched = true - break - } - } - - if !matched { - log.Warn("No execution step found for operation_ref") - return paymodel.PaymentStateSubmitted, - merrors.InvalidArgument( - fmt.Sprintf("execution step not found for operation reference: %s", operationRef), - ) - } - - // -------- GLOBAL REDUCTION -------- - - var ( - hasSuccess bool - allDone = true - ) - - for idx, step := range execPlan.Steps { - if step == nil { - continue - } - - log.Debug("Evaluating step for payment state", - zap.Int("step_index", idx), - zap.String("step_code", step.Code), - zap.String("step_state", string(step.State)), - ) - - switch step.State { - case paymodel.OperationStateFailed: - payment.FailureReason = step.Error - log.Info("Payment marked as FAILED due to step failure", - zap.String("failed_step_code", step.Code), - zap.String("error", step.Error), - ) - return paymodel.PaymentStateFailed, nil - - case paymodel.OperationStateSuccess: - hasSuccess = true - - case paymodel.OperationStateSkipped: - // ok - - default: - allDone = false - } - } - - if hasSuccess && allDone { - log.Info("Payment marked as SUCCESS (all steps completed)") - return paymodel.PaymentStateSuccess, nil - } - - log.Info("Payment still PROCESSING (steps not finished)") - return paymodel.PaymentStateSubmitted, nil -} - -func executionStepStatusFromGatewayStatus(status rail.OperationResult) paymodel.OperationState { - switch status { - - case rail.OperationResultSuccess: - return paymodel.OperationStateSuccess - - case rail.OperationResultFailed: - return paymodel.OperationStateFailed - - case rail.OperationResultCancelled: - return paymodel.OperationStateCancelled - - default: - return paymodel.OperationStateFailed - } -} - -func (s *Service) Shutdown() { - if s == nil { - return - } - for _, consumer := range s.gatewayConsumers { - if consumer != nil { - consumer.Close() - } - } -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/gateway_execution_consumer_test.go b/api/payments/orchestrator/internal/service/orchestrator/gateway_execution_consumer_test.go deleted file mode 100644 index 79c429f6..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/gateway_execution_consumer_test.go +++ /dev/null @@ -1,105 +0,0 @@ -package orchestrator - -import ( - "context" - "testing" - - paymodel "github.com/tech/sendico/payments/storage/model" - mloggerfactory "github.com/tech/sendico/pkg/mlogger/factory" - "github.com/tech/sendico/pkg/model" - "github.com/tech/sendico/pkg/payments/rail" -) - -func TestGatewayExecutionSuccessUpdatesMetadataOnly(t *testing.T) { - logger := mloggerfactory.NewLogger(false) - store := newHelperPaymentStore() - - payment := &paymodel.Payment{ - PaymentRef: "pi-1", - State: paymodel.PaymentStateSubmitted, - } - if err := store.Create(context.Background(), payment); err != nil { - t.Fatalf("failed to seed payment: %v", err) - } - - svc := &Service{ - logger: logger, - storage: stubRepo{payments: store}, - } - - exec := &model.PaymentGatewayExecution{ - PaymentRef: "pi-1", - Status: rail.OperationResultSuccess, - IdempotencyKey: "idem-1", - OperationRef: "oper-1", - } - - if err := svc.onGatewayExecution(context.Background(), exec); err != nil { - t.Fatalf("onGatewayExecution error: %v", err) - } - - updated, _ := store.GetByPaymentRef(context.Background(), "pi-1") - - // Should not be Settled without execution plan - if updated.State != paymodel.PaymentStateSubmitted { - t.Fatalf("expected payment to remain submitted, got %s", updated.State) - } - - if updated.Metadata["gateway_request_idempotency"] != "idem-1" { - t.Fatalf("expected gateway_request_idempotency metadata") - } - - if updated.Metadata["gateway_operation_result"] != string(rail.OperationResultSuccess) { - t.Fatalf("expected gateway_operation_result metadata") - } -} - -func TestGatewayExecutionRejectedFailsPayment(t *testing.T) { - logger := mloggerfactory.NewLogger(false) - store := newHelperPaymentStore() - - payment := &paymodel.Payment{ - PaymentRef: "pi-2", State: paymodel.PaymentStateSubmitted, IdempotencyKey: "idem-1", - PaymentPlan: &paymodel.PaymentPlan{ - Steps: []*paymodel.PaymentStep{ - {StepID: "crypto_send"}, - }, - }, - ExecutionPlan: &paymodel.ExecutionPlan{ - Steps: []*paymodel.ExecutionStep{ - {Code: "crypto_send", OperationRef: "s1", State: paymodel.OperationStateWaiting, TransferRef: "trn-1"}, - }, - }, - } - - if err := store.Create(context.Background(), payment); err != nil { - t.Fatalf("failed to seed payment: %v", err) - } - - svc := &Service{ - logger: logger, - storage: stubRepo{payments: store}, - } - - exec := &model.PaymentGatewayExecution{ - PaymentRef: "pi-2", - OperationRef: "s1", - TransferRef: "trn-1", - Status: rail.OperationResultFailed, - Error: "execution_plan_failed", - } - - if err := svc.onGatewayExecution(context.Background(), exec); err != nil { - t.Fatalf("onGatewayExecution error: %v", err) - } - - updated, _ := store.GetByPaymentRef(context.Background(), "pi-2") - - if updated.State != paymodel.PaymentStateFailed { - t.Fatalf("expected payment failed, got %s", updated.State) - } - - if updated.FailureReason != "execution_plan_failed" { - t.Fatalf("expected failure reason execution_plan_failed, got %q", updated.FailureReason) - } -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/gateway_registry.go b/api/payments/orchestrator/internal/service/orchestrator/gateway_registry.go deleted file mode 100644 index 19b60785..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/gateway_registry.go +++ /dev/null @@ -1,117 +0,0 @@ -package orchestrator - -import ( - "context" - "sort" - "strings" - - "github.com/tech/sendico/payments/storage/model" - "github.com/tech/sendico/pkg/mlogger" -) - -type gatewayRegistry struct { - logger mlogger.Logger - static []*model.GatewayInstanceDescriptor -} - -// NewGatewayRegistry aggregates static gateway descriptors. -func NewGatewayRegistry(logger mlogger.Logger, static []*model.GatewayInstanceDescriptor) GatewayRegistry { - if len(static) == 0 { - return nil - } - if logger != nil { - logger = logger.Named("gateway_registry") - } - return &gatewayRegistry{ - logger: logger, - static: cloneGatewayDescriptors(static), - } -} - -func (r *gatewayRegistry) List(ctx context.Context) ([]*model.GatewayInstanceDescriptor, error) { - items := map[string]*model.GatewayInstanceDescriptor{} - for _, gw := range r.static { - key := model.GatewayDescriptorIdentityKey(gw) - if key == "" { - continue - } - items[key] = cloneGatewayDescriptor(gw) - } - - result := make([]*model.GatewayInstanceDescriptor, 0, len(items)) - for _, gw := range items { - result = append(result, gw) - } - sort.Slice(result, func(i, j int) bool { - return model.LessGatewayDescriptor(result[i], result[j]) - }) - return result, nil -} - -func normalizeCurrencies(values []string) []string { - if len(values) == 0 { - return nil - } - seen := map[string]bool{} - result := make([]string, 0, len(values)) - for _, value := range values { - clean := strings.ToUpper(strings.TrimSpace(value)) - if clean == "" || seen[clean] { - continue - } - seen[clean] = true - result = append(result, clean) - } - return result -} - -func cloneGatewayDescriptors(src []*model.GatewayInstanceDescriptor) []*model.GatewayInstanceDescriptor { - if len(src) == 0 { - return nil - } - result := make([]*model.GatewayInstanceDescriptor, 0, len(src)) - for _, item := range src { - if item == nil { - continue - } - if cloned := cloneGatewayDescriptor(item); cloned != nil { - result = append(result, cloned) - } - } - return result -} - -func cloneGatewayDescriptor(src *model.GatewayInstanceDescriptor) *model.GatewayInstanceDescriptor { - if src == nil { - return nil - } - dst := *src - if src.Currencies != nil { - dst.Currencies = append([]string(nil), src.Currencies...) - } - dst.Limits = cloneLimits(src.Limits) - return &dst -} - -func cloneLimits(src model.Limits) model.Limits { - dst := src - if src.VolumeLimit != nil { - dst.VolumeLimit = map[string]string{} - for key, value := range src.VolumeLimit { - dst.VolumeLimit[key] = value - } - } - if src.VelocityLimit != nil { - dst.VelocityLimit = map[string]int{} - for key, value := range src.VelocityLimit { - dst.VelocityLimit[key] = value - } - } - if src.CurrencyLimits != nil { - dst.CurrencyLimits = map[string]model.LimitsOverride{} - for key, value := range src.CurrencyLimits { - dst.CurrencyLimits[key] = value - } - } - return dst -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/gateway_registry_identity_test.go b/api/payments/orchestrator/internal/service/orchestrator/gateway_registry_identity_test.go deleted file mode 100644 index adf3ad86..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/gateway_registry_identity_test.go +++ /dev/null @@ -1,72 +0,0 @@ -package orchestrator - -import ( - "context" - "testing" - - "github.com/tech/sendico/payments/storage/model" -) - -type identityGatewayRegistryStub struct { - items []*model.GatewayInstanceDescriptor -} - -func (s identityGatewayRegistryStub) List(context.Context) ([]*model.GatewayInstanceDescriptor, error) { - return s.items, nil -} - -func TestGatewayRegistry_ListKeepsDistinctInstancesPerGatewayID(t *testing.T) { - registry := NewGatewayRegistry(nil, []*model.GatewayInstanceDescriptor{ - {ID: "crypto_rail_gateway_tron", InstanceID: "inst-b", InvokeURI: "grpc://b"}, - {ID: "crypto_rail_gateway_tron", InstanceID: "inst-a", InvokeURI: "grpc://a"}, - {ID: "crypto_rail_gateway_tron", InstanceID: "inst-a", InvokeURI: "grpc://a-new"}, - }) - if registry == nil { - t.Fatalf("expected registry to be created") - } - - items, err := registry.List(context.Background()) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got, want := len(items), 2; got != want { - t.Fatalf("unexpected items count: got=%d want=%d", got, want) - } - if got, want := items[0].InstanceID, "inst-a"; got != want { - t.Fatalf("unexpected first instance id: got=%q want=%q", got, want) - } - if got, want := items[0].InvokeURI, "grpc://a-new"; got != want { - t.Fatalf("expected latest duplicate to win for same gateway+instance: got=%q want=%q", got, want) - } - if got, want := items[1].InstanceID, "inst-b"; got != want { - t.Fatalf("unexpected second instance id: got=%q want=%q", got, want) - } -} - -func TestCompositeGatewayRegistry_ListKeepsDistinctInstancesPerGatewayID(t *testing.T) { - registry := NewCompositeGatewayRegistry(nil, - identityGatewayRegistryStub{items: []*model.GatewayInstanceDescriptor{ - {ID: "crypto_rail_gateway_tron", InstanceID: "inst-b", InvokeURI: "grpc://b"}, - }}, - identityGatewayRegistryStub{items: []*model.GatewayInstanceDescriptor{ - {ID: "crypto_rail_gateway_tron", InstanceID: "inst-a", InvokeURI: "grpc://a"}, - }}, - ) - if registry == nil { - t.Fatalf("expected registry to be created") - } - - items, err := registry.List(context.Background()) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got, want := len(items), 2; got != want { - t.Fatalf("unexpected items count: got=%d want=%d", got, want) - } - if got, want := items[0].InstanceID, "inst-a"; got != want { - t.Fatalf("unexpected first instance id: got=%q want=%q", got, want) - } - if got, want := items[1].InstanceID, "inst-b"; got != want { - t.Fatalf("unexpected second instance id: got=%q want=%q", got, want) - } -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/gateway_resolution.go b/api/payments/orchestrator/internal/service/orchestrator/gateway_resolution.go deleted file mode 100644 index b92d92b7..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/gateway_resolution.go +++ /dev/null @@ -1,142 +0,0 @@ -package orchestrator - -import ( - "context" - "sort" - "strings" - - "github.com/shopspring/decimal" - chainclient "github.com/tech/sendico/gateway/chain/client" - "github.com/tech/sendico/payments/storage/model" - "github.com/tech/sendico/pkg/merrors" - paymenttypes "github.com/tech/sendico/pkg/payments/types" - "go.uber.org/zap" -) - -func (s *Service) resolveChainGatewayClient(ctx context.Context, network string, amount *paymenttypes.Money, actions []model.RailOperation, instanceID string, paymentRef string) (chainclient.Client, *model.GatewayInstanceDescriptor, error) { - if s.deps.gatewayRegistry != nil && s.deps.gatewayInvokeResolver != nil { - entry, err := selectGatewayForActions(ctx, s.deps.gatewayRegistry, model.RailCrypto, network, amount, actions, instanceID, sendDirectionForRail(model.RailCrypto)) - if err != nil { - return nil, nil, err - } - invokeURI := strings.TrimSpace(entry.InvokeURI) - if invokeURI == "" { - return nil, nil, merrors.InvalidArgument("chain gateway: invoke uri is required") - } - client, err := s.deps.gatewayInvokeResolver.Resolve(ctx, invokeURI) - if err != nil { - return nil, nil, err - } - if s.logger != nil { - fields := []zap.Field{ - zap.String("gateway_id", entry.ID), - zap.String("instance_id", entry.InstanceID), - zap.String("rail", string(entry.Rail)), - zap.String("network", entry.Network), - zap.String("invoke_uri", invokeURI), - } - if paymentRef != "" { - fields = append(fields, zap.String("payment_ref", paymentRef)) - } - if len(actions) > 0 { - fields = append(fields, zap.Strings("actions", railActionNames(actions))) - } - s.logger.Info("Chain gateway selected", fields...) - } - return client, entry, nil - } - if s.deps.gateway.resolver != nil { - client, err := s.deps.gateway.resolver.Resolve(ctx, network) - if err != nil { - return nil, nil, err - } - return client, nil, nil - } - return nil, nil, merrors.NoData("chain gateway unavailable") -} - -func selectGatewayForActions(ctx context.Context, registry GatewayRegistry, rail model.Rail, network string, amount *paymenttypes.Money, actions []model.RailOperation, instanceID string, dir sendDirection) (*model.GatewayInstanceDescriptor, error) { - if registry == nil { - return nil, merrors.NoData("gateway registry unavailable") - } - all, err := registry.List(ctx) - if err != nil { - return nil, err - } - if len(all) == 0 { - return nil, merrors.NoData("no gateway instances available") - } - if len(actions) == 0 { - actions = []model.RailOperation{model.RailOperationSend} - } - - currency := "" - amt := decimal.Zero - if amount != nil && strings.TrimSpace(amount.GetAmount()) != "" { - amt, err = decimalFromMoney(amount) - if err != nil { - return nil, err - } - currency = strings.ToUpper(strings.TrimSpace(amount.GetCurrency())) - } - network = strings.ToUpper(strings.TrimSpace(network)) - - eligible := make([]*model.GatewayInstanceDescriptor, 0) - var lastErr error - for _, entry := range all { - if entry == nil || !entry.IsEnabled { - continue - } - if entry.Rail != rail { - continue - } - ok := true - for _, action := range actions { - if err := isGatewayEligible(entry, rail, network, currency, action, dir, amt); err != nil { - lastErr = err - ok = false - break - } - } - if !ok { - continue - } - eligible = append(eligible, entry) - } - - if len(eligible) == 0 { - if lastErr != nil { - return nil, merrors.NoData("no eligible gateway instance found: " + lastErr.Error()) - } - return nil, merrors.NoData("no eligible gateway instance found") - } - sort.Slice(eligible, func(i, j int) bool { - return eligible[i].ID < eligible[j].ID - }) - if instanceID != "" { - for _, entry := range eligible { - if strings.EqualFold(strings.TrimSpace(entry.InstanceID), strings.TrimSpace(instanceID)) { - return entry, nil - } - } - } - return eligible[0], nil -} - -func railActionNames(actions []model.RailOperation) []string { - if len(actions) == 0 { - return nil - } - names := make([]string, 0, len(actions)) - for _, action := range actions { - name := strings.TrimSpace(string(action)) - if name == "" { - continue - } - names = append(names, name) - } - if len(names) == 0 { - return nil - } - return names -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/handlers_commands.go b/api/payments/orchestrator/internal/service/orchestrator/handlers_commands.go deleted file mode 100644 index f172b03e..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/handlers_commands.go +++ /dev/null @@ -1,405 +0,0 @@ -package orchestrator - -import ( - "context" - "errors" - "strings" - - "github.com/google/uuid" - "github.com/tech/sendico/payments/orchestrator/internal/service/shared" - "github.com/tech/sendico/payments/storage" - "github.com/tech/sendico/payments/storage/model" - quotestorage "github.com/tech/sendico/payments/storage/quote" - "github.com/tech/sendico/pkg/api/routers/gsresponse" - "github.com/tech/sendico/pkg/merrors" - "github.com/tech/sendico/pkg/mlogger" - "github.com/tech/sendico/pkg/mservice" - "github.com/tech/sendico/pkg/mutil/mzap" - orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestration/v1" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" - "go.uber.org/zap" -) - -type initiatePaymentsCommand struct { - engine paymentEngine - logger mlogger.Logger -} - -func (h *initiatePaymentsCommand) Execute(ctx context.Context, req *orchestratorv1.InitiatePaymentsRequest) gsresponse.Responder[orchestratorv1.InitiatePaymentsResponse] { - if err := h.engine.EnsureRepository(ctx); err != nil { - return gsresponse.Unavailable[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) - } - if req == nil { - return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request")) - } - _, orgRef, err := validateMetaAndOrgRef(req.GetMeta()) - if err != nil { - return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) - } - idempotencyKey, err := requireIdempotencyKey(req.GetIdempotencyKey()) - if err != nil { - return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) - } - quoteRef := strings.TrimSpace(req.GetQuoteRef()) - if quoteRef == "" { - return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("quote_ref is required")) - } - - quotesStore, err := ensureQuotesStore(h.engine.Repository()) - if err != nil { - return gsresponse.Unavailable[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) - } - record, err := quotesStore.GetByRef(ctx, orgRef, quoteRef) - if err != nil { - if errors.Is(err, quotestorage.ErrQuoteNotFound) { - return gsresponse.FailedPrecondition[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, "quote_not_found", merrors.InvalidArgument("quote_ref not found or expired")) - } - return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) - } - if note := strings.TrimSpace(record.ExecutionNote); note != "" { - return gsresponse.FailedPrecondition[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, "quote_not_executable", merrors.InvalidArgument(note)) - } - - intents := record.Intents - quotes := record.Quotes - plans := record.Plans - if len(intents) == 0 && record.Intent.Kind != "" && record.Intent.Kind != model.PaymentKindUnspecified { - intents = []model.PaymentIntent{record.Intent} - } - if len(quotes) == 0 && record.Quote != nil { - quotes = []*model.PaymentQuoteSnapshot{record.Quote} - } - if len(plans) == 0 && record.Plan != nil { - plans = []*model.PaymentPlan{record.Plan} - } - if len(intents) == 0 || len(quotes) == 0 || len(intents) != len(quotes) { - return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("stored quote payload is incomplete")) - } - if len(plans) > 0 && len(plans) != len(intents) { - return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("stored payment plans are incomplete")) - } - - store, err := ensurePaymentsStore(h.engine.Repository()) - if err != nil { - return gsresponse.Unavailable[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) - } - - payments := make([]*sharedv1.Payment, 0, len(intents)) - for i := range intents { - intentProto := protoIntentFromModel(intents[i]) - if err := requireNonNilIntent(intentProto); err != nil { - return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) - } - quoteProto := modelQuoteToProto(quotes[i]) - if quoteProto == nil { - return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("stored quote is empty")) - } - quoteProto.QuoteRef = quoteRef - - perKey := shared.PerIntentIdempotencyKey(idempotencyKey, i, len(intents)) - if existing, err := getPaymentByIdempotencyKey(ctx, store, orgRef, perKey); err == nil && existing != nil { - payments = append(payments, toProtoPayment(existing)) - continue - } else if err != nil && !errors.Is(err, storage.ErrPaymentNotFound) { - return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) - } - - entity := newPayment(orgRef, intentProto, perKey, req.GetMetadata(), quoteProto) - var plan *model.PaymentPlan - if i < len(plans) { - plan = plans[i] - } - if plan == nil { - return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("stored payment plans are incomplete")) - } - attachStoredPlan(entity, plan, perKey) - if err = store.Create(ctx, entity); err != nil { - if errors.Is(err, storage.ErrDuplicatePayment) { - return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.DataConflict("payment already exists")) - } - return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) - } - - if err := h.engine.ExecutePayment(ctx, store, entity, quoteProto); err != nil { - return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) - } - - payments = append(payments, toProtoPayment(entity)) - } - - h.logger.Info( - "Payments initiated", - mzap.ObjRef("org_ref", orgRef), - zap.String("quote_ref", quoteRef), - zap.String("idempotency_key", idempotencyKey), - zap.Int("payment_count", len(payments)), - ) - return gsresponse.Success(&orchestratorv1.InitiatePaymentsResponse{Payments: payments}) -} - -type initiatePaymentCommand struct { - engine paymentEngine - logger mlogger.Logger -} - -func (h *initiatePaymentCommand) Execute(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) gsresponse.Responder[orchestratorv1.InitiatePaymentResponse] { - if err := h.engine.EnsureRepository(ctx); err != nil { - return gsresponse.Unavailable[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err) - } - if req == nil { - return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request")) - } - orgRef, orgID, err := validateMetaAndOrgRef(req.GetMeta()) - if err != nil { - return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err) - } - intent := req.GetIntent() - quoteRef := strings.TrimSpace(req.GetQuoteRef()) - hasIntent := intent != nil - hasQuote := quoteRef != "" - switch { - case !hasIntent && !hasQuote: - return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("intent or quote_ref is required")) - case hasIntent && hasQuote: - return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("intent and quote_ref are mutually exclusive")) - } - if hasIntent { - if err := requireNonNilIntent(intent); err != nil { - return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err) - } - } - idempotencyKey, err := requireIdempotencyKey(req.GetIdempotencyKey()) - if err != nil { - return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err) - } - h.logger.Debug( - "Initiate payment request accepted", - mzap.ObjRef("org_ref", orgID), - zap.String("idempotency_key", idempotencyKey), - zap.String("quote_ref", quoteRef), - zap.Bool("has_intent", hasIntent), - ) - - store, err := ensurePaymentsStore(h.engine.Repository()) - if err != nil { - return gsresponse.Unavailable[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err) - } - - if existing, err := getPaymentByIdempotencyKey(ctx, store, orgID, idempotencyKey); err == nil && existing != nil { - h.logger.Debug( - "idempotent payment request reused", - zap.String("payment_ref", existing.PaymentRef), - mzap.ObjRef("org_ref", orgID), - zap.String("idempotency_key", idempotencyKey), - zap.String("quote_ref", quoteRef), - ) - return gsresponse.Success(&orchestratorv1.InitiatePaymentResponse{Payment: toProtoPayment(existing)}) - } else if err != nil && !errors.Is(err, storage.ErrPaymentNotFound) { - return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err) - } - - quoteSnapshot, resolvedIntent, plan, err := h.engine.ResolvePaymentQuote(ctx, quoteResolutionInput{ - OrgRef: orgRef, - OrgID: orgID, - Meta: req.GetMeta(), - Intent: intent, - QuoteRef: quoteRef, - IdempotencyKey: req.GetIdempotencyKey(), - }) - if err != nil { - if qerr, ok := err.(quoteResolutionError); ok { - switch qerr.code { - case "quote_not_found": - return gsresponse.FailedPrecondition[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, qerr.code, qerr.err) - case "quote_expired": - return gsresponse.FailedPrecondition[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, qerr.code, qerr.err) - case "quote_not_executable": - return gsresponse.FailedPrecondition[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, qerr.code, qerr.err) - case "quote_intent_mismatch": - return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, qerr.err) - default: - return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, qerr.err) - } - } - return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err) - } - if quoteSnapshot == nil { - quoteSnapshot = &sharedv1.PaymentQuote{} - } - if err := requireNonNilIntent(resolvedIntent); err != nil { - return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err) - } - h.logger.Debug( - "Payment quote resolved", - mzap.ObjRef("org_ref", orgID), - zap.String("quote_ref", quoteRef), - zap.Bool("quote_ref_used", quoteRef != ""), - ) - - entity := newPayment(orgID, resolvedIntent, idempotencyKey, req.GetMetadata(), quoteSnapshot) - if plan == nil { - return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("stored payment plan is required")) - } - attachStoredPlan(entity, plan, idempotencyKey) - - if err = store.Create(ctx, entity); err != nil { - if errors.Is(err, storage.ErrDuplicatePayment) { - return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.DataConflict("payment already exists")) - } - return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err) - } - - if err := h.engine.ExecutePayment(ctx, store, entity, quoteSnapshot); err != nil { - return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err) - } - - h.logger.Info( - "Payment initiated", - zap.String("payment_ref", entity.PaymentRef), - mzap.ObjRef("org_ref", orgID), - zap.String("kind", resolvedIntent.GetKind().String()), - zap.String("quote_ref", quoteSnapshot.GetQuoteRef()), - zap.String("idempotency_key", idempotencyKey), - ) - return gsresponse.Success(&orchestratorv1.InitiatePaymentResponse{ - Payment: toProtoPayment(entity), - }) -} - -type cancelPaymentCommand struct { - engine paymentEngine - logger mlogger.Logger -} - -func (h *cancelPaymentCommand) Execute(ctx context.Context, req *orchestratorv1.CancelPaymentRequest) gsresponse.Responder[orchestratorv1.CancelPaymentResponse] { - if err := h.engine.EnsureRepository(ctx); err != nil { - return gsresponse.Unavailable[orchestratorv1.CancelPaymentResponse](h.logger, mservice.PaymentOrchestrator, err) - } - if req == nil { - return gsresponse.InvalidArgument[orchestratorv1.CancelPaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request")) - } - paymentRef, err := requirePaymentRef(req.GetPaymentRef()) - if err != nil { - return gsresponse.InvalidArgument[orchestratorv1.CancelPaymentResponse](h.logger, mservice.PaymentOrchestrator, err) - } - store, err := ensurePaymentsStore(h.engine.Repository()) - if err != nil { - return gsresponse.Unavailable[orchestratorv1.CancelPaymentResponse](h.logger, mservice.PaymentOrchestrator, err) - } - payment, err := store.GetByPaymentRef(ctx, paymentRef) - if err != nil { - return paymentNotFoundResponder[orchestratorv1.CancelPaymentResponse](mservice.PaymentOrchestrator, h.logger, err) - } - if payment.State != model.PaymentStateAccepted { - reason := merrors.InvalidArgument("payment cannot be cancelled in current state") - return gsresponse.FailedPrecondition[orchestratorv1.CancelPaymentResponse](h.logger, mservice.PaymentOrchestrator, "payment_not_cancellable", reason) - } - payment.State = model.PaymentStateCancelled - payment.FailureCode = model.PaymentFailureCodePolicy - payment.FailureReason = strings.TrimSpace(req.GetReason()) - if err := store.Update(ctx, payment); err != nil { - return gsresponse.Auto[orchestratorv1.CancelPaymentResponse](h.logger, mservice.PaymentOrchestrator, err) - } - h.logger.Info("Payment cancelled", zap.String("payment_ref", payment.PaymentRef), zap.String("org_ref", payment.OrganizationRef.Hex())) - return gsresponse.Success(&orchestratorv1.CancelPaymentResponse{Payment: toProtoPayment(payment)}) -} - -type initiateConversionCommand struct { - engine paymentEngine - logger mlogger.Logger -} - -func (h *initiateConversionCommand) Execute(ctx context.Context, req *orchestratorv1.InitiateConversionRequest) gsresponse.Responder[orchestratorv1.InitiateConversionResponse] { - if err := h.engine.EnsureRepository(ctx); err != nil { - return gsresponse.Unavailable[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err) - } - if req == nil { - return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request")) - } - _, orgID, err := validateMetaAndOrgRef(req.GetMeta()) - if err != nil { - return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err) - } - idempotencyKey, err := requireIdempotencyKey(req.GetIdempotencyKey()) - if err != nil { - return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err) - } - if req.GetSource() == nil || req.GetSource().GetLedger() == nil { - return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("source ledger endpoint is required")) - } - if req.GetDestination() == nil || req.GetDestination().GetLedger() == nil { - return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("destination ledger endpoint is required")) - } - fxIntent := req.GetFx() - if fxIntent == nil { - return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("fx intent is required")) - } - - store, err := ensurePaymentsStore(h.engine.Repository()) - if err != nil { - return gsresponse.Unavailable[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err) - } - - if existing, err := getPaymentByIdempotencyKey(ctx, store, orgID, idempotencyKey); err == nil && existing != nil { - h.logger.Debug("Idempotent conversion request reused", zap.String("payment_ref", existing.PaymentRef), mzap.ObjRef("org_ref", orgID)) - return gsresponse.Success(&orchestratorv1.InitiateConversionResponse{Conversion: toProtoPayment(existing)}) - } else if err != nil && !errors.Is(err, storage.ErrPaymentNotFound) { - return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err) - } - - amount, err := conversionAmountFromMetadata(req.GetMetadata(), fxIntent) - if err != nil { - return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err) - } - - intentProto := &sharedv1.PaymentIntent{ - Ref: uuid.New().String(), - Kind: sharedv1.PaymentKind_PAYMENT_KIND_FX_CONVERSION, - Source: req.GetSource(), - Destination: req.GetDestination(), - Amount: amount, - RequiresFx: true, - Fx: fxIntent, - FeePolicy: req.GetFeePolicy(), - SettlementCurrency: strings.TrimSpace(amount.GetCurrency()), - } - - quote, resolvedIntent, plan, err := h.engine.ResolvePaymentQuote(ctx, quoteResolutionInput{ - OrgRef: req.GetMeta().GetOrganizationRef(), - OrgID: orgID, - Meta: req.GetMeta(), - Intent: intentProto, - IdempotencyKey: req.GetIdempotencyKey(), - }) - if err != nil { - return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err) - } - if quote == nil { - return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("quote is required")) - } - if resolvedIntent == nil { - return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("intent is required")) - } - if plan == nil { - return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("stored payment plan is required")) - } - - entity := newPayment(orgID, resolvedIntent, idempotencyKey, req.GetMetadata(), quote) - attachStoredPlan(entity, plan, idempotencyKey) - - if err = store.Create(ctx, entity); err != nil { - if errors.Is(err, storage.ErrDuplicatePayment) { - return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, merrors.DataConflict("payment already exists")) - } - return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err) - } - - if err := h.engine.ExecutePayment(ctx, store, entity, quote); err != nil { - return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err) - } - - h.logger.Info("Conversion initiated", zap.String("payment_ref", entity.PaymentRef), mzap.ObjRef("org_ref", orgID)) - return gsresponse.Success(&orchestratorv1.InitiateConversionResponse{ - Conversion: toProtoPayment(entity), - }) -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/handlers_events.go b/api/payments/orchestrator/internal/service/orchestrator/handlers_events.go deleted file mode 100644 index 17535a63..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/handlers_events.go +++ /dev/null @@ -1,318 +0,0 @@ -package orchestrator - -import ( - "context" - "strings" - - "github.com/tech/sendico/payments/storage" - "github.com/tech/sendico/payments/storage/model" - "github.com/tech/sendico/pkg/api/routers/gsresponse" - "github.com/tech/sendico/pkg/merrors" - "github.com/tech/sendico/pkg/mlogger" - "github.com/tech/sendico/pkg/mservice" - chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" - mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" - orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestration/v1" - "go.uber.org/zap" -) - -type paymentEventHandler struct { - repo storage.Repository - ensureRepo func(ctx context.Context) error - logger mlogger.Logger - submitCardPayout func(ctx context.Context, operationRef string, payment *model.Payment) error - resumePlan func(ctx context.Context, store storage.PaymentsStore, payment *model.Payment) error - releaseHold func(ctx context.Context, store storage.PaymentsStore, payment *model.Payment) error -} - -func newPaymentEventHandler(repo storage.Repository, ensure func(ctx context.Context) error, logger mlogger.Logger, submitCardPayout func(ctx context.Context, operationRef string, payment *model.Payment) error, resumePlan func(ctx context.Context, store storage.PaymentsStore, payment *model.Payment) error, releaseHold func(ctx context.Context, store storage.PaymentsStore, payment *model.Payment) error) *paymentEventHandler { - return &paymentEventHandler{ - repo: repo, - ensureRepo: ensure, - logger: logger, - submitCardPayout: submitCardPayout, - resumePlan: resumePlan, - releaseHold: releaseHold, - } -} - -func (h *paymentEventHandler) processTransferUpdate(ctx context.Context, req *orchestratorv1.ProcessTransferUpdateRequest) gsresponse.Responder[orchestratorv1.ProcessTransferUpdateResponse] { - if err := h.ensureRepo(ctx); err != nil { - return gsresponse.Unavailable[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, err) - } - if req == nil || req.GetEvent() == nil || req.GetEvent().GetTransfer() == nil { - return gsresponse.InvalidArgument[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("transfer event is required")) - } - transfer := req.GetEvent().GetTransfer() - transferRef := strings.TrimSpace(transfer.GetTransferRef()) - if transferRef == "" { - return gsresponse.InvalidArgument[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("transfer_ref is required")) - } - store := h.repo.Payments() - if store == nil { - return gsresponse.Unavailable[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, errStorageUnavailable) - } - payment, err := store.GetByChainTransferRef(ctx, transferRef) - if err != nil { - return paymentNotFoundResponder[orchestratorv1.ProcessTransferUpdateResponse](mservice.PaymentOrchestrator, h.logger, err) - } - if payment.PaymentPlan != nil && len(payment.PaymentPlan.Steps) > 0 { - if payment.ExecutionPlan == nil || len(payment.ExecutionPlan.Steps) != len(payment.PaymentPlan.Steps) { - ensureExecutionPlanForPlan(payment, payment.PaymentPlan) - } - updateExecutionStepFromTransfer(payment.ExecutionPlan, req.GetEvent()) - if payment.Execution == nil { - payment.Execution = &model.ExecutionRefs{} - } - if payment.Execution.ChainTransferRef == "" { - payment.Execution.ChainTransferRef = transferRef - } - reason := transferFailureReason(req.GetEvent()) - switch transfer.GetStatus() { - case chainv1.TransferStatus_TRANSFER_FAILED: - payment.State = model.PaymentStateFailed - payment.FailureCode = model.PaymentFailureCodeChain - payment.FailureReason = reason - if err := store.Update(ctx, payment); err != nil { - return gsresponse.Auto[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, err) - } - return gsresponse.Success(&orchestratorv1.ProcessTransferUpdateResponse{Payment: toProtoPayment(payment)}) - case chainv1.TransferStatus_TRANSFER_CANCELLED: - payment.State = model.PaymentStateCancelled - payment.FailureCode = model.PaymentFailureCodePolicy - payment.FailureReason = reason - if err := store.Update(ctx, payment); err != nil { - return gsresponse.Auto[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, err) - } - return gsresponse.Success(&orchestratorv1.ProcessTransferUpdateResponse{Payment: toProtoPayment(payment)}) - case chainv1.TransferStatus_TRANSFER_SUCCESS: - if h.resumePlan != nil { - if err := h.resumePlan(ctx, store, payment); err != nil { - return gsresponse.Auto[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, err) - } - } - return gsresponse.Success(&orchestratorv1.ProcessTransferUpdateResponse{Payment: toProtoPayment(payment)}) - case chainv1.TransferStatus_TRANSFER_WAITING: - return gsresponse.Success(&orchestratorv1.ProcessTransferUpdateResponse{Payment: toProtoPayment(payment)}) - default: - if err := store.Update(ctx, payment); err != nil { - return gsresponse.Auto[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, err) - } - return gsresponse.Success(&orchestratorv1.ProcessTransferUpdateResponse{Payment: toProtoPayment(payment)}) - } - } - - updateExecutionStepFromTransfer(payment.ExecutionPlan, req.GetEvent()) - if payment.Intent.Destination.Type == model.EndpointTypeCard { - if payment.Execution == nil { - payment.Execution = &model.ExecutionRefs{} - } - if payment.Execution.ChainTransferRef == "" { - payment.Execution.ChainTransferRef = transferRef - } - reason := transferFailureReason(req.GetEvent()) - switch transfer.GetStatus() { - case chainv1.TransferStatus_TRANSFER_FAILED: - payment.State = model.PaymentStateFailed - payment.FailureCode = model.PaymentFailureCodeChain - payment.FailureReason = reason - case chainv1.TransferStatus_TRANSFER_CANCELLED: - payment.State = model.PaymentStateCancelled - payment.FailureCode = model.PaymentFailureCodePolicy - payment.FailureReason = reason - case chainv1.TransferStatus_TRANSFER_SUCCESS: - if payment.State != model.PaymentStateFailed && payment.State != model.PaymentStateCancelled && payment.State != model.PaymentStateSettled { - if cardPayoutDependenciesConfirmed(payment.PaymentPlan, payment.ExecutionPlan) { - if payment.Execution.CardPayoutRef == "" { - payment.State = model.PaymentStateFundsReserved - if h.submitCardPayout == nil { - h.logger.Warn("Card payout execution skipped", zap.String("payment_ref", payment.PaymentRef)) - } else if err := h.submitCardPayout(ctx, transfer.GetOperationRef(), payment); err != nil { - payment.State = model.PaymentStateFailed - payment.FailureCode = model.PaymentFailureCodePolicy - payment.FailureReason = strings.TrimSpace(err.Error()) - h.logger.Warn("Card payout execution failed", zap.Error(err), zap.String("payment_ref", payment.PaymentRef)) - } - } - } - } - case chainv1.TransferStatus_TRANSFER_WAITING: - default: - // keep current state - } - if err := store.Update(ctx, payment); err != nil { - return gsresponse.Auto[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, err) - } - h.logger.Info("Transfer update applied", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", transferRef), zap.Any("state", payment.State)) - return gsresponse.Success(&orchestratorv1.ProcessTransferUpdateResponse{Payment: toProtoPayment(payment)}) - } - - applyTransferStatus(req.GetEvent(), payment) - if err := store.Update(ctx, payment); err != nil { - return gsresponse.Auto[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, err) - } - h.logger.Info("Transfer update applied", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", transferRef), zap.Any("state", payment.State)) - return gsresponse.Success(&orchestratorv1.ProcessTransferUpdateResponse{Payment: toProtoPayment(payment)}) -} - -func (h *paymentEventHandler) processDepositObserved(ctx context.Context, req *orchestratorv1.ProcessDepositObservedRequest) gsresponse.Responder[orchestratorv1.ProcessDepositObservedResponse] { - if err := h.ensureRepo(ctx); err != nil { - return gsresponse.Unavailable[orchestratorv1.ProcessDepositObservedResponse](h.logger, mservice.PaymentOrchestrator, err) - } - if req == nil || req.GetEvent() == nil { - return gsresponse.InvalidArgument[orchestratorv1.ProcessDepositObservedResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("deposit event is required")) - } - event := req.GetEvent() - walletRef := strings.TrimSpace(event.GetWalletRef()) - if walletRef == "" { - return gsresponse.InvalidArgument[orchestratorv1.ProcessDepositObservedResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("wallet_ref is required")) - } - store := h.repo.Payments() - if store == nil { - return gsresponse.Unavailable[orchestratorv1.ProcessDepositObservedResponse](h.logger, mservice.PaymentOrchestrator, errStorageUnavailable) - } - filter := &model.PaymentFilter{ - States: []model.PaymentState{model.PaymentStateSubmitted, model.PaymentStateFundsReserved}, - DestinationRef: walletRef, - } - result, err := store.List(ctx, filter) - if err != nil { - return gsresponse.Auto[orchestratorv1.ProcessDepositObservedResponse](h.logger, mservice.PaymentOrchestrator, err) - } - for _, payment := range result.Items { - if payment.Intent.Destination.Type != model.EndpointTypeManagedWallet { - continue - } - if !moneyEquals(payment.Intent.Amount, event.GetAmount()) { - continue - } - payment.State = model.PaymentStateSettled - payment.FailureCode = model.PaymentFailureCodeUnspecified - payment.FailureReason = "" - if payment.Execution == nil { - payment.Execution = &model.ExecutionRefs{} - } - if payment.Execution.ChainTransferRef == "" { - payment.Execution.ChainTransferRef = strings.TrimSpace(event.GetTransactionHash()) - } - if err := store.Update(ctx, payment); err != nil { - return gsresponse.Auto[orchestratorv1.ProcessDepositObservedResponse](h.logger, mservice.PaymentOrchestrator, err) - } - h.logger.Info("Deposit observed matched payment", zap.String("payment_ref", payment.PaymentRef), zap.String("wallet_ref", walletRef)) - return gsresponse.Success(&orchestratorv1.ProcessDepositObservedResponse{Payment: toProtoPayment(payment)}) - } - return gsresponse.Success(&orchestratorv1.ProcessDepositObservedResponse{}) -} - -func (h *paymentEventHandler) processCardPayoutUpdate(ctx context.Context, req *orchestratorv1.ProcessCardPayoutUpdateRequest) gsresponse.Responder[orchestratorv1.ProcessCardPayoutUpdateResponse] { - if err := h.ensureRepo(ctx); err != nil { - return gsresponse.Unavailable[orchestratorv1.ProcessCardPayoutUpdateResponse](h.logger, mservice.PaymentOrchestrator, err) - } - if req == nil || req.GetEvent() == nil || req.GetEvent().GetPayout() == nil { - return gsresponse.InvalidArgument[orchestratorv1.ProcessCardPayoutUpdateResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("event is required")) - } - payout := req.GetEvent().GetPayout() - paymentRef := strings.TrimSpace(payout.GetPayoutId()) - if paymentRef == "" { - return gsresponse.InvalidArgument[orchestratorv1.ProcessCardPayoutUpdateResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("payout_id is required")) - } - - store := h.repo.Payments() - if store == nil { - return gsresponse.Unavailable[orchestratorv1.ProcessCardPayoutUpdateResponse](h.logger, mservice.PaymentOrchestrator, errStorageUnavailable) - } - payment, err := store.GetByPaymentRef(ctx, paymentRef) - if err != nil { - return paymentNotFoundResponder[orchestratorv1.ProcessCardPayoutUpdateResponse](mservice.PaymentOrchestrator, h.logger, err) - } - - applyCardPayoutUpdate(payment, payout) - - switch payout.GetStatus() { - - case mntxv1.PayoutStatus_PAYOUT_STATUS_SUCCESS: - h.logger.Info("Card payout success received", - zap.String("payment_ref", payment.PaymentRef), - zap.String("payout_ref", payout.GetPayoutId()), - zap.String("payment_state_before", string(payment.State)), - zap.Bool("has_plan", payment.PaymentPlan != nil && len(payment.PaymentPlan.Steps) > 0), - zap.Bool("resume_plan_present", h.resumePlan != nil), - ) - - if h.resumePlan != nil && payment.PaymentPlan != nil && len(payment.PaymentPlan.Steps) > 0 { - if err := h.resumePlan(ctx, store, payment); err != nil { - h.logger.Error("ResumePlan failed after payout success", - zap.String("payment_ref", payment.PaymentRef), - zap.String("payout_ref", payout.GetPayoutId()), - zap.Error(err), - ) - return gsresponse.Auto[orchestratorv1.ProcessCardPayoutUpdateResponse](h.logger, mservice.PaymentOrchestrator, err) - } - h.logger.Info("ResumePlan executed after payout success", - zap.String("payment_ref", payment.PaymentRef), - zap.String("payout_ref", payout.GetPayoutId()), - ) - } else { - h.logger.Warn("Payout success but plan cannot be resumed", - zap.String("payment_ref", payment.PaymentRef), - zap.String("payout_ref", payout.GetPayoutId()), - zap.Bool("resume_plan_present", h.resumePlan != nil), - zap.Bool("has_plan", payment.PaymentPlan != nil && len(payment.PaymentPlan.Steps) > 0), - ) - } - - case mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED: - h.logger.Warn("Card payout failed", - zap.String("payment_ref", payment.PaymentRef), - zap.String("payout_ref", payout.GetPayoutId()), - zap.String("provider_message", payout.GetProviderMessage()), - ) - - payment.State = model.PaymentStateFailed - payment.FailureCode = model.PaymentFailureCodePolicy - payment.FailureReason = strings.TrimSpace(payout.GetProviderMessage()) - - if h.releaseHold != nil && payment.PaymentPlan != nil && len(payment.PaymentPlan.Steps) > 0 { - h.logger.Info("Releasing hold after payout failure", - zap.String("payment_ref", payment.PaymentRef), - zap.String("payout_ref", payout.GetPayoutId()), - ) - - if err := h.releaseHold(ctx, store, payment); err != nil { - h.logger.Error("ReleaseHold failed after payout failure", - zap.String("payment_ref", payment.PaymentRef), - zap.String("payout_ref", payout.GetPayoutId()), - zap.Error(err), - ) - return gsresponse.Auto[orchestratorv1.ProcessCardPayoutUpdateResponse](h.logger, mservice.PaymentOrchestrator, err) - } - } else { - h.logger.Warn("Payout failed but hold cannot be released", - zap.String("payment_ref", payment.PaymentRef), - zap.String("payout_ref", payout.GetPayoutId()), - zap.Bool("release_hold_present", h.releaseHold != nil), - zap.Bool("has_plan", payment.PaymentPlan != nil && len(payment.PaymentPlan.Steps) > 0), - ) - } - } - - if err := store.Update(ctx, payment); err != nil { - return gsresponse.Auto[orchestratorv1.ProcessCardPayoutUpdateResponse](h.logger, mservice.PaymentOrchestrator, err) - } - - h.logger.Info("Card payout update applied", zap.String("payment_ref", payment.PaymentRef), zap.String("payout_id", paymentRef), zap.Any("state", payment.State)) - return gsresponse.Success(&orchestratorv1.ProcessCardPayoutUpdateResponse{ - Payment: toProtoPayment(payment), - }) -} - -func transferFailureReason(event *chainv1.TransferStatusChangedEvent) string { - if event == nil || event.GetTransfer() == nil { - return "" - } - reason := strings.TrimSpace(event.GetReason()) - if reason != "" { - return reason - } - return strings.TrimSpace(event.GetTransfer().GetFailureReason()) -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/handlers_queries.go b/api/payments/orchestrator/internal/service/orchestrator/handlers_queries.go deleted file mode 100644 index b190b7f2..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/handlers_queries.go +++ /dev/null @@ -1,81 +0,0 @@ -package orchestrator - -import ( - "context" - - "github.com/tech/sendico/payments/storage" - "github.com/tech/sendico/pkg/api/routers/gsresponse" - "github.com/tech/sendico/pkg/merrors" - "github.com/tech/sendico/pkg/mlogger" - "github.com/tech/sendico/pkg/mservice" - paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1" - orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestration/v1" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" - "go.uber.org/zap" -) - -type paymentQueryHandler struct { - repo storage.Repository - ensureRepo func(ctx context.Context) error - logger mlogger.Logger -} - -func newPaymentQueryHandler(repo storage.Repository, ensure func(ctx context.Context) error, logger mlogger.Logger) *paymentQueryHandler { - return &paymentQueryHandler{ - repo: repo, - ensureRepo: ensure, - logger: logger, - } -} - -func (h *paymentQueryHandler) getPayment(ctx context.Context, req *orchestratorv1.GetPaymentRequest) gsresponse.Responder[orchestratorv1.GetPaymentResponse] { - if err := h.ensureRepo(ctx); err != nil { - return gsresponse.Unavailable[orchestratorv1.GetPaymentResponse](h.logger, mservice.PaymentOrchestrator, err) - } - if req == nil { - return gsresponse.InvalidArgument[orchestratorv1.GetPaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request")) - } - paymentRef, err := requirePaymentRef(req.GetPaymentRef()) - if err != nil { - return gsresponse.InvalidArgument[orchestratorv1.GetPaymentResponse](h.logger, mservice.PaymentOrchestrator, err) - } - store, err := ensurePaymentsStore(h.repo) - if err != nil { - return gsresponse.Unavailable[orchestratorv1.GetPaymentResponse](h.logger, mservice.PaymentOrchestrator, err) - } - entity, err := store.GetByPaymentRef(ctx, paymentRef) - if err != nil { - return paymentNotFoundResponder[orchestratorv1.GetPaymentResponse](mservice.PaymentOrchestrator, h.logger, err) - } - h.logger.Debug("Payment fetched", zap.String("payment_ref", paymentRef)) - return gsresponse.Success(&orchestratorv1.GetPaymentResponse{Payment: toProtoPayment(entity)}) -} - -func (h *paymentQueryHandler) listPayments(ctx context.Context, req *orchestratorv1.ListPaymentsRequest) gsresponse.Responder[orchestratorv1.ListPaymentsResponse] { - if err := h.ensureRepo(ctx); err != nil { - return gsresponse.Unavailable[orchestratorv1.ListPaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) - } - if req == nil { - return gsresponse.InvalidArgument[orchestratorv1.ListPaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request")) - } - store, err := ensurePaymentsStore(h.repo) - if err != nil { - return gsresponse.Unavailable[orchestratorv1.ListPaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) - } - filter := filterFromProto(req) - result, err := store.List(ctx, filter) - if err != nil { - return gsresponse.Auto[orchestratorv1.ListPaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) - } - resp := &orchestratorv1.ListPaymentsResponse{ - Page: &paginationv1.CursorPageResponse{ - NextCursor: result.NextCursor, - }, - } - resp.Payments = make([]*sharedv1.Payment, 0, len(result.Items)) - for _, item := range result.Items { - resp.Payments = append(resp.Payments, toProtoPayment(item)) - } - h.logger.Debug("Payments listed", zap.Int("count", len(resp.Payments)), zap.String("next_cursor", resp.GetPage().GetNextCursor())) - return gsresponse.Success(resp) -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/helpers.go b/api/payments/orchestrator/internal/service/orchestrator/helpers.go deleted file mode 100644 index 28717bff..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/helpers.go +++ /dev/null @@ -1,299 +0,0 @@ -package orchestrator - -import ( - "strings" - - "github.com/shopspring/decimal" - "github.com/tech/sendico/pkg/merrors" - chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" - ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1" - oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" - - 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" -) - -type moneyGetter interface { - GetAmount() string - GetCurrency() string -} - -const ( - feeLineMetaTarget = "fee_target" - feeLineTargetWallet = "wallet" -) - -func cloneProtoMoney(input *moneyv1.Money) *moneyv1.Money { - if input == nil { - return nil - } - return &moneyv1.Money{ - Currency: input.GetCurrency(), - Amount: input.GetAmount(), - } -} - -func cloneMetadata(input map[string]string) map[string]string { - if len(input) == 0 { - return nil - } - clone := make(map[string]string, len(input)) - for k, v := range input { - clone[k] = v - } - return clone -} - -func cloneStringList(values []string) []string { - if len(values) == 0 { - return nil - } - result := make([]string, 0, len(values)) - for _, value := range values { - clean := strings.TrimSpace(value) - if clean == "" { - continue - } - result = append(result, clean) - } - if len(result) == 0 { - return nil - } - return result -} - -func resolveTradeAmounts(intentAmount *moneyv1.Money, fxQuote *oraclev1.Quote, side fxv1.Side) (*moneyv1.Money, *moneyv1.Money) { - if fxQuote == nil { - return cloneProtoMoney(intentAmount), cloneProtoMoney(intentAmount) - } - qSide := fxQuote.GetSide() - if qSide == fxv1.Side_SIDE_UNSPECIFIED { - qSide = side - } - - switch qSide { - case fxv1.Side_BUY_BASE_SELL_QUOTE: - pay := cloneProtoMoney(fxQuote.GetQuoteAmount()) - settle := cloneProtoMoney(fxQuote.GetBaseAmount()) - if pay == nil { - pay = cloneProtoMoney(intentAmount) - } - if settle == nil { - settle = cloneProtoMoney(intentAmount) - } - return pay, settle - case fxv1.Side_SELL_BASE_BUY_QUOTE: - pay := cloneProtoMoney(fxQuote.GetBaseAmount()) - settle := cloneProtoMoney(fxQuote.GetQuoteAmount()) - if pay == nil { - pay = cloneProtoMoney(intentAmount) - } - if settle == nil { - settle = cloneProtoMoney(intentAmount) - } - return pay, settle - default: - return cloneProtoMoney(intentAmount), cloneProtoMoney(intentAmount) - } -} - -func computeAggregates(pay, settlement, fee *moneyv1.Money, network *chainv1.EstimateTransferFeeResponse, fxQuote *oraclev1.Quote, mode paymentv1.SettlementMode) (*moneyv1.Money, *moneyv1.Money) { - if pay == nil { - return nil, nil - } - debitDecimal, err := decimalFromMoney(pay) - if err != nil { - return cloneProtoMoney(pay), cloneProtoMoney(settlement) - } - - settlementCurrency := pay.GetCurrency() - if settlement != nil && strings.TrimSpace(settlement.GetCurrency()) != "" { - settlementCurrency = settlement.GetCurrency() - } - - settlementDecimal := debitDecimal - if settlement != nil { - if val, err := decimalFromMoney(settlement); err == nil { - settlementDecimal = val - } - } - - applyChargeToDebit := func(m *moneyv1.Money) { - converted, err := ensureCurrency(m, pay.GetCurrency(), fxQuote) - if err != nil || converted == nil { - return - } - if val, err := decimalFromMoney(converted); err == nil { - debitDecimal = debitDecimal.Add(val) - } - } - - applyChargeToSettlement := func(m *moneyv1.Money) { - converted, err := ensureCurrency(m, settlementCurrency, fxQuote) - if err != nil || converted == nil { - return - } - if val, err := decimalFromMoney(converted); err == nil { - settlementDecimal = settlementDecimal.Sub(val) - } - } - - switch mode { - case paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED: - // Sender pays the fee: keep settlement fixed, increase debit. - applyChargeToDebit(fee) - default: - // Recipient pays the fee (default): reduce settlement, keep debit fixed. - applyChargeToSettlement(fee) - } - - if network != nil && network.GetNetworkFee() != nil { - switch mode { - case paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED: - applyChargeToDebit(network.GetNetworkFee()) - default: - applyChargeToSettlement(network.GetNetworkFee()) - } - } - - return makeMoney(pay.GetCurrency(), debitDecimal), makeMoney(settlementCurrency, settlementDecimal) -} - -func decimalFromMoney(m moneyGetter) (decimal.Decimal, error) { - if m == nil { - return decimal.Zero, nil - } - return decimal.NewFromString(m.GetAmount()) -} - -func makeMoney(currency string, value decimal.Decimal) *moneyv1.Money { - return &moneyv1.Money{ - Currency: currency, - Amount: value.String(), - } -} - -func ensureCurrency(m *moneyv1.Money, targetCurrency string, quote *oraclev1.Quote) (*moneyv1.Money, error) { - if m == nil || strings.TrimSpace(targetCurrency) == "" { - return nil, nil - } - if strings.EqualFold(m.GetCurrency(), targetCurrency) { - return cloneProtoMoney(m), nil - } - return convertWithQuote(m, quote, targetCurrency) -} - -func convertWithQuote(m *moneyv1.Money, quote *oraclev1.Quote, targetCurrency string) (*moneyv1.Money, error) { - if m == nil || quote == nil || quote.GetPair() == nil || quote.GetPrice() == nil { - return nil, nil - } - - base := strings.TrimSpace(quote.GetPair().GetBase()) - qt := strings.TrimSpace(quote.GetPair().GetQuote()) - if base == "" || qt == "" || strings.TrimSpace(targetCurrency) == "" { - return nil, nil - } - - price, err := decimal.NewFromString(quote.GetPrice().GetValue()) - if err != nil || price.IsZero() { - return nil, err - } - value, err := decimalFromMoney(m) - if err != nil { - return nil, err - } - - switch { - case strings.EqualFold(m.GetCurrency(), base) && strings.EqualFold(targetCurrency, qt): - return makeMoney(targetCurrency, value.Mul(price)), nil - case strings.EqualFold(m.GetCurrency(), qt) && strings.EqualFold(targetCurrency, base): - return makeMoney(targetCurrency, value.Div(price)), nil - default: - return nil, nil - } -} - -func feeLineTarget(line *feesv1.DerivedPostingLine) string { - if line == nil { - return "" - } - return strings.TrimSpace(line.GetMeta()[feeLineMetaTarget]) -} - -func isWalletTargetFeeLine(line *feesv1.DerivedPostingLine) bool { - return strings.EqualFold(feeLineTarget(line), feeLineTargetWallet) -} - -func ledgerChargesFromFeeLines(lines []*feesv1.DerivedPostingLine) []*ledgerv1.PostingLine { - if len(lines) == 0 { - return nil - } - charges := make([]*ledgerv1.PostingLine, 0, len(lines)) - for _, line := range lines { - if line == nil || isWalletTargetFeeLine(line) || strings.TrimSpace(line.GetLedgerAccountRef()) == "" { - continue - } - money := cloneProtoMoney(line.GetMoney()) - if money == nil { - continue - } - charges = append(charges, &ledgerv1.PostingLine{ - LedgerAccountRef: strings.TrimSpace(line.GetLedgerAccountRef()), - Money: money, - LineType: ledgerLineTypeFromAccounting(line.GetLineType()), - }) - } - if len(charges) == 0 { - return nil - } - return charges -} - -func ledgerLineTypeFromAccounting(lineType accountingv1.PostingLineType) ledgerv1.LineType { - switch lineType { - case accountingv1.PostingLineType_POSTING_LINE_SPREAD: - return ledgerv1.LineType_LINE_SPREAD - case accountingv1.PostingLineType_POSTING_LINE_REVERSAL: - return ledgerv1.LineType_LINE_REVERSAL - case accountingv1.PostingLineType_POSTING_LINE_FEE, - accountingv1.PostingLineType_POSTING_LINE_TAX: - return ledgerv1.LineType_LINE_FEE - default: - return ledgerv1.LineType_LINE_MAIN - } -} - -func moneyEquals(a, b moneyGetter) bool { - if a == nil || b == nil { - return false - } - if !strings.EqualFold(a.GetCurrency(), b.GetCurrency()) { - return false - } - return strings.TrimSpace(a.GetAmount()) == strings.TrimSpace(b.GetAmount()) -} - -func conversionAmountFromMetadata(meta map[string]string, fx *sharedv1.FXIntent) (*moneyv1.Money, error) { - if meta == nil { - meta = map[string]string{} - } - amount := strings.TrimSpace(meta["amount"]) - if amount == "" { - return nil, merrors.InvalidArgument("conversion amount metadata is required") - } - currency := strings.TrimSpace(meta["currency"]) - if currency == "" && fx != nil && fx.GetPair() != nil { - currency = strings.TrimSpace(fx.GetPair().GetBase()) - } - if currency == "" { - return nil, merrors.InvalidArgument("conversion currency metadata is required") - } - return &moneyv1.Money{ - Currency: currency, - Amount: amount, - }, nil -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/helpers_test.go b/api/payments/orchestrator/internal/service/orchestrator/helpers_test.go deleted file mode 100644 index cb49ab5a..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/helpers_test.go +++ /dev/null @@ -1,107 +0,0 @@ -package orchestrator - -import ( - "testing" - - 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" - chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" - oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" -) - -func TestResolveTradeAmountsBuyBase(t *testing.T) { - fxQuote := &oraclev1.Quote{ - Side: fxv1.Side_BUY_BASE_SELL_QUOTE, - Pair: &fxv1.CurrencyPair{Base: "EUR", Quote: "USD"}, - BaseAmount: &moneyv1.Money{ - Currency: "EUR", - Amount: "100", - }, - QuoteAmount: &moneyv1.Money{ - Currency: "USD", - Amount: "110", - }, - } - - pay, settle := resolveTradeAmounts(nil, fxQuote, fxv1.Side_SIDE_UNSPECIFIED) - if pay.GetCurrency() != "USD" || pay.GetAmount() != "110" { - t.Fatalf("expected pay amount in USD 110, got %s %s", pay.GetCurrency(), pay.GetAmount()) - } - if settle.GetCurrency() != "EUR" || settle.GetAmount() != "100" { - t.Fatalf("expected settlement in EUR 100, got %s %s", settle.GetCurrency(), settle.GetAmount()) - } -} - -func TestComputeAggregatesConvertsCurrencies(t *testing.T) { - pay := &moneyv1.Money{Currency: "USD", Amount: "100"} - settle := &moneyv1.Money{Currency: "EUR", Amount: "50"} - fee := &moneyv1.Money{Currency: "USD", Amount: "10"} - network := &chainv1.EstimateTransferFeeResponse{ - NetworkFee: &moneyv1.Money{Currency: "USD", Amount: "5"}, - } - fxQuote := &oraclev1.Quote{ - Pair: &fxv1.CurrencyPair{Base: "EUR", Quote: "USD"}, - Side: fxv1.Side_BUY_BASE_SELL_QUOTE, - Price: &moneyv1.Decimal{ - Value: "2", - }, - } - - debit, settlement := computeAggregates(pay, settle, fee, network, fxQuote, paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED) - if debit.GetCurrency() != "USD" || debit.GetAmount() != "115" { - t.Fatalf("expected debit 115 USD, got %s %s", debit.GetCurrency(), debit.GetAmount()) - } - if settlement.GetCurrency() != "EUR" || settlement.GetAmount() != "50" { - t.Fatalf("expected settlement 50 EUR, got %s %s", settlement.GetCurrency(), settlement.GetAmount()) - } -} - -func TestComputeAggregatesRecipientPaysFee(t *testing.T) { - pay := &moneyv1.Money{Currency: "USDT", Amount: "100"} - settle := &moneyv1.Money{Currency: "RUB", Amount: "7932"} // 100 * 79.32 - fee := &moneyv1.Money{Currency: "USDT", Amount: "7"} // 7% of 100 - fxQuote := &oraclev1.Quote{ - Pair: &fxv1.CurrencyPair{Base: "USDT", Quote: "RUB"}, - Side: fxv1.Side_SELL_BASE_BUY_QUOTE, - Price: &moneyv1.Decimal{ - Value: "79.32", - }, - } - - debit, settlement := computeAggregates(pay, settle, fee, nil, fxQuote, paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE) - if debit.GetCurrency() != "USDT" || debit.GetAmount() != "100" { - t.Fatalf("expected debit 100 USDT, got %s %s", debit.GetCurrency(), debit.GetAmount()) - } - if settlement.GetCurrency() != "RUB" || settlement.GetAmount() != "7376.76" { - t.Fatalf("expected settlement 7376.76 RUB, got %s %s", settlement.GetCurrency(), settlement.GetAmount()) - } -} - -func TestLedgerChargesFromFeeLinesSkipsWalletTarget(t *testing.T) { - lines := []*feesv1.DerivedPostingLine{ - { - LedgerAccountRef: "ledger:fees", - Money: &moneyv1.Money{Currency: "USDT", Amount: "0.7"}, - LineType: accountingv1.PostingLineType_POSTING_LINE_FEE, - Meta: map[string]string{ - feeLineMetaTarget: feeLineTargetWallet, - }, - }, - { - LedgerAccountRef: "ledger:fees", - Money: &moneyv1.Money{Currency: "USDT", Amount: "1.0"}, - LineType: accountingv1.PostingLineType_POSTING_LINE_FEE, - }, - } - - charges := ledgerChargesFromFeeLines(lines) - if len(charges) != 1 { - t.Fatalf("expected 1 ledger charge, got %d", len(charges)) - } - if charges[0].GetMoney().GetAmount() != "1.0" { - t.Fatalf("expected remaining charge amount 1.0, got %s", charges[0].GetMoney().GetAmount()) - } -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/internal_helpers.go b/api/payments/orchestrator/internal/service/orchestrator/internal_helpers.go deleted file mode 100644 index 062e1f27..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/internal_helpers.go +++ /dev/null @@ -1,68 +0,0 @@ -package orchestrator - -import ( - "context" - - "github.com/tech/sendico/payments/storage/model" - "github.com/tech/sendico/pkg/api/routers/gsresponse" - "github.com/tech/sendico/pkg/mservice" - mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" -) - -func (s *Service) ensureRepository(ctx context.Context) error { - if s.storage == nil { - return errStorageUnavailable - } - return s.storage.Ping(ctx) -} - -func executeUnary[TReq any, TResp any](ctx context.Context, svc *Service, method string, handler func(context.Context, *TReq) gsresponse.Responder[TResp], req *TReq) (*TResp, error) { - start := svc.clock.Now() - resp, err := gsresponse.Unary(svc.logger, mservice.PaymentOrchestrator, handler)(ctx, req) - observeRPC(method, err, svc.clock.Now().Sub(start)) - return resp, err -} - -func shouldEstimateNetworkFee(intent *sharedv1.PaymentIntent) bool { - if intent == nil { - return false - } - dest := intent.GetDestination() - if dest == nil { - return false - } - if dest.GetCard() != nil { - return false - } - if intent.GetKind() == sharedv1.PaymentKind_PAYMENT_KIND_PAYOUT { - return true - } - if dest.GetManagedWallet() != nil || dest.GetExternalChain() != nil { - return true - } - return false -} - -func mapMntxStatusToState(status mntxv1.PayoutStatus) model.PaymentState { - switch status { - - case mntxv1.PayoutStatus_PAYOUT_STATUS_CREATED: - return model.PaymentStateFundsReserved - - case mntxv1.PayoutStatus_PAYOUT_STATUS_WAITING: - return model.PaymentStateSubmitted - - case mntxv1.PayoutStatus_PAYOUT_STATUS_SUCCESS: - return model.PaymentStateSettled - - case mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED: - return model.PaymentStateFailed - - case mntxv1.PayoutStatus_PAYOUT_STATUS_CANCELLED: - return model.PaymentStateCancelled - - default: - return model.PaymentStateUnspecified - } -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/internal_helpers_test.go b/api/payments/orchestrator/internal/service/orchestrator/internal_helpers_test.go deleted file mode 100644 index ad9395ef..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/internal_helpers_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package orchestrator - -import ( - "testing" - - "github.com/tech/sendico/payments/storage/model" - mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" -) - -func TestShouldEstimateNetworkFeeSkipsCard(t *testing.T) { - intent := &sharedv1.PaymentIntent{ - Ref: "ref-1", - Kind: sharedv1.PaymentKind_PAYMENT_KIND_PAYOUT, - Destination: &sharedv1.PaymentEndpoint{ - Endpoint: &sharedv1.PaymentEndpoint_Card{ - Card: &sharedv1.CardEndpoint{}, - }, - }, - } - if shouldEstimateNetworkFee(intent) { - t.Fatalf("expected network fee estimation to be skipped for card payouts") - } -} - -func TestShouldEstimateNetworkFeeManagedWallet(t *testing.T) { - intent := &sharedv1.PaymentIntent{ - Ref: "ref-1", - Destination: &sharedv1.PaymentEndpoint{ - Endpoint: &sharedv1.PaymentEndpoint_ManagedWallet{ - ManagedWallet: &sharedv1.ManagedWalletEndpoint{ManagedWalletRef: "mw"}, - }, - }, - } - if !shouldEstimateNetworkFee(intent) { - t.Fatalf("expected network fee estimation when destination is managed wallet") - } -} - -func TestMapMntxStatusToState(t *testing.T) { - if mapMntxStatusToState(mntxv1.PayoutStatus_PAYOUT_STATUS_SUCCESS) != model.PaymentStateSettled { - t.Fatalf("processed should map to settled") - } - if mapMntxStatusToState(mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED) != model.PaymentStateFailed { - t.Fatalf("failed should map to failed") - } - if mapMntxStatusToState(mntxv1.PayoutStatus_PAYOUT_STATUS_WAITING) != model.PaymentStateSubmitted { - t.Fatalf("pending should map to submitted") - } - if mapMntxStatusToState(mntxv1.PayoutStatus_PAYOUT_STATUS_UNSPECIFIED) != model.PaymentStateUnspecified { - t.Fatalf("unspecified should map to unspecified") - } -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/metrics.go b/api/payments/orchestrator/internal/service/orchestrator/metrics.go deleted file mode 100644 index 417eb90e..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/metrics.go +++ /dev/null @@ -1,65 +0,0 @@ -package orchestrator - -import ( - "errors" - "sync" - "time" - - "github.com/prometheus/client_golang/prometheus" - "github.com/prometheus/client_golang/prometheus/promauto" - "github.com/tech/sendico/pkg/merrors" -) - -var ( - metricsOnce sync.Once - - rpcLatency *prometheus.HistogramVec - rpcStatus *prometheus.CounterVec -) - -func initMetrics() { - metricsOnce.Do(func() { - rpcLatency = promauto.NewHistogramVec(prometheus.HistogramOpts{ - Namespace: "sendico", - Subsystem: "payment_orchestrator", - Name: "rpc_latency_seconds", - Help: "Latency distribution for payment orchestrator RPC handlers.", - Buckets: prometheus.DefBuckets, - }, []string{"method"}) - - rpcStatus = promauto.NewCounterVec(prometheus.CounterOpts{ - Namespace: "sendico", - Subsystem: "payment_orchestrator", - Name: "rpc_requests_total", - Help: "Total number of RPC invocations grouped by method and status.", - }, []string{"method", "status"}) - }) -} - -func observeRPC(method string, err error, duration time.Duration) { - if rpcLatency != nil { - rpcLatency.WithLabelValues(method).Observe(duration.Seconds()) - } - if rpcStatus != nil { - rpcStatus.WithLabelValues(method, statusLabel(err)).Inc() - } -} - -func statusLabel(err error) string { - switch { - case err == nil: - return "ok" - case errors.Is(err, merrors.ErrInvalidArg): - return "invalid_argument" - case errors.Is(err, merrors.ErrNoData): - return "not_found" - case errors.Is(err, merrors.ErrDataConflict): - return "conflict" - case errors.Is(err, merrors.ErrAccessDenied): - return "denied" - case errors.Is(err, merrors.ErrInternal): - return "internal" - default: - return "error" - } -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/model_money.go b/api/payments/orchestrator/internal/service/orchestrator/model_money.go deleted file mode 100644 index 3a8184d3..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/model_money.go +++ /dev/null @@ -1,13 +0,0 @@ -package orchestrator - -import paymenttypes "github.com/tech/sendico/pkg/payments/types" - -func cloneMoney(input *paymenttypes.Money) *paymenttypes.Money { - if input == nil { - return nil - } - return &paymenttypes.Money{ - Currency: input.GetCurrency(), - Amount: input.GetAmount(), - } -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/options.go b/api/payments/orchestrator/internal/service/orchestrator/options.go index f404b9d2..1b3591e2 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/options.go +++ b/api/payments/orchestrator/internal/service/orchestrator/options.go @@ -2,27 +2,24 @@ package orchestrator import ( "context" - "sort" "strings" "time" - "github.com/shopspring/decimal" chainclient "github.com/tech/sendico/gateway/chain/client" mntxclient "github.com/tech/sendico/gateway/mntx/client" ledgerclient "github.com/tech/sendico/ledger/client" - "github.com/tech/sendico/payments/orchestrator/internal/service/plan_builder" "github.com/tech/sendico/payments/storage/model" clockpkg "github.com/tech/sendico/pkg/clock" - "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/discovery" mb "github.com/tech/sendico/pkg/messaging/broker" "github.com/tech/sendico/pkg/mlogger" - "github.com/tech/sendico/pkg/payments/rail" feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" - quotationv1 "github.com/tech/sendico/pkg/proto/payments/quotation/v1" - "go.uber.org/zap" ) -// Option configures service dependencies. +// Option configures Service construction. +// +// Orchestration runtime is v2-only; legacy option knobs are retained as no-op +// compatibility shims for server wiring. type Option func(*Service) // GatewayInvokeResolver resolves gateway invoke URIs into chain gateway clients. @@ -30,240 +27,9 @@ type GatewayInvokeResolver interface { Resolve(ctx context.Context, invokeURI string) (chainclient.Client, error) } -// ChainGatewayResolver resolves chain gateway clients by network. -type ChainGatewayResolver interface { - Resolve(ctx context.Context, network string) (chainclient.Client, error) -} - -type quotationDependency struct { - client quotationv1.QuotationServiceClient -} - -func (q quotationDependency) available() bool { - if q.client == nil { - return false - } - if checker, ok := q.client.(interface{ Available() bool }); ok { - return checker.Available() - } - return true -} - -type feesDependency struct { - client feesv1.FeeEngineClient - timeout time.Duration -} - -type ledgerDependency struct { - client ledgerclient.Client - internal rail.InternalLedger -} - -type gatewayDependency struct { - resolver ChainGatewayResolver -} - -type railGatewayDependency struct { - byID map[string]rail.RailGateway - byRail map[model.Rail][]rail.RailGateway - registry GatewayRegistry - chainResolver GatewayInvokeResolver - providerResolver GatewayInvokeResolver - logger mlogger.Logger -} - -func (g railGatewayDependency) available() bool { - return len(g.byID) > 0 || len(g.byRail) > 0 || (g.registry != nil && (g.chainResolver != nil || g.providerResolver != nil)) -} - -func (g railGatewayDependency) resolve(ctx context.Context, step *model.PaymentStep) (rail.RailGateway, error) { - if step == nil { - return nil, merrors.InvalidArgument("rail gateway: step is required") - } - if id := strings.TrimSpace(step.GatewayID); id != "" { - if gw, ok := g.byID[id]; ok { - return gw, nil - } - return g.resolveDynamic(ctx, step) - } - if len(g.byRail) == 0 { - return g.resolveDynamic(ctx, step) - } - list := g.byRail[step.Rail] - if len(list) == 0 { - return g.resolveDynamic(ctx, step) - } - return list[0], nil -} - -func (g railGatewayDependency) resolveDynamic(ctx context.Context, step *model.PaymentStep) (rail.RailGateway, error) { - if g.registry == nil { - return nil, merrors.InvalidArgument("rail gateway: registry is required") - } - if g.chainResolver == nil && g.providerResolver == nil { - return nil, merrors.InvalidArgument("rail gateway: gateway resolver is required") - } - items, err := g.registry.List(ctx) - if err != nil { - return nil, err - } - if len(items) == 0 { - return nil, merrors.InvalidArgument("rail gateway: no gateway instances available") - } - - currency := "" - amount := decimal.Zero - if step.Amount != nil && strings.TrimSpace(step.Amount.GetAmount()) != "" { - value, err := decimalFromMoney(step.Amount) - if err != nil { - return nil, err - } - amount = value - currency = strings.ToUpper(strings.TrimSpace(step.Amount.GetCurrency())) - } - - candidates := make([]*model.GatewayInstanceDescriptor, 0) - var lastErr error - for _, entry := range items { - if entry == nil || !entry.IsEnabled { - continue - } - if entry.Rail != step.Rail { - continue - } - if step.Action != model.RailOperationUnspecified { - if err := isGatewayEligible(entry, step.Rail, "", currency, step.Action, sendDirectionForRail(step.Rail), amount); err != nil { - lastErr = err - continue - } - } - candidates = append(candidates, entry) - } - if len(candidates) == 0 { - if lastErr != nil { - return nil, merrors.InvalidArgument("rail gateway: missing gateway for rail: " + lastErr.Error()) - } - return nil, merrors.InvalidArgument("rail gateway: missing gateway for rail") - } - sort.Slice(candidates, func(i, j int) bool { - return model.LessGatewayDescriptor(candidates[i], candidates[j]) - }) - entry, selectionMode := model.SelectGatewayByPreference( - candidates, - step.GatewayID, - step.InstanceID, - step.GatewayInvokeURI, - ) - if entry == nil { - entry = candidates[0] - selectionMode = "rail_fallback" - } - invokeURI := strings.TrimSpace(entry.InvokeURI) - if invokeURI == "" { - return nil, merrors.InvalidArgument("rail gateway: invoke uri is required") - } - originalGatewayID := strings.TrimSpace(step.GatewayID) - originalInstanceID := strings.TrimSpace(step.InstanceID) - originalInvokeURI := strings.TrimSpace(step.GatewayInvokeURI) - step.GatewayID = strings.TrimSpace(entry.ID) - step.InstanceID = strings.TrimSpace(entry.InstanceID) - step.GatewayInvokeURI = invokeURI - g.logger.Debug("Rail gateway candidate selected", - zap.String("step_id", strings.TrimSpace(step.StepID)), - zap.String("selection_mode", selectionMode), - zap.String("requested_gateway_id", originalGatewayID), - zap.String("requested_instance_id", originalInstanceID), - zap.String("requested_invoke_uri", originalInvokeURI), - zap.String("resolved_gateway_id", step.GatewayID), - zap.String("resolved_instance_id", step.InstanceID), - zap.String("resolved_invoke_uri", step.GatewayInvokeURI), - ) - - cfg := chainclient.RailGatewayConfig{ - Rail: string(entry.Rail), - Network: entry.Network, - Capabilities: rail.RailCapabilities{ - CanPayIn: entry.Capabilities.CanPayIn, - CanPayOut: entry.Capabilities.CanPayOut, - CanReadBalance: entry.Capabilities.CanReadBalance, - CanSendFee: entry.Capabilities.CanSendFee, - RequiresObserveConfirm: entry.Capabilities.RequiresObserveConfirm, - CanBlock: entry.Capabilities.CanBlock, - CanRelease: entry.Capabilities.CanRelease, - }, - } - - if selectionMode != "exact" && (originalGatewayID != "" || originalInstanceID != "" || originalInvokeURI != "") { - g.logger.Warn("Rail gateway identity fallback applied", - zap.String("step_id", strings.TrimSpace(step.StepID)), - zap.String("selection_mode", selectionMode), - zap.String("requested_gateway_id", originalGatewayID), - zap.String("requested_instance_id", originalInstanceID), - zap.String("requested_invoke_uri", originalInvokeURI), - zap.String("resolved_gateway_id", step.GatewayID), - zap.String("resolved_instance_id", step.InstanceID), - zap.String("resolved_invoke_uri", step.GatewayInvokeURI), - ) - } - g.logger.Info("Rail gateway resolved", - zap.String("step_id", strings.TrimSpace(step.StepID)), - zap.String("action", string(step.Action)), - zap.String("selection_mode", selectionMode), - zap.String("gateway_id", entry.ID), - zap.String("instance_id", entry.InstanceID), - zap.String("rail", string(entry.Rail)), - zap.String("network", entry.Network), - zap.String("invoke_uri", invokeURI)) - - switch entry.Rail { - case model.RailProviderSettlement: - if g.providerResolver == nil { - return nil, merrors.InvalidArgument("rail gateway: provider settlement resolver required") - } - client, err := g.providerResolver.Resolve(ctx, invokeURI) - if err != nil { - return nil, err - } - return NewProviderSettlementGateway(client, cfg), nil - default: - if g.chainResolver == nil { - return nil, merrors.InvalidArgument("rail gateway: chain gateway resolver required") - } - client, err := g.chainResolver.Resolve(ctx, invokeURI) - if err != nil { - return nil, err - } - return chainclient.NewRailGateway(client, cfg), nil - } -} - -type mntxDependency struct { - client mntxclient.Client -} - -func (m mntxDependency) available() bool { - if m.client == nil { - return false - } - if checker, ok := m.client.(interface{ Available() bool }); ok { - return checker.Available() - } - return true -} - -type providerGatewayDependency struct { - resolver ChainGatewayResolver -} - -type staticChainGatewayResolver struct { - client chainclient.Client -} - -func (r staticChainGatewayResolver) Resolve(ctx context.Context, _ string) (chainclient.Client, error) { - if r.client == nil { - return nil, merrors.InvalidArgument("chain gateway client is required") - } - return r.client, nil +// GatewayRegistry exposes gateway descriptors. +type GatewayRegistry interface { + List(ctx context.Context) ([]*model.GatewayInstanceDescriptor, error) } // CardGatewayRoute maps a gateway to its funding and fee destinations. @@ -273,204 +39,169 @@ type CardGatewayRoute struct { FeeWalletRef string } -// WithFeeEngine wires the fee engine client. -func WithFeeEngine(client feesv1.FeeEngineClient, timeout time.Duration) Option { - return func(s *Service) { - s.deps.fees = feesDependency{ - client: client, - timeout: timeout, - } - } +// WithFeeEngine is retained for backward-compatible wiring and is currently a no-op. +func WithFeeEngine(_ feesv1.FeeEngineClient, _ time.Duration) Option { + return func(*Service) {} } -func WithPaymentGatewayBroker(broker mb.Broker) Option { - return func(s *Service) { - if broker != nil { - s.gatewayBroker = broker - } - } +// WithLedgerClient is retained for backward-compatible wiring and is currently a no-op. +func WithLedgerClient(_ ledgerclient.Client) Option { + return func(*Service) {} } -// WithQuotationService wires the quotation gRPC client. -func WithQuotationService(client quotationv1.QuotationServiceClient) Option { - return func(s *Service) { - s.deps.quotation = quotationDependency{client: client} - } +// WithMntxGateway is retained for backward-compatible wiring and is currently a no-op. +func WithMntxGateway(_ mntxclient.Client) Option { + return func(*Service) {} } -// WithLedgerClient wires the ledger client. -func WithLedgerClient(client ledgerclient.Client) Option { - return func(s *Service) { - s.deps.ledger = ledgerDependency{ - client: client, - internal: client, - } - } +// WithPaymentGatewayBroker is retained for backward-compatible wiring and is currently a no-op. +func WithPaymentGatewayBroker(_ mb.Broker) Option { + return func(*Service) {} } -// WithChainGatewayClient wires the chain gateway client. -func WithChainGatewayClient(client chainclient.Client) Option { - return func(s *Service) { - s.deps.gateway = gatewayDependency{resolver: staticChainGatewayResolver{client: client}} - } +// WithClock is retained for backward-compatible wiring and is currently a no-op. +func WithClock(_ clockpkg.Clock) Option { + return func(*Service) {} } -// WithChainGatewayResolver wires a resolver for chain gateway clients. -func WithChainGatewayResolver(resolver ChainGatewayResolver) Option { - return func(s *Service) { - if resolver != nil { - s.deps.gateway = gatewayDependency{resolver: resolver} - } - } +// WithMaxFXQuoteTTLMillis is retained for backward-compatible wiring and is currently a no-op. +func WithMaxFXQuoteTTLMillis(_ int64) Option { + return func(*Service) {} } -// WithProviderSettlementGatewayClient wires the provider settlement gateway client. -func WithProviderSettlementGatewayClient(client chainclient.Client) Option { - return func(s *Service) { - s.deps.providerGateway = providerGatewayDependency{resolver: staticChainGatewayResolver{client: client}} - } +// WithGatewayInvokeResolver is retained for backward-compatible wiring and is currently a no-op. +func WithGatewayInvokeResolver(_ GatewayInvokeResolver) Option { + return func(*Service) {} } -// WithProviderSettlementGatewayResolver wires a resolver for provider settlement gateway clients. -func WithProviderSettlementGatewayResolver(resolver ChainGatewayResolver) Option { - return func(s *Service) { - if resolver != nil { - s.deps.providerGateway = providerGatewayDependency{resolver: resolver} - } - } +// WithCardGatewayRoutes is retained for backward-compatible wiring and is currently a no-op. +func WithCardGatewayRoutes(_ map[string]CardGatewayRoute) Option { + return func(*Service) {} } -// WithGatewayInvokeResolver wires a resolver for gateway invoke URIs. -func WithGatewayInvokeResolver(resolver GatewayInvokeResolver) Option { - return func(s *Service) { - if resolver == nil { - return - } - s.deps.gatewayInvokeResolver = resolver - s.deps.railGateways.chainResolver = resolver - s.deps.railGateways.providerResolver = resolver - } +// WithFeeLedgerAccounts is retained for backward-compatible wiring and is currently a no-op. +func WithFeeLedgerAccounts(_ map[string]string) Option { + return func(*Service) {} } -// WithRailGateways wires rail gateway adapters by instance ID. -func WithRailGateways(gateways map[string]rail.RailGateway) Option { - return func(s *Service) { - if len(gateways) == 0 { - return - } - s.deps.railGateways = buildRailGatewayDependency(gateways, s.deps.gatewayRegistry, s.deps.gatewayInvokeResolver, s.deps.gatewayInvokeResolver, s.logger) - } +// WithGatewayRegistry is retained for backward-compatible wiring and is currently a no-op. +func WithGatewayRegistry(_ GatewayRegistry) Option { + return func(*Service) {} } -// WithMntxGateway wires the Monetix gateway client. -func WithMntxGateway(client mntxclient.Client) Option { - return func(s *Service) { - s.deps.mntx = mntxDependency{client: client} - } +type discoveryGatewayRegistry struct { + registry *discovery.Registry } -// WithCardGatewayRoutes configures funding/fee wallet routing per gateway. -func WithCardGatewayRoutes(routes map[string]CardGatewayRoute) Option { - return func(s *Service) { - if len(routes) == 0 { - return - } - s.deps.cardRoutes = make(map[string]CardGatewayRoute, len(routes)) - for k, v := range routes { - s.deps.cardRoutes[strings.ToLower(strings.TrimSpace(k))] = v - } +// NewDiscoveryGatewayRegistry adapts discovery registry entries to gateway descriptors. +func NewDiscoveryGatewayRegistry(_ mlogger.Logger, registry *discovery.Registry) GatewayRegistry { + if registry == nil { + return nil } + return &discoveryGatewayRegistry{registry: registry} } -// WithFeeLedgerAccounts maps gateway identifiers to ledger accounts used for fees. -func WithFeeLedgerAccounts(routes map[string]string) Option { - return func(s *Service) { - if len(routes) == 0 { - return - } - s.deps.feeLedgerAccounts = make(map[string]string, len(routes)) - for k, v := range routes { - key := strings.ToLower(strings.TrimSpace(k)) - val := strings.TrimSpace(v) - if key == "" || val == "" { - continue - } - s.deps.feeLedgerAccounts[key] = val - } - } -} - -// WithGatewayRegistry wires a registry of gateway instances for routing. -func WithGatewayRegistry(registry plan_builder.GatewayRegistry) Option { - return func(s *Service) { - if registry != nil { - s.deps.gatewayRegistry = registry - s.deps.railGateways.registry = registry - s.deps.railGateways.chainResolver = s.deps.gatewayInvokeResolver - s.deps.railGateways.providerResolver = s.deps.gatewayInvokeResolver - s.deps.railGateways.logger = s.logger.Named("rail_gateways") - } - } -} - -// WithClock overrides the default clock. -func WithClock(clock clockpkg.Clock) Option { - return func(s *Service) { - if clock != nil { - s.clock = clock - } - } -} - -// WithMaxFXQuoteTTLMillis caps forwarded FX quote TTL requests. -func WithMaxFXQuoteTTLMillis(value int64) Option { - return func(s *Service) { - if value > 0 { - s.maxFXQuoteTTLMillis = value - } - } -} - -func buildRailGatewayDependency(gateways map[string]rail.RailGateway, registry GatewayRegistry, chainResolver GatewayInvokeResolver, providerResolver GatewayInvokeResolver, logger mlogger.Logger) railGatewayDependency { - result := railGatewayDependency{ - byID: map[string]rail.RailGateway{}, - byRail: map[model.Rail][]rail.RailGateway{}, - registry: registry, - chainResolver: chainResolver, - providerResolver: providerResolver, - logger: logger, - } - if len(gateways) == 0 { - return result +func (r *discoveryGatewayRegistry) List(_ context.Context) ([]*model.GatewayInstanceDescriptor, error) { + if r == nil || r.registry == nil { + return nil, nil } - type item struct { - id string - gw rail.RailGateway - } - itemsByRail := map[model.Rail][]item{} - - for id, gw := range gateways { - cleanID := strings.TrimSpace(id) - if cleanID == "" || gw == nil { + entries := r.registry.List(time.Now(), true) + items := make([]*model.GatewayInstanceDescriptor, 0, len(entries)) + for _, entry := range entries { + rail := railFromDiscovery(entry.Rail) + if rail == model.RailUnspecified { continue } - result.byID[cleanID] = gw - railID := parseRailValue(gw.Rail()) - if railID == model.RailUnspecified { - continue - } - itemsByRail[railID] = append(itemsByRail[railID], item{id: cleanID, gw: gw}) - } - - for railID, items := range itemsByRail { - sort.Slice(items, func(i, j int) bool { - return items[i].id < items[j].id + operations := operationsFromDiscovery(entry.Operations) + items = append(items, &model.GatewayInstanceDescriptor{ + ID: strings.TrimSpace(entry.ID), + InstanceID: strings.TrimSpace(entry.InstanceID), + Rail: rail, + Network: strings.ToUpper(strings.TrimSpace(entry.Network)), + InvokeURI: strings.TrimSpace(entry.InvokeURI), + Currencies: currenciesFromDiscovery(entry.Currencies), + Operations: operations, + Capabilities: model.RailCapabilitiesFromOperations(operations), + Limits: limitsFromDiscovery(entry.Limits), + IsEnabled: entry.Healthy, }) - for _, entry := range items { - result.byRail[railID] = append(result.byRail[railID], entry.gw) - } } + return items, nil +} +func railFromDiscovery(value string) model.Rail { + switch discovery.NormalizeRail(value) { + case discovery.RailCrypto: + return model.RailCrypto + case discovery.RailProviderSettlement: + return model.RailProviderSettlement + case discovery.RailLedger: + return model.RailLedger + case discovery.RailCardPayout: + return model.RailCardPayout + case discovery.RailFiatOnRamp: + return model.RailFiatOnRamp + default: + return model.RailUnspecified + } +} + +func operationsFromDiscovery(values []string) []model.RailOperation { + return model.NormalizeRailOperationStrings(discovery.NormalizeRailOperations(values)) +} + +func currenciesFromDiscovery(values []string) []string { + if len(values) == 0 { + return nil + } + result := make([]string, 0, len(values)) + seen := map[string]bool{} + for _, value := range values { + currency := strings.ToUpper(strings.TrimSpace(value)) + if currency == "" || seen[currency] { + continue + } + seen[currency] = true + result = append(result, currency) + } + if len(result) == 0 { + return nil + } return result } + +func limitsFromDiscovery(src *discovery.Limits) model.Limits { + limits := model.Limits{} + if src == nil { + return limits + } + + limits.MinAmount = strings.TrimSpace(src.MinAmount) + limits.MaxAmount = strings.TrimSpace(src.MaxAmount) + + if len(src.VolumeLimit) > 0 { + limits.VolumeLimit = map[string]string{} + for bucket, value := range src.VolumeLimit { + key := strings.TrimSpace(bucket) + amount := strings.TrimSpace(value) + if key == "" || amount == "" { + continue + } + limits.VolumeLimit[key] = amount + } + } + + if len(src.VelocityLimit) > 0 { + limits.VelocityLimit = map[string]int{} + for bucket, value := range src.VelocityLimit { + key := strings.TrimSpace(bucket) + if key == "" || value <= 0 { + continue + } + limits.VelocityLimit[key] = value + } + } + + return limits +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/options_rail_gateway_test.go b/api/payments/orchestrator/internal/service/orchestrator/options_rail_gateway_test.go deleted file mode 100644 index 11aac9df..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/options_rail_gateway_test.go +++ /dev/null @@ -1,145 +0,0 @@ -package orchestrator - -import ( - "context" - "testing" - - chainclient "github.com/tech/sendico/gateway/chain/client" - "github.com/tech/sendico/payments/storage/model" - paymenttypes "github.com/tech/sendico/pkg/payments/types" - "go.uber.org/zap" -) - -type optionsGatewayRegistryStub struct { - items []*model.GatewayInstanceDescriptor -} - -func (s optionsGatewayRegistryStub) List(context.Context) ([]*model.GatewayInstanceDescriptor, error) { - return s.items, nil -} - -type optionsInvokeResolverStub struct { - uris []string -} - -func (s *optionsInvokeResolverStub) Resolve(_ context.Context, invokeURI string) (chainclient.Client, error) { - s.uris = append(s.uris, invokeURI) - return &chainclient.Fake{}, nil -} - -func TestResolveDynamicGateway_FallsBackToInvokeURI(t *testing.T) { - resolver := &optionsInvokeResolverStub{} - deps := railGatewayDependency{ - registry: optionsGatewayRegistryStub{items: []*model.GatewayInstanceDescriptor{ - { - ID: "aaa", - InstanceID: "inst-a", - Rail: model.RailCrypto, - Network: "TRON", - InvokeURI: "grpc://gw-a:50051", - Currencies: []string{"USDT"}, - Capabilities: model.RailCapabilities{ - CanPayOut: true, - }, - IsEnabled: true, - }, - { - ID: "bbb", - InstanceID: "inst-b", - Rail: model.RailCrypto, - Network: "TRON", - InvokeURI: "grpc://gw-b:50051", - Currencies: []string{"USDT"}, - Capabilities: model.RailCapabilities{ - CanPayOut: true, - }, - IsEnabled: true, - }, - }}, - chainResolver: resolver, - logger: zap.NewNop(), - } - step := &model.PaymentStep{ - StepID: "crypto.send", - Rail: model.RailCrypto, - Action: model.RailOperationSend, - GatewayID: "legacy-id", - InstanceID: "legacy-instance", - GatewayInvokeURI: "grpc://gw-b:50051", - Amount: &paymenttypes.Money{Currency: "USDT", Amount: "1"}, - } - - if _, err := deps.resolveDynamic(context.Background(), step); err != nil { - t.Fatalf("resolveDynamic returned error: %v", err) - } - if got, want := step.GatewayID, "bbb"; got != want { - t.Fatalf("unexpected gateway_id: got=%q want=%q", got, want) - } - if got, want := step.InstanceID, "inst-b"; got != want { - t.Fatalf("unexpected instance_id: got=%q want=%q", got, want) - } - if got, want := step.GatewayInvokeURI, "grpc://gw-b:50051"; got != want { - t.Fatalf("unexpected gateway_invoke_uri: got=%q want=%q", got, want) - } - if len(resolver.uris) != 1 || resolver.uris[0] != "grpc://gw-b:50051" { - t.Fatalf("unexpected resolver invocations: %#v", resolver.uris) - } -} - -func TestResolveDynamicGateway_FallsBackToGatewayIDWhenInstanceChanges(t *testing.T) { - resolver := &optionsInvokeResolverStub{} - deps := railGatewayDependency{ - registry: optionsGatewayRegistryStub{items: []*model.GatewayInstanceDescriptor{ - { - ID: "aaa", - InstanceID: "inst-a", - Rail: model.RailCrypto, - Network: "TRON", - InvokeURI: "grpc://gw-a:50051", - Currencies: []string{"USDT"}, - Capabilities: model.RailCapabilities{ - CanPayOut: true, - }, - IsEnabled: true, - }, - { - ID: "crypto_rail_gateway_tron", - InstanceID: "inst-new", - Rail: model.RailCrypto, - Network: "TRON", - InvokeURI: "grpc://gw-tron:50051", - Currencies: []string{"USDT"}, - Capabilities: model.RailCapabilities{ - CanPayOut: true, - }, - IsEnabled: true, - }, - }}, - chainResolver: resolver, - logger: zap.NewNop(), - } - step := &model.PaymentStep{ - StepID: "crypto.send", - Rail: model.RailCrypto, - Action: model.RailOperationSend, - GatewayID: "crypto_rail_gateway_tron", - InstanceID: "inst-old", - Amount: &paymenttypes.Money{Currency: "USDT", Amount: "1"}, - } - - if _, err := deps.resolveDynamic(context.Background(), step); err != nil { - t.Fatalf("resolveDynamic returned error: %v", err) - } - if got, want := step.GatewayID, "crypto_rail_gateway_tron"; got != want { - t.Fatalf("unexpected gateway_id: got=%q want=%q", got, want) - } - if got, want := step.InstanceID, "inst-new"; got != want { - t.Fatalf("unexpected instance_id: got=%q want=%q", got, want) - } - if got, want := step.GatewayInvokeURI, "grpc://gw-tron:50051"; got != want { - t.Fatalf("unexpected gateway_invoke_uri: got=%q want=%q", got, want) - } - if len(resolver.uris) != 1 || resolver.uris[0] != "grpc://gw-tron:50051" { - t.Fatalf("unexpected resolver invocations: %#v", resolver.uris) - } -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/payment_executor.go b/api/payments/orchestrator/internal/service/orchestrator/payment_executor.go deleted file mode 100644 index 1ef737a4..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/payment_executor.go +++ /dev/null @@ -1,173 +0,0 @@ -package orchestrator - -import ( - "context" - "strings" - - "github.com/tech/sendico/payments/storage" - "github.com/tech/sendico/payments/storage/model" - "github.com/tech/sendico/pkg/merrors" - "github.com/tech/sendico/pkg/mlogger" - fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1" - chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" - ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" - "go.uber.org/zap" -) - -type paymentExecutor struct { - deps *serviceDependencies - logger mlogger.Logger - svc *Service -} - -func newPaymentExecutor(deps *serviceDependencies, logger mlogger.Logger, svc *Service) *paymentExecutor { - return &paymentExecutor{deps: deps, logger: logger, svc: svc} -} - -func (p *paymentExecutor) executePayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, quote *sharedv1.PaymentQuote) error { - if store == nil { - return errStorageUnavailable - } - if payment == nil { - return merrors.InvalidArgument("payment is required") - } - if payment.PaymentPlan == nil || len(payment.PaymentPlan.Steps) == 0 { - return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, "payment_plan_missing", merrors.InvalidArgument("payment plan is required")) - } - if strings.TrimSpace(payment.PaymentPlan.ID) == "" { - payment.PaymentPlan.ID = payment.PaymentRef - } - if strings.TrimSpace(payment.PaymentPlan.IdempotencyKey) == "" { - payment.PaymentPlan.IdempotencyKey = payment.IdempotencyKey - } - - return p.executePaymentPlan(ctx, store, payment, quote) -} - -func (p *paymentExecutor) applyFX(ctx context.Context, payment *model.Payment, quote *sharedv1.PaymentQuote, charges []*ledgerv1.PostingLine, description string, metadata map[string]string, exec *model.ExecutionRefs) error { - intent := payment.Intent - source := intent.Source.Ledger - destination := intent.Destination.Ledger - if source == nil || destination == nil { - return merrors.InvalidArgument("ledger: fx conversion requires ledger source and destination") - } - fq := quote.GetFxQuote() - if fq == nil { - return merrors.InvalidArgument("ledger: fx quote missing") - } - fxSide := fxv1.Side_SIDE_UNSPECIFIED - if intent.FX != nil { - fxSide = fxSideToProto(intent.FX.Side) - } - fromMoney, toMoney := resolveTradeAmounts(protoMoney(intent.Amount), fq, fxSide) - if fromMoney == nil { - fromMoney = protoMoney(intent.Amount) - } - if toMoney == nil { - toMoney = cloneProtoMoney(quote.GetExpectedSettlementAmount()) - } - rate := "" - if fq.GetPrice() != nil { - rate = fq.GetPrice().GetValue() - } - req := &ledgerv1.FXRequest{ - IdempotencyKey: payment.IdempotencyKey, - OrganizationRef: payment.OrganizationRef.Hex(), - FromLedgerAccountRef: strings.TrimSpace(source.LedgerAccountRef), - ToLedgerAccountRef: strings.TrimSpace(destination.LedgerAccountRef), - FromMoney: fromMoney, - ToMoney: toMoney, - Rate: rate, - Description: description, - Charges: charges, - Metadata: metadata, - } - resp, err := p.deps.ledger.client.ApplyFXWithCharges(ctx, req) - if err != nil { - return err - } - exec.FXEntryRef = strings.TrimSpace(resp.GetJournalEntryRef()) - payment.Execution = exec - return nil -} - -func (p *paymentExecutor) persistPayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment) error { - if store == nil { - return errStorageUnavailable - } - return store.Update(ctx, payment) -} - -func (p *paymentExecutor) failPayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, code model.PaymentFailureCode, reason string, err error) error { - payment.State = model.PaymentStateFailed - payment.FailureCode = code - payment.FailureReason = strings.TrimSpace(reason) - if store != nil { - if updateErr := store.Update(ctx, payment); updateErr != nil { - p.logger.Warn("Failed to persist payment failure", zap.Error(updateErr), zap.String("payment_ref", payment.PaymentRef)) - } - } - if err != nil { - return err - } - return merrors.Internal(reason) -} - -func paymentDescription(payment *model.Payment) string { - if payment == nil { - return "" - } - if val := strings.TrimSpace(payment.Intent.Attributes["description"]); val != "" { - return val - } - if payment.Metadata != nil { - if val := strings.TrimSpace(payment.Metadata["description"]); val != "" { - return val - } - } - return payment.PaymentRef -} - -func applyTransferStatus(event *chainv1.TransferStatusChangedEvent, payment *model.Payment) { - if payment.Execution == nil { - payment.Execution = &model.ExecutionRefs{} - } - if event == nil || event.GetTransfer() == nil { - return - } - transfer := event.GetTransfer() - payment.Execution.ChainTransferRef = strings.TrimSpace(transfer.GetTransferRef()) - reason := strings.TrimSpace(event.GetReason()) - if reason == "" { - reason = strings.TrimSpace(transfer.GetFailureReason()) - } - switch transfer.GetStatus() { - - case chainv1.TransferStatus_TRANSFER_SUCCESS: - payment.State = model.PaymentStateSettled - payment.FailureCode = model.PaymentFailureCodeUnspecified - payment.FailureReason = "" - - case chainv1.TransferStatus_TRANSFER_FAILED: - payment.State = model.PaymentStateFailed - payment.FailureCode = model.PaymentFailureCodeChain - payment.FailureReason = reason - - case chainv1.TransferStatus_TRANSFER_CANCELLED: - payment.State = model.PaymentStateCancelled - payment.FailureCode = model.PaymentFailureCodePolicy - payment.FailureReason = reason - - case chainv1.TransferStatus_TRANSFER_WAITING: - payment.State = model.PaymentStateSubmitted - - case chainv1.TransferStatus_TRANSFER_CREATED, - chainv1.TransferStatus_TRANSFER_PROCESSING: - // do nothing, retain previous state - - default: - // retain previous state - } - -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_card.go b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_card.go deleted file mode 100644 index 2a1fb32a..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_card.go +++ /dev/null @@ -1,196 +0,0 @@ -package orchestrator - -import ( - "context" - "strings" - - "github.com/shopspring/decimal" - "github.com/tech/sendico/payments/storage/model" - "github.com/tech/sendico/pkg/merrors" - "github.com/tech/sendico/pkg/model/account_role" - moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" - mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" -) - -func (p *paymentExecutor) submitCardPayoutPlan(ctx context.Context, payment *model.Payment, operationRef string, amount *moneyv1.Money, fromRole, toRole *account_role.AccountRole) (string, error) { - if payment == nil { - return "", merrors.InvalidArgument("payment is required") - } - if !p.deps.mntx.available() { - return "", merrors.Internal("card_gateway_unavailable") - } - intent := payment.Intent - card := intent.Destination.Card - if card == nil { - return "", merrors.InvalidArgument("card payout: card endpoint is required") - } - if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" { - return "", merrors.InvalidArgument("card payout: amount is required") - } - - amtDec, err := decimalFromMoney(amount) - if err != nil { - return "", err - } - minor := amtDec.Mul(decimal.NewFromInt(100)).IntPart() - - payoutID := payment.PaymentRef - currency := strings.TrimSpace(amount.GetCurrency()) - holder := strings.TrimSpace(card.Cardholder) - meta := cloneMetadata(payment.Metadata) - if strings.TrimSpace(string(mergeAccountRole(fromRole))) != "" { - if meta == nil { - meta = map[string]string{} - } - meta[account_role.MetadataKeyFromRole] = strings.TrimSpace(string(mergeAccountRole(fromRole))) - } - if strings.TrimSpace(string(mergeAccountRole(toRole))) != "" { - if meta == nil { - meta = map[string]string{} - } - meta[account_role.MetadataKeyToRole] = strings.TrimSpace(string(mergeAccountRole(toRole))) - } - customer := intent.Customer - customerID := "" - customerFirstName := "" - customerMiddleName := "" - customerLastName := "" - customerIP := "" - customerZip := "" - customerCountry := "" - customerState := "" - customerCity := "" - customerAddress := "" - if customer != nil { - customerID = strings.TrimSpace(customer.ID) - customerFirstName = strings.TrimSpace(customer.FirstName) - customerMiddleName = strings.TrimSpace(customer.MiddleName) - customerLastName = strings.TrimSpace(customer.LastName) - customerIP = strings.TrimSpace(customer.IP) - customerZip = strings.TrimSpace(customer.Zip) - customerCountry = strings.TrimSpace(customer.Country) - customerState = strings.TrimSpace(customer.State) - customerCity = strings.TrimSpace(customer.City) - customerAddress = strings.TrimSpace(customer.Address) - } - if customerFirstName == "" { - customerFirstName = strings.TrimSpace(card.Cardholder) - } - if customerLastName == "" { - customerLastName = strings.TrimSpace(card.CardholderSurname) - } - if customerID == "" { - return "", merrors.InvalidArgument("card payout: customer id is required") - } - if customerFirstName == "" { - return "", merrors.InvalidArgument("card payout: customer first name is required") - } - if customerLastName == "" { - return "", merrors.InvalidArgument("card payout: customer last name is required") - } - if customerIP == "" { - return "", merrors.InvalidArgument("card payout: customer ip is required") - } - - var state *mntxv1.CardPayoutState - if token := strings.TrimSpace(card.Token); token != "" { - req := &mntxv1.CardTokenPayoutRequest{ - PayoutId: payoutID, - CustomerId: customerID, - CustomerFirstName: customerFirstName, - CustomerMiddleName: customerMiddleName, - CustomerLastName: customerLastName, - CustomerIp: customerIP, - CustomerZip: customerZip, - CustomerCountry: customerCountry, - CustomerState: customerState, - CustomerCity: customerCity, - CustomerAddress: customerAddress, - AmountMinor: minor, - Currency: currency, - CardToken: token, - CardHolder: holder, - MaskedPan: strings.TrimSpace(card.MaskedPan), - Metadata: meta, - OperationRef: operationRef, - IntentRef: payment.Intent.Ref, - IdempotencyKey: payment.IdempotencyKey, - } - resp, err := p.deps.mntx.client.CreateCardTokenPayout(ctx, req) - if err != nil { - return "", err - } - state = resp.GetPayout() - } else if pan := strings.TrimSpace(card.Pan); pan != "" { - req := &mntxv1.CardPayoutRequest{ - PayoutId: payoutID, - CustomerId: customerID, - CustomerFirstName: customerFirstName, - CustomerMiddleName: customerMiddleName, - CustomerLastName: customerLastName, - CustomerIp: customerIP, - CustomerZip: customerZip, - CustomerCountry: customerCountry, - CustomerState: customerState, - CustomerCity: customerCity, - CustomerAddress: customerAddress, - AmountMinor: minor, - Currency: currency, - CardPan: pan, - CardExpYear: card.ExpYear, - CardExpMonth: card.ExpMonth, - CardHolder: holder, - Metadata: meta, - OperationRef: operationRef, - IntentRef: payment.Intent.Ref, - IdempotencyKey: payment.IdempotencyKey, - } - resp, err := p.deps.mntx.client.CreateCardPayout(ctx, req) - if err != nil { - return "", err - } - state = resp.GetPayout() - } else { - return "", merrors.InvalidArgument("card payout: either token or pan must be provided") - } - - if state == nil { - return "", merrors.Internal("card payout: missing payout state") - } - recordCardPayoutState(payment, state) - exec := ensureExecutionRefs(payment) - if exec.CardPayoutRef == "" { - exec.CardPayoutRef = strings.TrimSpace(state.GetPayoutId()) - } - return exec.CardPayoutRef, nil -} - -func mergeAccountRole(role *account_role.AccountRole) account_role.AccountRole { - if role == nil { - return "" - } - return account_role.AccountRole(strings.TrimSpace(string(*role))) -} - -func (p *paymentExecutor) resolveCardRoute(intent model.PaymentIntent) (CardGatewayRoute, error) { - if p.svc != nil { - return p.svc.cardRoute(p.gatewayKeyFromIntent(intent)) - } - key := p.gatewayKeyFromIntent(intent) - route, ok := p.deps.cardRoutes[key] - if !ok { - return CardGatewayRoute{}, merrors.InvalidArgument("card routing missing for gateway " + key) - } - if strings.TrimSpace(route.FundingAddress) == "" { - return CardGatewayRoute{}, merrors.InvalidArgument("card funding address is required for gateway " + key) - } - return route, nil -} - -func (p *paymentExecutor) gatewayKeyFromIntent(intent model.PaymentIntent) string { - key := strings.TrimSpace(intent.Attributes["gateway"]) - if key == "" && intent.Destination.Card != nil { - key = defaultCardGateway - } - return strings.ToLower(key) -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_chain.go b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_chain.go deleted file mode 100644 index c97deb9b..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_chain.go +++ /dev/null @@ -1,116 +0,0 @@ -package orchestrator - -import ( - "strings" - - "github.com/tech/sendico/payments/storage/model" - "github.com/tech/sendico/pkg/merrors" - "github.com/tech/sendico/pkg/model/account_role" - "github.com/tech/sendico/pkg/payments/rail" - paymenttypes "github.com/tech/sendico/pkg/payments/types" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" -) - -func (p *paymentExecutor) buildCryptoTransferRequest(payment *model.Payment, amount *paymenttypes.Money, action model.RailOperation, idempotencyKey, operationRef string, quote *sharedv1.PaymentQuote, fromRole, toRole *account_role.AccountRole) (rail.TransferRequest, error) { - if payment == nil { - return rail.TransferRequest{}, merrors.InvalidArgument("chain: payment is required") - } - if amount == nil { - return rail.TransferRequest{}, merrors.InvalidArgument("chain: amount is required") - } - source := payment.Intent.Source.ManagedWallet - if source == nil || strings.TrimSpace(source.ManagedWalletRef) == "" { - return rail.TransferRequest{}, merrors.InvalidArgument("chain: source managed wallet is required") - } - destRef, memo, err := p.resolveCryptoDestination(payment, action) - if err != nil { - return rail.TransferRequest{}, err - } - paymentRef := strings.TrimSpace(payment.PaymentRef) - if paymentRef == "" { - return rail.TransferRequest{}, merrors.InvalidArgument("chain: payment reference is required") - } - req := rail.TransferRequest{ - IntentRef: strings.TrimSpace(payment.Intent.Ref), - OperationRef: strings.TrimSpace(operationRef), - OrganizationRef: payment.OrganizationRef.Hex(), - PaymentRef: strings.TrimSpace(payment.PaymentRef), - FromAccountID: strings.TrimSpace(source.ManagedWalletRef), - ToAccountID: strings.TrimSpace(destRef), - Currency: strings.TrimSpace(amount.GetCurrency()), - Network: strings.TrimSpace(cryptoNetworkForPayment(payment)), - Amount: strings.TrimSpace(amount.GetAmount()), - IdempotencyKey: strings.TrimSpace(idempotencyKey), - Metadata: cloneMetadata(payment.Metadata), - DestinationMemo: memo, - } - if fromRole != nil { - req.FromRole = *fromRole - } - if toRole != nil { - req.ToRole = *toRole - } - if req.Currency == "" || req.Amount == "" { - return rail.TransferRequest{}, merrors.InvalidArgument("chain: amount is required") - } - if req.IdempotencyKey == "" { - return rail.TransferRequest{}, merrors.InvalidArgument("chain: idempotency_key is required") - } - return req, nil -} - -func (p *paymentExecutor) resolveCryptoDestination(payment *model.Payment, action model.RailOperation) (string, string, error) { - if payment == nil { - return "", "", merrors.InvalidArgument("chain: payment is required") - } - intent := payment.Intent - switch intent.Destination.Type { - case model.EndpointTypeManagedWallet: - if action == model.RailOperationSend { - if intent.Destination.ManagedWallet == nil || strings.TrimSpace(intent.Destination.ManagedWallet.ManagedWalletRef) == "" { - return "", "", merrors.InvalidArgument("chain: destination managed wallet is required") - } - return strings.TrimSpace(intent.Destination.ManagedWallet.ManagedWalletRef), "", nil - } - case model.EndpointTypeExternalChain: - if action == model.RailOperationSend { - if intent.Destination.ExternalChain == nil || strings.TrimSpace(intent.Destination.ExternalChain.Address) == "" { - return "", "", merrors.InvalidArgument("chain: external address is required") - } - return strings.TrimSpace(intent.Destination.ExternalChain.Address), strings.TrimSpace(intent.Destination.ExternalChain.Memo), nil - } - } - route, err := p.resolveCardRoute(intent) - if err != nil { - return "", "", err - } - switch action { - case model.RailOperationSend: - address := strings.TrimSpace(route.FundingAddress) - if address == "" { - return "", "", merrors.InvalidArgument("chain: funding address is required") - } - return address, "", nil - case model.RailOperationFee: - if walletRef := strings.TrimSpace(route.FeeWalletRef); walletRef != "" { - return walletRef, "", nil - } - if address := strings.TrimSpace(route.FeeAddress); address != "" { - return address, "", nil - } - return "", "", merrors.InvalidArgument("chain: fee destination is required") - default: - return "", "", merrors.InvalidArgument("chain: unsupported action") - } -} - -func cryptoNetworkForPayment(payment *model.Payment) string { - if payment == nil { - return "" - } - network := networkFromEndpoint(payment.Intent.Source) - if network != "" { - return network - } - return networkFromEndpoint(payment.Intent.Destination) -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_executor.go b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_executor.go deleted file mode 100644 index 671d20c3..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_executor.go +++ /dev/null @@ -1,200 +0,0 @@ -package orchestrator - -import ( - "context" - - "github.com/tech/sendico/payments/storage" - "github.com/tech/sendico/payments/storage/model" - "github.com/tech/sendico/pkg/merrors" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" - "go.uber.org/zap" -) - -func buildStepIndex(plan *model.PaymentPlan) map[string]int { - m := make(map[string]int, len(plan.Steps)) - for i, s := range plan.Steps { - if s == nil { - continue - } - m[s.StepID] = i - } - return m -} - -func isPlanComplete(payment *model.Payment) bool { - if (payment.State == model.PaymentStateCancelled) || - (payment.State == model.PaymentStateSettled) || - (payment.State == model.PaymentStateFailed) { - return true - } - return false -} -func (p *paymentExecutor) pickIndependentSteps( - ctx context.Context, - l *zap.Logger, - store storage.PaymentsStore, - waiting []*model.ExecutionStep, - payment *model.Payment, - quote *sharedv1.PaymentQuote, -) error { - - logger := l.With(zap.Int("waiting_steps", len(waiting))) - logger.Debug("Selecting independent steps for execution") - - execSteps := executionStepsByCode(payment.ExecutionPlan) - planSteps := planStepsByID(payment.PaymentPlan) - execQuote := executionQuote(payment, quote) - charges := ledgerChargesFromFeeLines(execQuote.GetFeeLines()) - stepIdx := buildStepIndex(payment.PaymentPlan) - - for _, execStep := range waiting { - if execStep == nil { - continue - } - - lg := logger.With( - zap.String("step_code", execStep.Code), - zap.String("step_state", string(execStep.State)), - ) - - planStep := planSteps[execStep.Code] - if planStep == nil { - lg.Warn("Plan step not found") - continue - } - - ready, waitingDep, blocked, err := - stepDependenciesReady(planStep, execSteps, planSteps, true) - - if err != nil { - lg.Warn("Dependency evaluation failed", zap.Error(err)) - continue - } - - if blocked { - lg.Debug("Step permanently blocked by dependency failure") - setExecutionStepStatus(execStep, model.OperationStateCancelled) - continue - } - - if waitingDep { - lg.Debug("Step waiting for dependencies") - continue - } - - if !ready { - continue - } - - lg.Debug("Executing independent step") - idx := stepIdx[execStep.Code] - - async, err := p.executePlanStep( - ctx, - payment, - planStep, - execStep, - quote, - charges, - idx, - ) - if err != nil { - lg.Warn("Step execution failed", zap.Error(err), zap.Bool("async", async)) - return err - } - } - - return nil -} - -func (p *paymentExecutor) pickWaitingSteps( - ctx context.Context, - l *zap.Logger, - store storage.PaymentsStore, - payment *model.Payment, - quote *sharedv1.PaymentQuote, -) error { - if payment == nil || payment.ExecutionPlan == nil { - l.Debug("No execution plan") - return nil - } - - logger := l.With(zap.Int("total_steps", len(payment.ExecutionPlan.Steps))) - logger.Debug("Collecting waiting steps") - - waitingSteps := make([]*model.ExecutionStep, 0, len(payment.ExecutionPlan.Steps)) - for _, step := range payment.ExecutionPlan.Steps { - if step == nil { - continue - } - if step.State != model.OperationStatePlanned { - continue - } - waitingSteps = append(waitingSteps, step) - } - - if len(waitingSteps) == 0 { - logger.Debug("No waiting steps to process") - return nil - } - - return p.pickIndependentSteps(ctx, logger, store, waitingSteps, payment, quote) -} - -func (p *paymentExecutor) executePaymentPlan( - ctx context.Context, - store storage.PaymentsStore, - payment *model.Payment, - quote *sharedv1.PaymentQuote, -) error { - - if payment == nil { - return merrors.InvalidArgument("plan must be provided") - } - - logger := p.logger.With(zap.String("payment_ref", payment.PaymentRef)) - logger.Debug("Starting plan execution") - - if isPlanComplete(payment) { - logger.Debug("Plan already completed") - return nil - } - - if payment.ExecutionPlan == nil { - logger.Debug("Initializing execution plan from payment plan") - payment.ExecutionPlan = ensureExecutionPlanForPlan(payment, payment.PaymentPlan) - if err := store.Update(ctx, payment); err != nil { - return err - } - } - - // Execute steps - if err := p.pickWaitingSteps(ctx, logger, store, payment, quote); err != nil { - logger.Warn("Step execution returned infrastructure error", zap.Error(err)) - } - - if err := store.Update(ctx, payment); err != nil { - return err - } - - done, failed, rootErr := analyzeExecutionPlan(logger, payment) - if !done { - return nil - } - - if failed { - payment.State = model.PaymentStateFailed - } else { - payment.State = model.PaymentStateSettled - } - - if err := store.Update(ctx, payment); err != nil { - logger.Warn("Failed to update final payment state", zap.Error(err)) - return err - } - - if failed && rootErr != nil { - return rootErr - } - return nil -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_executor_test.go b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_executor_test.go deleted file mode 100644 index 2a45114b..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_executor_test.go +++ /dev/null @@ -1,217 +0,0 @@ -package orchestrator - -import ( - "context" - "testing" - - mntxclient "github.com/tech/sendico/gateway/mntx/client" - ledgerclient "github.com/tech/sendico/ledger/client" - "github.com/tech/sendico/payments/storage/model" - mo "github.com/tech/sendico/pkg/model" - "github.com/tech/sendico/pkg/model/account_role" - "github.com/tech/sendico/pkg/payments/rail" - paymenttypes "github.com/tech/sendico/pkg/payments/types" - connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1" - mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" - ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" - "go.mongodb.org/mongo-driver/v2/bson" - "go.uber.org/zap" - "google.golang.org/protobuf/types/known/structpb" -) - -func TestExecutePaymentPlan_SourceBeforeDestination(t *testing.T) { - ctx := context.Background() - - store := newStubPaymentsStore() - repo := &stubRepository{store: store} - - transferRefs := []string{"send-1", "fee-1"} - sendCalls := 0 - railGateway := &fakeRailGateway{ - rail: "CRYPTO", - sendFn: func(ctx context.Context, req rail.TransferRequest) (rail.RailResult, error) { - ref := transferRefs[sendCalls] - sendCalls++ - return rail.RailResult{ReferenceID: ref, Status: rail.TransferStatusWaiting}, nil - }, - } - - moveCalls := 0 - pendingAccountID := "ledger:pending" - operatingAccountID := "ledger:operating" - transitAccountID := "ledger:transit" - ledgerFake := &ledgerclient.Fake{ - ListConnectorAccountsFn: func(ctx context.Context, req *connectorv1.ListAccountsRequest) (*connectorv1.ListAccountsResponse, error) { - details, _ := structpb.NewStruct(map[string]interface{}{ - "role": "ACCOUNT_ROLE_PENDING", - }) - detailsOperating, _ := structpb.NewStruct(map[string]interface{}{ - "role": "ACCOUNT_ROLE_OPERATING", - }) - detailsTransit, _ := structpb.NewStruct(map[string]interface{}{ - "role": "ACCOUNT_ROLE_TRANSIT", - }) - return &connectorv1.ListAccountsResponse{ - Accounts: []*connectorv1.Account{ - {Ref: &connectorv1.AccountRef{ConnectorId: "ledger", AccountId: pendingAccountID}, Kind: connectorv1.AccountKind_LEDGER_ACCOUNT, Asset: "USDT", ProviderDetails: details}, - {Ref: &connectorv1.AccountRef{ConnectorId: "ledger", AccountId: operatingAccountID}, Kind: connectorv1.AccountKind_LEDGER_ACCOUNT, Asset: "USDT", ProviderDetails: detailsOperating}, - {Ref: &connectorv1.AccountRef{ConnectorId: "ledger", AccountId: transitAccountID}, Kind: connectorv1.AccountKind_LEDGER_ACCOUNT, Asset: "USDT", ProviderDetails: detailsTransit}, - }, - }, nil - }, - TransferInternalFn: func(ctx context.Context, req *ledgerv1.TransferRequest) (*ledgerv1.PostResponse, error) { - moveCalls++ - return &ledgerv1.PostResponse{JournalEntryRef: "move-1"}, nil - }, - } - - payoutCalls := 0 - mntxFake := &mntxclient.Fake{ - CreateCardPayoutFn: func(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error) { - payoutCalls++ - return &mntxv1.CardPayoutResponse{Payout: &mntxv1.CardPayoutState{PayoutId: "payout-1"}}, nil - }, - } - - svc := &Service{ - logger: zap.NewNop(), - storage: repo, - deps: serviceDependencies{ - railGateways: buildRailGatewayDependency(map[string]rail.RailGateway{ - "crypto-default": railGateway, - }, nil, nil, nil, nil), - ledger: ledgerDependency{ - client: ledgerFake, - internal: ledgerFake, - }, - mntx: mntxDependency{client: mntxFake}, - cardRoutes: map[string]CardGatewayRoute{ - defaultCardGateway: { - FundingAddress: "funding-address", - FeeWalletRef: "fee-wallet", - }, - }, - }, - } - - executor := newPaymentExecutor(&svc.deps, svc.logger, svc) - - payment := &model.Payment{ - PaymentRef: "pay-plan-1", - IdempotencyKey: "pay-plan-1", - OrganizationBoundBase: mo.OrganizationBoundBase{ - OrganizationRef: bson.NewObjectID(), - }, - Intent: model.PaymentIntent{ - Ref: "ref-1", - Kind: model.PaymentKindPayout, - Source: model.PaymentEndpoint{ - Type: model.EndpointTypeManagedWallet, - ManagedWallet: &model.ManagedWalletEndpoint{ - ManagedWalletRef: "wallet-src", - }, - }, - Destination: model.PaymentEndpoint{ - Type: model.EndpointTypeCard, - Card: &model.CardEndpoint{ - Pan: "4111111111111111", - Cardholder: "Ada", - CardholderSurname: "Lovelace", - ExpMonth: 1, - ExpYear: 2030, - MaskedPan: "4111", - }, - }, - Attributes: map[string]string{ - "ledger_credit_account_ref": "ledger:credit", - "ledger_debit_account_ref": "ledger:debit", - }, - Customer: &model.Customer{ - ID: "cust-1", - FirstName: "Ada", - LastName: "Lovelace", - IP: "1.2.3.4", - }, - }, - PaymentPlan: &model.PaymentPlan{ - ID: "pay-plan-1", - IdempotencyKey: "pay-plan-1", - Steps: []*model.PaymentStep{ - {StepID: "crypto_send", Rail: model.RailCrypto, Action: model.RailOperationSend, Amount: &paymenttypes.Money{Currency: "USDT", Amount: "100"}}, - {StepID: "crypto_fee", Rail: model.RailCrypto, Action: model.RailOperationFee, DependsOn: []string{"crypto_send"}, Amount: &paymenttypes.Money{Currency: "USDT", Amount: "5"}}, - {StepID: "crypto_observe", Rail: model.RailProviderSettlement, Action: model.RailOperationObserveConfirm, DependsOn: []string{"crypto_send"}}, - {StepID: "ledger_credit", Rail: model.RailLedger, Action: model.RailOperationMove, DependsOn: []string{"crypto_observe"}, Amount: &paymenttypes.Money{Currency: "USDT", Amount: "95"}, FromRole: rolePtr(account_role.AccountRolePending), ToRole: rolePtr(account_role.AccountRoleOperating)}, - {StepID: "card_payout", Rail: model.RailCardPayout, Action: model.RailOperationSend, DependsOn: []string{"ledger_credit"}, Amount: &paymenttypes.Money{Currency: "USDT", Amount: "95"}}, - {StepID: "ledger_debit", Rail: model.RailLedger, Action: model.RailOperationMove, DependsOn: []string{"card_payout"}, CommitPolicy: model.CommitPolicyAfterSuccess, CommitAfter: []string{"card_payout"}, Amount: &paymenttypes.Money{Currency: "USDT", Amount: "95"}, FromRole: rolePtr(account_role.AccountRoleOperating), ToRole: rolePtr(account_role.AccountRoleTransit)}, - }, - }, - } - - store.payments[payment.PaymentRef] = payment - - if err := executor.executePaymentPlan(ctx, store, payment, &sharedv1.PaymentQuote{}); err != nil { - t.Fatalf("executePaymentPlan error: %v", err) - } - - if payment.Execution == nil || payment.Execution.ChainTransferRef == "" { - t.Fatalf("expected chain transfer ref set") - } - if payment.Execution.FeeTransferRef != "" { - t.Fatalf("fee must NOT be executed before send success") - } - - steps := executionStepsByCode(payment.ExecutionPlan) - - if steps["crypto_send"].State != model.OperationStateWaiting { - t.Fatalf("send must be waiting") - } - if steps["crypto_fee"].State != model.OperationStatePlanned { - t.Fatalf("fee must NOT start before send success") - } - if steps["crypto_observe"].State != model.OperationStatePlanned { - t.Fatalf("observe must NOT start before send success") - } - - // ---- имитируем подтверждение сети по crypto_send ---- - setExecutionStepStatus(steps["crypto_send"], model.OperationStateSuccess) - - if err := executor.executePaymentPlan(ctx, store, payment, &sharedv1.PaymentQuote{}); err != nil { - t.Fatalf("executePaymentPlan resume error: %v", err) - } - - // Теперь должны стартовать fee и observe - if steps["crypto_fee"].State != model.OperationStateWaiting { - t.Fatalf("fee must start after send success") - } - if steps["crypto_observe"].State != model.OperationStateWaiting { - t.Fatalf("observe must start after send success") - } - - // Имитируем подтверждение observe (это unlock ledger_credit) - setExecutionStepStatus(steps["crypto_observe"], model.OperationStateSuccess) - - if err := executor.executePaymentPlan(ctx, store, payment, &sharedv1.PaymentQuote{}); err != nil { - t.Fatalf("executePaymentPlan resume after observe error: %v", err) - } - - if moveCalls != 1 { - t.Fatalf("expected one ledger move after observe confirmation, got %d", moveCalls) - } - if payoutCalls != 1 { - t.Fatalf("expected card payout submitted, got %d", payoutCalls) - } - - // Mock card payout confirmation - cardStep := executionStepsByCode(payment.ExecutionPlan)["card_payout"] - setExecutionStepStatus(cardStep, model.OperationStateSuccess) - - if err := executor.executePaymentPlan(ctx, store, payment, &sharedv1.PaymentQuote{}); err != nil { - t.Fatalf("executePaymentPlan finalize error: %v", err) - } - - if moveCalls != 2 { - t.Fatalf("expected two ledger moves after payout confirmation, got %d", moveCalls) - } - -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_ledger.go b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_ledger.go deleted file mode 100644 index 9cb753c4..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_ledger.go +++ /dev/null @@ -1,596 +0,0 @@ -package orchestrator - -import ( - "context" - "fmt" - "strings" - - "github.com/tech/sendico/payments/storage/model" - "github.com/tech/sendico/pkg/ledgerconv" - "github.com/tech/sendico/pkg/merrors" - "github.com/tech/sendico/pkg/model/account_role" - "github.com/tech/sendico/pkg/payments/rail" - moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" - connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1" - ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" - "go.mongodb.org/mongo-driver/v2/bson" - "go.uber.org/zap" -) - -func (p *paymentExecutor) postLedgerDebit(ctx context.Context, payment *model.Payment, amount *moneyv1.Money, charges []*ledgerv1.PostingLine, idempotencyKey string, idx int, action model.RailOperation, quote *sharedv1.PaymentQuote) (string, error) { - paymentRef := "" - if payment != nil { - paymentRef = strings.TrimSpace(payment.PaymentRef) - } - if p.deps.ledger.internal == nil { - p.logger.Error("Ledger client unavailable", zap.String("action", "debit"), zap.String("payment_ref", paymentRef)) - return "", merrors.Internal("ledger_client_unavailable") - } - tx, err := p.ledgerTxForAction(ctx, payment, amount, charges, idempotencyKey, idx, action, quote) - if err != nil { - p.logger.Warn("Ledger debit preparation failed", zap.String("payment_ref", paymentRef), zap.Int("step_index", idx), zap.Error(err)) - return "", err - } - ref, err := p.deps.ledger.internal.CreateTransaction(ctx, tx) - if err != nil { - p.logger.Warn("Ledger debit failed", zap.String("payment_ref", paymentRef), zap.Int("step_index", idx), zap.Error(err)) - return "", err - } - p.logger.Info("Ledger debit posted", - zap.String("payment_ref", paymentRef), - zap.Int("step_index", idx), - zap.String("action", string(action)), - zap.String("entry_ref", strings.TrimSpace(ref))) - return ref, nil -} - -func (p *paymentExecutor) postLedgerCredit(ctx context.Context, payment *model.Payment, amount *moneyv1.Money, idempotencyKey string, idx int, action model.RailOperation, quote *sharedv1.PaymentQuote) (string, error) { - paymentRef := "" - if payment != nil { - paymentRef = strings.TrimSpace(payment.PaymentRef) - } - if p.deps.ledger.internal == nil { - p.logger.Error("Ledger client unavailable", zap.String("action", "credit"), zap.String("payment_ref", paymentRef)) - return "", merrors.Internal("ledger_client_unavailable") - } - tx, err := p.ledgerTxForAction(ctx, payment, amount, nil, idempotencyKey, idx, action, quote) - if err != nil { - p.logger.Warn("Ledger credit preparation failed", zap.String("payment_ref", paymentRef), zap.Int("step_index", idx), zap.Error(err)) - return "", err - } - ref, err := p.deps.ledger.internal.CreateTransaction(ctx, tx) - if err != nil { - p.logger.Warn("Ledger credit failed", zap.String("payment_ref", paymentRef), zap.Int("step_index", idx), zap.Error(err)) - return "", err - } - p.logger.Info("Ledger credit posted", - zap.String("payment_ref", paymentRef), - zap.Int("step_index", idx), - zap.String("action", string(action)), - zap.String("entry_ref", strings.TrimSpace(ref))) - return ref, nil -} - -func (p *paymentExecutor) postLedgerMove(ctx context.Context, payment *model.Payment, step *model.PaymentStep, amount *moneyv1.Money, idempotencyKey string, idx int) (string, error) { - paymentRef := "" - if payment != nil { - paymentRef = strings.TrimSpace(payment.PaymentRef) - } - if p.deps.ledger.internal == nil { - p.logger.Error("Ledger client unavailable", zap.String("action", "move"), zap.String("payment_ref", paymentRef)) - return "", merrors.Internal("ledger_client_unavailable") - } - if payment == nil { - return "", merrors.InvalidArgument("ledger: payment is required") - } - if payment.OrganizationRef == bson.NilObjectID { - return "", merrors.InvalidArgument("ledger: organization_ref is required") - } - if step == nil { - return "", merrors.InvalidArgument("ledger: step is required") - } - if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" { - return "", merrors.InvalidArgument("ledger: amount is required") - } - fromRole, toRole, err := ledgerMoveRoles(step) - if err != nil { - return "", err - } - currency := strings.TrimSpace(amount.GetCurrency()) - fromAccount, err := p.resolveAccount(ctx, payment.OrganizationRef, currency, model.RailLedger, fromRole) - if err != nil { - return "", err - } - toAccount, err := p.resolveAccount(ctx, payment.OrganizationRef, currency, model.RailLedger, toRole) - if err != nil { - return "", err - } - resp, err := p.deps.ledger.internal.TransferInternal(ctx, &ledgerv1.TransferRequest{ - IdempotencyKey: strings.TrimSpace(idempotencyKey), - OrganizationRef: payment.OrganizationRef.Hex(), - FromLedgerAccountRef: strings.TrimSpace(fromAccount), - ToLedgerAccountRef: strings.TrimSpace(toAccount), - Money: cloneProtoMoney(amount), - Description: paymentDescription(payment), - Metadata: cloneMetadata(payment.Metadata), - FromRole: ledgerRoleFromAccountRole(fromRole), - ToRole: ledgerRoleFromAccountRole(toRole), - }) - if err != nil { - p.logger.Warn("Ledger move failed", - zap.String("payment_ref", paymentRef), - zap.Int("step_index", idx), - zap.String("from_role", string(fromRole)), - zap.String("to_role", string(toRole)), - zap.String("from_account", strings.TrimSpace(fromAccount)), - zap.String("to_account", strings.TrimSpace(toAccount)), - zap.String("amount", strings.TrimSpace(amount.GetAmount())), - zap.String("currency", currency), - zap.Error(err)) - return "", err - } - entryRef := strings.TrimSpace(resp.GetJournalEntryRef()) - p.logger.Info("Ledger move posted", - zap.String("payment_ref", paymentRef), - zap.Int("step_index", idx), - zap.String("entry_ref", entryRef), - zap.String("from_role", string(fromRole)), - zap.String("to_role", string(toRole)), - zap.String("from_account", strings.TrimSpace(fromAccount)), - zap.String("to_account", strings.TrimSpace(toAccount)), - zap.String("amount", strings.TrimSpace(amount.GetAmount())), - zap.String("currency", currency)) - return entryRef, nil -} - -func (p *paymentExecutor) ledgerTxForAction(ctx context.Context, payment *model.Payment, amount *moneyv1.Money, charges []*ledgerv1.PostingLine, idempotencyKey string, idx int, action model.RailOperation, quote *sharedv1.PaymentQuote) (rail.LedgerTx, error) { - if payment == nil { - return rail.LedgerTx{}, merrors.InvalidArgument("ledger: payment is required") - } - if payment.OrganizationRef == bson.NilObjectID { - return rail.LedgerTx{}, merrors.InvalidArgument("ledger: organization_ref is required") - } - if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" { - return rail.LedgerTx{}, merrors.InvalidArgument("ledger: amount is required") - } - - sourceRail, _, err := railFromEndpoint(payment.Intent.Source, payment.Intent.Attributes, true) - if err != nil { - sourceRail = model.RailUnspecified - } - destRail, _, err := railFromEndpoint(payment.Intent.Destination, payment.Intent.Attributes, false) - if err != nil { - destRail = model.RailUnspecified - } - - fromRail := model.RailUnspecified - toRail := model.RailUnspecified - accountRef := "" - contraRef := "" - externalRef := "" - operation := "" - - switch action { - case model.RailOperationDebit, model.RailOperationExternalDebit: - fromRail = model.RailLedger - toRail = ledgerStepToRail(payment.PaymentPlan, idx, destRail) - accountRef, contraRef, err = ledgerDebitAccount(payment) - if err != nil { - accountRef, contraRef, err = p.resolveLedgerAccountRef(ctx, payment, amount, action) - } - if err == nil { - if blockRef := ledgerBlockAccountIfConfirmed(payment); blockRef != "" { - accountRef = blockRef - contraRef = "" - } - } - if action == model.RailOperationExternalDebit { - operation = "external.debit" - } - case model.RailOperationCredit, model.RailOperationExternalCredit: - fromRail = ledgerStepFromRail(payment.PaymentPlan, idx, sourceRail) - toRail = model.RailLedger - accountRef, contraRef, err = ledgerCreditAccount(payment) - if err != nil { - accountRef, contraRef, err = p.resolveLedgerAccountRef(ctx, payment, amount, action) - } - externalRef = ledgerExternalReference(payment.ExecutionPlan, idx) - if action == model.RailOperationExternalCredit { - operation = "external.credit" - } - default: - return rail.LedgerTx{}, merrors.InvalidArgument("ledger: unsupported action") - } - if err != nil { - return rail.LedgerTx{}, err - } - isDebit := action == model.RailOperationDebit || action == model.RailOperationExternalDebit - isCredit := action == model.RailOperationCredit || action == model.RailOperationExternalCredit - if isCredit && strings.TrimSpace(accountRef) != "" { - setLedgerAccountAttributes(payment, accountRef) - } - if isDebit && toRail == model.RailLedger { - toRail = model.RailUnspecified - } - if isCredit && fromRail == model.RailLedger { - fromRail = model.RailUnspecified - } - - planID := payment.PaymentRef - if payment.PaymentPlan != nil && strings.TrimSpace(payment.PaymentPlan.ID) != "" { - planID = strings.TrimSpace(payment.PaymentPlan.ID) - } - - feeAmount := "" - if isDebit { - if feeMoney := resolveFeeAmount(payment, quote); feeMoney != nil { - feeAmount = strings.TrimSpace(feeMoney.GetAmount()) - } - } - - fxRate := "" - if quote != nil && quote.GetFxQuote() != nil && quote.GetFxQuote().GetPrice() != nil { - fxRate = strings.TrimSpace(quote.GetFxQuote().GetPrice().GetValue()) - } - - return rail.LedgerTx{ - PaymentPlanID: planID, - Currency: strings.TrimSpace(amount.GetCurrency()), - Amount: strings.TrimSpace(amount.GetAmount()), - FeeAmount: feeAmount, - FromRail: ledgerRailValue(fromRail), - ToRail: ledgerRailValue(toRail), - ExternalReferenceID: externalRef, - Operation: operation, - FXRateUsed: fxRate, - IdempotencyKey: strings.TrimSpace(idempotencyKey), - CreatedAt: planTimestamp(payment), - OrganizationRef: payment.OrganizationRef.Hex(), - LedgerAccountRef: strings.TrimSpace(accountRef), - ContraLedgerAccountRef: strings.TrimSpace(contraRef), - Description: paymentDescription(payment), - Charges: charges, - Metadata: cloneMetadata(payment.Metadata), - }, nil -} - -func ledgerRailValue(railValue model.Rail) string { - if railValue == model.RailUnspecified || strings.TrimSpace(string(railValue)) == "" { - return "" - } - return string(railValue) -} - -func ledgerStepFromRail(plan *model.PaymentPlan, idx int, fallback model.Rail) model.Rail { - if plan == nil || idx <= 0 { - return fallback - } - for i := idx - 1; i >= 0; i-- { - step := plan.Steps[i] - if step == nil { - continue - } - if step.Rail != model.RailLedger && step.Rail != model.RailUnspecified { - return step.Rail - } - } - return fallback -} - -func ledgerStepToRail(plan *model.PaymentPlan, idx int, fallback model.Rail) model.Rail { - if plan == nil || idx < 0 { - return fallback - } - for i := idx + 1; i < len(plan.Steps); i++ { - step := plan.Steps[i] - if step == nil { - continue - } - if step.Rail != model.RailLedger && step.Rail != model.RailUnspecified { - return step.Rail - } - } - return fallback -} - -func ledgerExternalReference(plan *model.ExecutionPlan, idx int) string { - if plan == nil || idx <= 0 { - return "" - } - for i := idx - 1; i >= 0; i-- { - step := plan.Steps[i] - if step == nil { - continue - } - if ref := strings.TrimSpace(step.TransferRef); ref != "" { - return ref - } - } - return "" -} - -func ledgerMoveRoles(step *model.PaymentStep) (account_role.AccountRole, account_role.AccountRole, error) { - if step == nil { - return "", "", merrors.InvalidArgument("ledger: step is required") - } - if step.FromRole == nil || strings.TrimSpace(string(*step.FromRole)) == "" { - return "", "", merrors.InvalidArgument("ledger: from_role is required") - } - if step.ToRole == nil || strings.TrimSpace(string(*step.ToRole)) == "" { - return "", "", merrors.InvalidArgument("ledger: to_role is required") - } - from := strings.ToLower(strings.TrimSpace(string(*step.FromRole))) - to := strings.ToLower(strings.TrimSpace(string(*step.ToRole))) - if from == "" || to == "" || strings.EqualFold(from, to) { - return "", "", merrors.InvalidArgument("ledger: from_role and to_role must differ") - } - return account_role.AccountRole(from), account_role.AccountRole(to), nil -} - -func ledgerRoleFromAccountRole(role account_role.AccountRole) ledgerv1.AccountRole { - if strings.TrimSpace(string(role)) == "" { - return ledgerv1.AccountRole_ACCOUNT_ROLE_UNSPECIFIED - } - if parsed, ok := ledgerconv.ParseAccountRole(string(role)); ok { - return parsed - } - return ledgerv1.AccountRole_ACCOUNT_ROLE_UNSPECIFIED -} - -func (p *paymentExecutor) resolveAccount(ctx context.Context, orgRef bson.ObjectID, asset string, rail model.Rail, role account_role.AccountRole) (string, error) { - switch rail { - case model.RailLedger: - return p.resolveLedgerAccountByRole(ctx, orgRef, asset, role) - default: - return "", nil - } -} - -func (p *paymentExecutor) resolveLedgerAccountByRole(ctx context.Context, orgRef bson.ObjectID, asset string, role account_role.AccountRole) (string, error) { - if p == nil || p.deps == nil || p.deps.ledger.client == nil { - return "", merrors.Internal("ledger_client_unavailable") - } - if orgRef == bson.NilObjectID { - return "", merrors.InvalidArgument("ledger: organization_ref is required") - } - currency := strings.TrimSpace(asset) - if currency == "" { - return "", merrors.InvalidArgument("ledger: asset is required") - } - if strings.TrimSpace(string(role)) == "" { - return "", merrors.InvalidArgument("ledger: role is required") - } - - resp, err := p.deps.ledger.client.ListConnectorAccounts(ctx, &connectorv1.ListAccountsRequest{ - OrganizationRef: orgRef.Hex(), - Kind: connectorv1.AccountKind_LEDGER_ACCOUNT, - Asset: currency, - }) - if err != nil { - return "", err - } - expectedRole := strings.ToLower(strings.TrimSpace(string(role))) - for _, account := range resp.GetAccounts() { - if account == nil { - continue - } - if account.GetKind() != connectorv1.AccountKind_LEDGER_ACCOUNT { - continue - } - if asset := strings.TrimSpace(account.GetAsset()); asset == "" || !strings.EqualFold(asset, currency) { - continue - } - if strings.TrimSpace(account.GetOwnerRef()) != "" { - continue - } - accRole := strings.ToLower(strings.TrimSpace(string(connectorAccountRole(account)))) - if accRole == "" || !strings.EqualFold(accRole, expectedRole) { - continue - } - if ref := account.GetRef(); ref != nil { - if accountID := strings.TrimSpace(ref.GetAccountId()); accountID != "" { - return accountID, nil - } - } - } - return "", merrors.InvalidArgument("ledger: account role not found") -} - -func (p *paymentExecutor) resolveLedgerAccountRef(ctx context.Context, payment *model.Payment, amount *moneyv1.Money, action model.RailOperation) (string, string, error) { - if payment == nil { - return "", "", merrors.InvalidArgument("ledger: payment is required") - } - if amount == nil || strings.TrimSpace(amount.GetCurrency()) == "" { - return "", "", merrors.InvalidArgument("ledger: amount is required") - } - switch action { - case model.RailOperationCredit, model.RailOperationExternalCredit: - if account, _, err := ledgerDebitAccount(payment); err == nil && strings.TrimSpace(account) != "" { - setLedgerAccountAttributes(payment, account) - return account, "", nil - } - case model.RailOperationDebit, model.RailOperationExternalDebit: - if account, _, err := ledgerCreditAccount(payment); err == nil && strings.TrimSpace(account) != "" { - setLedgerAccountAttributes(payment, account) - return account, "", nil - } - } - account, err := p.resolveOrgOwnedLedgerAccount(ctx, payment, amount) - if err != nil { - return "", "", err - } - setLedgerAccountAttributes(payment, account) - return account, "", nil -} - -func (p *paymentExecutor) resolveOrgOwnedLedgerAccount(ctx context.Context, payment *model.Payment, amount *moneyv1.Money) (string, error) { - if payment == nil { - return "", merrors.InvalidArgument("ledger: payment is required") - } - if payment.OrganizationRef == bson.NilObjectID { - return "", merrors.InvalidArgument("ledger: organization_ref is required") - } - if amount == nil || strings.TrimSpace(amount.GetCurrency()) == "" { - return "", merrors.InvalidArgument("ledger: amount is required") - } - if p == nil || p.deps == nil || p.deps.ledger.client == nil { - return "", merrors.Internal("ledger_client_unavailable") - } - - currency := strings.TrimSpace(amount.GetCurrency()) - resp, err := p.deps.ledger.client.ListConnectorAccounts(ctx, &connectorv1.ListAccountsRequest{ - OrganizationRef: payment.OrganizationRef.Hex(), - Kind: connectorv1.AccountKind_LEDGER_ACCOUNT, - Asset: currency, - }) - if err != nil { - return "", err - } - for _, account := range resp.GetAccounts() { - if account == nil { - continue - } - if account.GetKind() != connectorv1.AccountKind_LEDGER_ACCOUNT { - continue - } - asset := strings.TrimSpace(account.GetAsset()) - if asset == "" || !strings.EqualFold(asset, currency) { - continue - } - if strings.TrimSpace(account.GetOwnerRef()) != "" { - continue - } - if connectorAccountIsSettlement(account) { - continue - } - if ref := account.GetRef(); ref != nil { - if accountID := strings.TrimSpace(ref.GetAccountId()); accountID != "" { - return accountID, nil - } - } - } - return "", merrors.InvalidArgument("ledger: org-owned account not found") -} - -func connectorAccountIsSettlement(account *connectorv1.Account) bool { - return connectorAccountRole(account) == account_role.AccountRoleSettlement -} - -func connectorAccountRole(account *connectorv1.Account) account_role.AccountRole { - if account == nil || account.GetProviderDetails() == nil { - return "" - } - details := account.GetProviderDetails().AsMap() - if value := strings.TrimSpace(fmt.Sprint(details["role"])); value != "" { - if role, ok := account_role.Parse(value); ok { - return role - } - } - switch v := details["is_settlement"].(type) { - case bool: - if v { - return account_role.AccountRoleSettlement - } - case string: - if strings.EqualFold(strings.TrimSpace(v), "true") { - return account_role.AccountRoleSettlement - } - } - return "" -} - -func setLedgerAccountAttributes(payment *model.Payment, accountRef string) { - if payment == nil || strings.TrimSpace(accountRef) == "" { - return - } - if payment.Intent.Attributes == nil { - payment.Intent.Attributes = map[string]string{} - } - if attributeLookup(payment.Intent.Attributes, "ledger_debit_account_ref", "ledgerDebitAccountRef") == "" { - payment.Intent.Attributes["ledger_debit_account_ref"] = accountRef - } - if attributeLookup(payment.Intent.Attributes, "ledger_credit_account_ref", "ledgerCreditAccountRef") == "" { - payment.Intent.Attributes["ledger_credit_account_ref"] = accountRef - } -} - -func ledgerDebitAccount(payment *model.Payment) (string, string, error) { - if payment == nil { - return "", "", merrors.InvalidArgument("ledger: payment is required") - } - intent := payment.Intent - if intent.Source.Ledger != nil && strings.TrimSpace(intent.Source.Ledger.LedgerAccountRef) != "" { - return strings.TrimSpace(intent.Source.Ledger.LedgerAccountRef), strings.TrimSpace(intent.Source.Ledger.ContraLedgerAccountRef), nil - } - if ref := attributeLookup(intent.Attributes, "ledger_debit_account_ref", "ledgerDebitAccountRef"); ref != "" { - return ref, strings.TrimSpace(attributeLookup(intent.Attributes, "ledger_debit_contra_account_ref", "ledgerDebitContraAccountRef")), nil - } - return "", "", merrors.InvalidArgument("ledger: source account is required") -} - -func ledgerBlockAccount(payment *model.Payment) (string, error) { - if payment == nil { - return "", merrors.InvalidArgument("ledger: payment is required") - } - intent := payment.Intent - if intent.Source.Ledger != nil { - if ref := strings.TrimSpace(intent.Source.Ledger.ContraLedgerAccountRef); ref != "" { - return ref, nil - } - } - if ref := attributeLookup(intent.Attributes, - "ledger_block_account_ref", - "ledgerBlockAccountRef", - "ledger_hold_account_ref", - "ledgerHoldAccountRef", - "ledger_debit_contra_account_ref", - "ledgerDebitContraAccountRef", - ); ref != "" { - return ref, nil - } - return "", merrors.InvalidArgument("ledger: block account is required") -} - -func ledgerBlockAccountIfConfirmed(payment *model.Payment) string { - if payment == nil { - return "" - } - if !blockStepConfirmed(payment.PaymentPlan, payment.ExecutionPlan) { - return "" - } - ref, err := ledgerBlockAccount(payment) - if err != nil { - return "" - } - return ref -} - -func ledgerCreditAccount(payment *model.Payment) (string, string, error) { - if payment == nil { - return "", "", merrors.InvalidArgument("ledger: payment is required") - } - intent := payment.Intent - if intent.Destination.Ledger != nil && strings.TrimSpace(intent.Destination.Ledger.LedgerAccountRef) != "" { - return strings.TrimSpace(intent.Destination.Ledger.LedgerAccountRef), strings.TrimSpace(intent.Destination.Ledger.ContraLedgerAccountRef), nil - } - if ref := attributeLookup(intent.Attributes, "ledger_credit_account_ref", "ledgerCreditAccountRef"); ref != "" { - return ref, strings.TrimSpace(attributeLookup(intent.Attributes, "ledger_credit_contra_account_ref", "ledgerCreditContraAccountRef")), nil - } - return "", "", merrors.InvalidArgument("ledger: destination account is required") -} - -func attributeLookup(attrs map[string]string, keys ...string) string { - if len(keys) == 0 { - return "" - } - for _, key := range keys { - if key == "" || attrs == nil { - continue - } - if val := strings.TrimSpace(attrs[key]); val != "" { - return val - } - } - return "" -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_ledger_test.go b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_ledger_test.go deleted file mode 100644 index 88525f2a..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_ledger_test.go +++ /dev/null @@ -1,114 +0,0 @@ -package orchestrator - -import ( - "context" - "testing" - - ledgerclient "github.com/tech/sendico/ledger/client" - "github.com/tech/sendico/payments/storage/model" - "github.com/tech/sendico/pkg/model/account_role" - paymenttypes "github.com/tech/sendico/pkg/payments/types" - connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1" - ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" - "go.mongodb.org/mongo-driver/v2/bson" - "go.uber.org/zap" - "google.golang.org/protobuf/types/known/structpb" -) - -func TestLedgerAccountResolution_UsesRoleAccounts(t *testing.T) { - ctx := context.Background() - fromAccountID := "ledger:operating:usd" - toAccountID := "ledger:transit:usd" - - operatingDetails, err := structpb.NewStruct(map[string]interface{}{ - "role": "ACCOUNT_ROLE_OPERATING", - }) - if err != nil { - t.Fatalf("provider details build error: %v", err) - } - transitDetails, err := structpb.NewStruct(map[string]interface{}{ - "role": "ACCOUNT_ROLE_TRANSIT", - }) - if err != nil { - t.Fatalf("provider details build error: %v", err) - } - - listCalls := 0 - var transferReq *ledgerv1.TransferRequest - ledgerFake := &ledgerclient.Fake{ - ListConnectorAccountsFn: func(ctx context.Context, req *connectorv1.ListAccountsRequest) (*connectorv1.ListAccountsResponse, error) { - listCalls++ - return &connectorv1.ListAccountsResponse{ - Accounts: []*connectorv1.Account{ - { - Ref: &connectorv1.AccountRef{ConnectorId: "ledger", AccountId: fromAccountID}, - Kind: connectorv1.AccountKind_LEDGER_ACCOUNT, - Asset: "USD", - OwnerRef: "", - ProviderDetails: operatingDetails, - }, - { - Ref: &connectorv1.AccountRef{ConnectorId: "ledger", AccountId: toAccountID}, - Kind: connectorv1.AccountKind_LEDGER_ACCOUNT, - Asset: "USD", - OwnerRef: "", - ProviderDetails: transitDetails, - }, - }, - }, nil - }, - TransferInternalFn: func(ctx context.Context, req *ledgerv1.TransferRequest) (*ledgerv1.PostResponse, error) { - transferReq = req - return &ledgerv1.PostResponse{JournalEntryRef: "entry-1"}, nil - }, - } - - svc := &Service{ - logger: zap.NewNop(), - deps: serviceDependencies{ - ledger: ledgerDependency{ - client: ledgerFake, - internal: ledgerFake, - }, - }, - } - executor := newPaymentExecutor(&svc.deps, svc.logger, svc) - - amount := &paymenttypes.Money{Currency: "USD", Amount: "10"} - payment := &model.Payment{ - PaymentRef: "pay-1", - IdempotencyKey: "pay-1", - Intent: model.PaymentIntent{ - Ref: "ref-1", - Kind: model.PaymentKindPayout, - }, - PaymentPlan: &model.PaymentPlan{ - ID: "pay-1", - IdempotencyKey: "pay-1", - Steps: []*model.PaymentStep{ - {StepID: "ledger_move", Rail: model.RailLedger, Action: model.RailOperationMove, Amount: cloneMoney(amount), FromRole: rolePtr(account_role.AccountRoleOperating), ToRole: rolePtr(account_role.AccountRoleTransit)}, - }, - }, - } - payment.OrganizationRef = bson.NewObjectID() - - store := newStubPaymentsStore() - store.payments[payment.PaymentRef] = payment - - if err := executor.executePaymentPlan(ctx, store, payment, &sharedv1.PaymentQuote{}); err != nil { - t.Fatalf("executePaymentPlan error: %v", err) - } - if listCalls == 0 { - t.Fatalf("expected ledger accounts lookup") - } - if transferReq == nil { - t.Fatalf("expected ledger transfer") - } - if transferReq.GetFromLedgerAccountRef() != fromAccountID { - t.Fatalf("expected from account %s, got %s", fromAccountID, transferReq.GetFromLedgerAccountRef()) - } - if transferReq.GetToLedgerAccountRef() != toAccountID { - t.Fatalf("expected to account %s, got %s", toAccountID, transferReq.GetToLedgerAccountRef()) - } -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_release.go b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_release.go deleted file mode 100644 index d0362fb8..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_release.go +++ /dev/null @@ -1,50 +0,0 @@ -package orchestrator - -import ( - "context" - - "github.com/tech/sendico/payments/storage" - "github.com/tech/sendico/payments/storage/model" - "go.uber.org/zap" -) - -func (p *paymentExecutor) releasePaymentHold(ctx context.Context, store storage.PaymentsStore, payment *model.Payment) error { - if store == nil { - return errStorageUnavailable - } - if payment == nil || payment.PaymentPlan == nil || len(payment.PaymentPlan.Steps) == 0 { - return nil - } - execPlan := ensureExecutionPlanForPlan(payment, payment.PaymentPlan) - if execPlan == nil || !blockStepConfirmed(payment.PaymentPlan, execPlan) { - return nil - } - execSteps := executionStepsByCode(execPlan) - execQuote := executionQuote(payment, nil) - - for idx, step := range payment.PaymentPlan.Steps { - if step == nil || step.Action != model.RailOperationRelease { - continue - } - stepID := planStepID(step, idx) - execStep := execSteps[stepID] - if execStep == nil { - execStep = &model.ExecutionStep{Code: stepID} - execSteps[stepID] = execStep - if idx < len(execPlan.Steps) { - execPlan.Steps[idx] = execStep - } - } - if execStep.State == model.OperationStateSuccess { - p.logger.Debug("Payment step already confirmed, skipping", zap.String("step_id", stepID), zap.String("quutation", execQuote.QuoteRef)) - continue - } - if _, err := p.executePlanStep(ctx, payment, step, execStep, execQuote, nil, idx); err != nil { - p.logger.Warn("Failed to execute payment step", zap.Error(err), - zap.String("step_id", stepID), zap.String("quutation", execQuote.QuoteRef)) - return err - } - } - - return p.persistPayment(ctx, store, payment) -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_release_test.go b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_release_test.go deleted file mode 100644 index be8ed450..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_release_test.go +++ /dev/null @@ -1,84 +0,0 @@ -package orchestrator - -import ( - "context" - "strings" - "testing" - - ledgerclient "github.com/tech/sendico/ledger/client" - "github.com/tech/sendico/payments/storage/model" - mo "github.com/tech/sendico/pkg/model" - paymenttypes "github.com/tech/sendico/pkg/payments/types" - "go.mongodb.org/mongo-driver/v2/bson" - "go.uber.org/zap" -) - -func TestReleasePaymentHold_RejectsLegacyLedgerRelease(t *testing.T) { - ctx := context.Background() - - store := newStubPaymentsStore() - repo := &stubRepository{store: store} - - ledgerFake := &ledgerclient.Fake{} - - svc := &Service{ - logger: zap.NewNop(), - storage: repo, - deps: serviceDependencies{ - ledger: ledgerDependency{ - client: ledgerFake, - internal: ledgerFake, - }, - }, - } - - executor := newPaymentExecutor(&svc.deps, svc.logger, svc) - - payment := &model.Payment{ - PaymentRef: "pay-release-1", - IdempotencyKey: "pay-release-1", - OrganizationBoundBase: mo.OrganizationBoundBase{ - OrganizationRef: bson.NewObjectID(), - }, - Intent: model.PaymentIntent{ - Ref: "ref-release-1", - Kind: model.PaymentKindPayout, - Source: model.PaymentEndpoint{ - Type: model.EndpointTypeManagedWallet, - ManagedWallet: &model.ManagedWalletEndpoint{ - ManagedWalletRef: "wallet-src", - }, - }, - Attributes: map[string]string{ - "ledger_debit_account_ref": "ledger:debit", - "ledger_block_account_ref": "ledger:block", - }, - }, - PaymentPlan: &model.PaymentPlan{ - ID: "pay-release-1", - IdempotencyKey: "pay-release-1", - Steps: []*model.PaymentStep{ - {StepID: "ledger_block", Rail: model.RailLedger, Action: model.RailOperationBlock, Amount: &paymenttypes.Money{Currency: "USD", Amount: "100"}}, - {StepID: "ledger_release", Rail: model.RailLedger, Action: model.RailOperationRelease, DependsOn: []string{"ledger_block"}, Amount: &paymenttypes.Money{Currency: "USD", Amount: "100"}}, - }, - }, - } - - store.payments[payment.PaymentRef] = payment - - payment.ExecutionPlan = ensureExecutionPlanForPlan(payment, payment.PaymentPlan) - steps := executionStepsByCode(payment.ExecutionPlan) - blockStep := steps["ledger_block"] - if blockStep == nil { - t.Fatalf("expected block step in execution plan") - } - setExecutionStepStatus(blockStep, model.OperationStateSuccess) - - err := executor.releasePaymentHold(ctx, store, payment) - if err == nil { - t.Fatal("expected legacy ledger operation error") - } - if !strings.Contains(err.Error(), "unsupported action") { - t.Fatalf("unexpected error: %v", err) - } -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_steps.go b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_steps.go deleted file mode 100644 index ef2a7d4d..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_steps.go +++ /dev/null @@ -1,427 +0,0 @@ -package orchestrator - -import ( - "context" - "strings" - - "github.com/tech/sendico/payments/storage/model" - "github.com/tech/sendico/pkg/merrors" - ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" - "go.uber.org/zap" -) - -func (p *paymentExecutor) executePlanStep( - ctx context.Context, - payment *model.Payment, - step *model.PaymentStep, - execStep *model.ExecutionStep, - quote *sharedv1.PaymentQuote, - charges []*ledgerv1.PostingLine, - idx int, -) (bool, error) { - - if payment == nil || step == nil || execStep == nil { - return false, merrors.InvalidArgument("payment plan: step is required") - } - - stepID := execStep.Code - logger := p.logger.With( - zap.String("payment_ref", payment.PaymentRef), - zap.String("step_id", stepID), - zap.String("rail", string(step.Rail)), - zap.String("action", string(step.Action)), - zap.Int("idx", idx), - ) - - logger.Debug("Executing payment plan step") - - if execStep.IsTerminal() { - logger.Debug("Step already in terminal state, skipping execution", - zap.String("state", string(execStep.State)), - ) - return false, nil - } - - switch step.Action { - - case model.RailOperationMove: - logger.Debug("Posting ledger move") - amount, err := requireMoney(cloneMoney(step.Amount), "ledger move amount") - if err != nil { - logger.Warn("Ledger move amount invalid", zap.Error(err)) - return false, err - } - ref, err := p.postLedgerMove(ctx, payment, step, protoMoney(amount), planStepIdempotencyKey(payment, idx, step), idx) - if err != nil { - logger.Warn("Ledger move failed", zap.Error(err)) - return false, err - } - execStep.TransferRef = strings.TrimSpace(ref) - setExecutionStepStatus(execStep, model.OperationStateSuccess) - logger.Info("Ledger move completed", zap.String("journal_ref", ref)) - return false, nil - - case model.RailOperationDebit, model.RailOperationExternalDebit: - logger.Debug("Posting ledger debit") - amount, err := requireMoney(cloneMoney(step.Amount), "ledger debit amount") - if err != nil { - logger.Warn("Ledger debit amount invalid", zap.Error(err)) - return false, err - } - ref, err := p.postLedgerDebit(ctx, payment, protoMoney(amount), charges, planStepIdempotencyKey(payment, idx, step), idx, step.Action, quote) - if err != nil { - logger.Warn("Ledger debit failed", zap.Error(err)) - return false, err - } - ensureExecutionRefs(payment).DebitEntryRef = ref - setExecutionStepStatus(execStep, model.OperationStateSuccess) - logger.Info("Ledger debit completed", zap.String("journal_ref", ref)) - return false, nil - - case model.RailOperationCredit, model.RailOperationExternalCredit: - logger.Debug("Posting ledger credit") - amount, err := requireMoney(cloneMoney(step.Amount), "ledger credit amount") - if err != nil { - logger.Warn("Ledger credit amount invalid", zap.Error(err)) - return false, err - } - ref, err := p.postLedgerCredit(ctx, payment, protoMoney(amount), planStepIdempotencyKey(payment, idx, step), idx, step.Action, quote) - if err != nil { - logger.Warn("Ledger credit failed", zap.Error(err)) - return false, err - } - ensureExecutionRefs(payment).CreditEntryRef = ref - setExecutionStepStatus(execStep, model.OperationStateSuccess) - logger.Info("Ledger credit completed", zap.String("journal_ref", ref)) - return false, nil - - case model.RailOperationFXConvert: - logger.Debug("Applying FX conversion") - if err := p.applyFX(ctx, payment, quote, charges, paymentDescription(payment), cloneMetadata(payment.Metadata), ensureExecutionRefs(payment)); err != nil { - logger.Warn("FX conversion failed", zap.Error(err)) - return false, err - } - setExecutionStepStatus(execStep, model.OperationStateSuccess) - logger.Info("FX conversion completed") - return false, nil - - case model.RailOperationObserveConfirm: - setExecutionStepStatus(execStep, model.OperationStateWaiting) - logger.Info("ObserveConfirm step set to waiting for external confirmation") - return true, nil - - case model.RailOperationSend: - logger.Debug("Executing send step") - async, err := p.executeSendStep(ctx, payment, step, execStep, quote, idx) - if err != nil { - setExecutionStepStatus(execStep, model.OperationStateFailed) - execStep.Error = err.Error() - logger.Warn("Send step failed", zap.Error(err)) - return false, err - } - - return async, nil - - case model.RailOperationFee: - logger.Debug("Executing fee step") - async, err := p.executeFeeStep(ctx, payment, step, execStep, idx) - if err != nil { - logger.Warn("Fee step failed", zap.Error(err)) - return false, err - } - logger.Info("Fee step submitted") - return async, nil - - default: - logger.Warn("Unsupported payment plan action") - return false, merrors.InvalidArgument("payment plan: unsupported action") - } -} - -func (p *paymentExecutor) executeSendStep( - ctx context.Context, - payment *model.Payment, - step *model.PaymentStep, - execStep *model.ExecutionStep, - quote *sharedv1.PaymentQuote, - idx int, -) (bool, error) { - - stepID := execStep.Code - logger := p.logger.With( - zap.String("payment_ref", payment.PaymentRef), - zap.String("step_id", stepID), - zap.String("rail", string(step.Rail)), - zap.String("action", string(step.Action)), - zap.Int("idx", idx), - ) - - logger.Debug("Executing send step") - - switch step.Rail { - - case model.RailCrypto: - logger.Debug("Preparing crypto transfer") - - amount, err := requireMoney(cloneMoney(step.Amount), "crypto send amount") - if err != nil { - logger.Warn("Invalid crypto amount", zap.Error(err)) - return false, err - } - - if !p.deps.railGateways.available() { - logger.Warn("Rail gateway unavailable") - return false, merrors.Internal("rail gateway unavailable") - } - - fromRole, toRole := roleHintsForStep(payment.PaymentPlan, idx) - req, err := p.buildCryptoTransferRequest( - payment, - amount, - model.RailOperationSend, - planStepIdempotencyKey(payment, idx, step), - execStep.OperationRef, - quote, - fromRole, toRole, - ) - if err != nil { - logger.Warn("Failed to build crypto transfer request", zap.Error(err)) - return false, err - } - - gw, err := p.deps.railGateways.resolve(ctx, step) - if err != nil { - logger.Warn("Failed to resolve rail gateway", zap.Error(err)) - return false, err - } - - logger.Debug("Sending crypto transfer", - zap.String("idempotency", req.IdempotencyKey), zap.String("intent_ref", req.IntentRef), - zap.String("operation_ref", req.OperationRef), - ) - - result, err := gw.Send(ctx, req) - if err != nil { - execStep.Error = strings.TrimSpace(err.Error()) - setExecutionStepStatus(execStep, model.OperationStateFailed) - - payment.State = model.PaymentStateFailed - payment.FailureCode = model.PaymentFailureCodeChain - - logger.Warn("Send failed; step marked as failed", zap.Error(err)) - return false, nil - } - - execStep.TransferRef = strings.TrimSpace(result.ReferenceID) - logger.Info("Crypto transfer submitted", - zap.String("transfer_ref", execStep.TransferRef), - ) - - exec := ensureExecutionRefs(payment) - if exec.ChainTransferRef == "" && execStep.TransferRef != "" { - exec.ChainTransferRef = execStep.TransferRef - } - - if execStep.TransferRef != "" { - linkRailObservation(payment, step.Rail, execStep.TransferRef, stepID) - } - - setExecutionStepStatus(execStep, model.OperationStateWaiting) - return true, nil - - case model.RailCardPayout: - logger.Debug("Submitting card payout") - - amount, err := requireMoney(cloneMoney(step.Amount), "card payout amount") - if err != nil { - logger.Warn("Invalid card payout amount", zap.Error(err)) - return false, err - } - - fromRole, toRole := roleHintsForStep(payment.PaymentPlan, idx) - ref, err := p.submitCardPayoutPlan( - ctx, - payment, - execStep.OperationRef, - protoMoney(amount), - fromRole, toRole, - ) - if err != nil { - logger.Warn("Card payout submission failed", zap.Error(err)) - return false, err - } - - execStep.TransferRef = ref - ensureExecutionRefs(payment).CardPayoutRef = ref - - logger.Info("Card payout submitted", zap.String("payout_ref", ref)) - - setExecutionStepStatus(execStep, model.OperationStateWaiting) - return true, nil - - case model.RailProviderSettlement: - logger.Debug("Preparing provider settlement transfer") - - amount, err := requireMoney(cloneMoney(payment.LastQuote.DebitSettlementAmount), "provider settlement amount") - if err != nil { - logger.Warn("Invalid provider settlement amount", zap.Error(err), zap.Any("settlement", payment.LastQuote.DebitSettlementAmount)) - return false, err - } - logger.Debug("Expected settlement amount", zap.String("amount", amount.Amount), zap.String("currency", amount.Currency)) - fee, err := requireMoney(cloneMoney(payment.LastQuote.ExpectedFeeTotal), "provider settlement amount") - if err != nil { - logger.Warn("Invalid fee settlement amount", zap.Error(err)) - return false, err - } - if fee.Currency != amount.Currency { - logger.Warn("Fee and amount currencies do not match", - zap.String("amount_currency", amount.Currency), zap.String("fee_currency", fee.Currency), - ) - return false, merrors.DataConflict("settlement payment: currencies mismatch") - } - - if !p.deps.railGateways.available() { - logger.Warn("Rail gateway unavailable") - return false, merrors.Internal("rail gateway unavailable") - } - - fromRole, toRole := roleHintsForStep(payment.PaymentPlan, idx) - req, err := p.buildProviderSettlementTransferRequest( - payment, - step, - execStep.OperationRef, - amount, - quote, - idx, - fromRole, toRole) - if err != nil { - logger.Warn("Failed to build provider settlement request", zap.Error(err)) - return false, err - } - - gw, err := p.deps.railGateways.resolve(ctx, step) - if err != nil { - logger.Warn("Failed to resolve rail gateway", zap.Error(err)) - return false, err - } - - logger.Info("Sending provider settlement transfer", - zap.String("idempotency", req.IdempotencyKey), zap.String("intent_ref", req.IntentRef), - ) - - result, err := gw.Send(ctx, req) - if err != nil { - execStep.Error = strings.TrimSpace(err.Error()) - setExecutionStepStatus(execStep, model.OperationStateFailed) - - payment.State = model.PaymentStateFailed - payment.FailureCode = model.PaymentFailureCodeSettlement - - logger.Warn("Send failed; step marked as failed", zap.Error(err)) - return false, nil - } - - execStep.TransferRef = strings.TrimSpace(result.ReferenceID) - if execStep.TransferRef == "" { - execStep.TransferRef = strings.TrimSpace(req.IdempotencyKey) - } - - logger.Info("Provider settlement submitted", - zap.String("transfer_ref", execStep.TransferRef), - ) - - linkProviderSettlementObservation(payment, execStep.TransferRef) - setExecutionStepStatus(execStep, model.OperationStateWaiting) - return true, nil - - case model.RailFiatOnRamp: - logger.Warn("Fiat on-ramp not implemented") - return false, merrors.InvalidArgument("payment plan: fiat on-ramp execution not implemented") - - default: - logger.Warn("Unsupported send rail") - return false, merrors.InvalidArgument("payment plan: unsupported send rail") - } -} - -func (p *paymentExecutor) executeFeeStep( - ctx context.Context, - payment *model.Payment, - step *model.PaymentStep, - execStep *model.ExecutionStep, - idx int, -) (bool, error) { - - if payment == nil || step == nil || execStep == nil { - return false, merrors.InvalidArgument("payment plan: fee step is required") - } - - switch step.Rail { - - case model.RailCrypto: - amount, err := requireMoney(cloneMoney(step.Amount), "crypto fee amount") - if err != nil { - return false, err - } - - if !p.deps.railGateways.available() { - return false, merrors.Internal("rail gateway unavailable") - } - - fromRole, toRole := roleHintsForStep(payment.PaymentPlan, idx) - - req, err := p.buildCryptoTransferRequest( - payment, - amount, - model.RailOperationFee, - planStepIdempotencyKey(payment, idx, step), - execStep.OperationRef, - nil, - fromRole, - toRole, - ) - if err != nil { - return false, err - } - - gw, err := p.deps.railGateways.resolve(ctx, step) - if err != nil { - return false, err - } - - p.logger.Debug("Executing crypto fee transfer", - zap.String("payment_ref", payment.PaymentRef), - zap.String("step_id", planStepID(step, idx)), - zap.String("amount", amount.GetAmount()), - zap.String("currency", amount.GetCurrency()), - ) - - result, err := gw.Send(ctx, req) - if err != nil { - p.logger.Warn("Crypto fee transfer failed to submit", zap.Error(err), - zap.String("payment_ref", payment.PaymentRef), - ) - return false, nil - } - - execStep.TransferRef = strings.TrimSpace(result.ReferenceID) - - if execStep.TransferRef != "" { - ensureExecutionRefs(payment).FeeTransferRef = execStep.TransferRef - } - - // ВАЖНО: больше не Submitted - setExecutionStepStatus(execStep, model.OperationStateWaiting) - - p.logger.Info("Crypto fee transfer submitted, waiting confirmation", - zap.String("payment_ref", payment.PaymentRef), - zap.String("transfer_ref", execStep.TransferRef), - ) - - return true, nil - - default: - return false, merrors.InvalidArgument("payment plan: unsupported fee rail") - } -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_storage.go b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_storage.go deleted file mode 100644 index 2cf52afd..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_storage.go +++ /dev/null @@ -1,112 +0,0 @@ -package orchestrator - -import ( - "strings" - - "github.com/tech/sendico/payments/storage/model" - paymenttypes "github.com/tech/sendico/pkg/payments/types" -) - -func attachStoredPlan(payment *model.Payment, plan *model.PaymentPlan, idempotencyKey string) { - if payment == nil || plan == nil { - return - } - cloned := cloneStoredPaymentPlan(plan) - if cloned == nil { - return - } - if strings.TrimSpace(cloned.ID) == "" { - cloned.ID = strings.TrimSpace(payment.PaymentRef) - } - if strings.TrimSpace(cloned.IdempotencyKey) == "" { - cloned.IdempotencyKey = strings.TrimSpace(idempotencyKey) - } - payment.PaymentPlan = cloned -} - -func cloneStoredPaymentPlan(src *model.PaymentPlan) *model.PaymentPlan { - if src == nil { - return nil - } - clone := &model.PaymentPlan{ - ID: strings.TrimSpace(src.ID), - IdempotencyKey: strings.TrimSpace(src.IdempotencyKey), - CreatedAt: src.CreatedAt, - FXQuote: cloneStoredFXQuote(src.FXQuote), - Fees: cloneStoredFeeLines(src.Fees), - } - if len(src.Steps) > 0 { - clone.Steps = make([]*model.PaymentStep, 0, len(src.Steps)) - for _, step := range src.Steps { - if step == nil { - clone.Steps = append(clone.Steps, nil) - continue - } - stepClone := &model.PaymentStep{ - StepID: strings.TrimSpace(step.StepID), - Rail: step.Rail, - GatewayID: strings.TrimSpace(step.GatewayID), - InstanceID: strings.TrimSpace(step.InstanceID), - GatewayInvokeURI: strings.TrimSpace(step.GatewayInvokeURI), - Action: step.Action, - ReportVisibility: step.ReportVisibility, - DependsOn: cloneStringList(step.DependsOn), - CommitPolicy: step.CommitPolicy, - CommitAfter: cloneStringList(step.CommitAfter), - Amount: cloneMoney(step.Amount), - FromRole: cloneAccountRole(step.FromRole), - ToRole: cloneAccountRole(step.ToRole), - } - clone.Steps = append(clone.Steps, stepClone) - } - } - return clone -} - -func cloneStoredFXQuote(src *paymenttypes.FXQuote) *paymenttypes.FXQuote { - if src == nil { - return nil - } - result := &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 { - result.Pair = &paymenttypes.CurrencyPair{ - Base: strings.TrimSpace(src.Pair.Base), - Quote: strings.TrimSpace(src.Pair.Quote), - } - } - if src.Price != nil { - result.Price = &paymenttypes.Decimal{Value: strings.TrimSpace(src.Price.Value)} - } - return result -} - -func cloneStoredFeeLines(lines []*paymenttypes.FeeLine) []*paymenttypes.FeeLine { - if len(lines) == 0 { - return nil - } - result := make([]*paymenttypes.FeeLine, 0, len(lines)) - for _, line := range lines { - if line == nil { - result = append(result, nil) - continue - } - result = append(result, &paymenttypes.FeeLine{ - LedgerAccountRef: strings.TrimSpace(line.LedgerAccountRef), - Money: cloneMoney(line.Money), - LineType: line.LineType, - Side: line.Side, - Meta: cloneMetadata(line.Meta), - }) - } - return result -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/plan_builder.go b/api/payments/orchestrator/internal/service/orchestrator/plan_builder.go deleted file mode 100644 index 1691878b..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/plan_builder.go +++ /dev/null @@ -1,7 +0,0 @@ -package orchestrator - -import "github.com/tech/sendico/payments/orchestrator/internal/service/plan_builder" - -// GatewayRegistry re-exports the plan_builder.GatewayRegistry interface for use -// within the orchestrator package (gateway_registry.go, options.go, etc.). -type GatewayRegistry = plan_builder.GatewayRegistry diff --git a/api/payments/orchestrator/internal/service/orchestrator/provider_settlement.go b/api/payments/orchestrator/internal/service/orchestrator/provider_settlement.go deleted file mode 100644 index cbebc77d..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/provider_settlement.go +++ /dev/null @@ -1,132 +0,0 @@ -package orchestrator - -import ( - "strings" - - "github.com/tech/sendico/payments/storage/model" - "github.com/tech/sendico/pkg/merrors" - "github.com/tech/sendico/pkg/model/account_role" - "github.com/tech/sendico/pkg/payments/rail" - paymenttypes "github.com/tech/sendico/pkg/payments/types" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" -) - -const ( - providerSettlementMetaPaymentIntentID = "payment_ref" - providerSettlementMetaQuoteRef = "quote_ref" - providerSettlementMetaTargetChatID = "target_chat_id" - providerSettlementMetaOutgoingLeg = "outgoing_leg" - providerSettlementMetaSourceAmount = "source_amount" - providerSettlementMetaSourceCurrency = "source_currency" -) - -func (p *paymentExecutor) buildProviderSettlementTransferRequest(payment *model.Payment, step *model.PaymentStep, operationRef string, amount *paymenttypes.Money, quote *sharedv1.PaymentQuote, idx int, fromRole, toRole *account_role.AccountRole) (rail.TransferRequest, error) { - if payment == nil || step == nil { - return rail.TransferRequest{}, merrors.InvalidArgument("provider settlement: payment and step are required") - } - if amount == nil { - return rail.TransferRequest{}, merrors.InvalidArgument("provider settlement: amount is required") - } - requestID := planStepIdempotencyKey(payment, idx, step) - if requestID == "" { - return rail.TransferRequest{}, merrors.InvalidArgument("provider settlement: idempotency key is required") - } - intentRef := strings.TrimSpace(payment.Intent.Ref) - if intentRef == "" { - return rail.TransferRequest{}, merrors.InvalidArgument("provider settlement: intention ref is required") - } - paymentRef := strings.TrimSpace(payment.PaymentRef) - if paymentRef == "" { - return rail.TransferRequest{}, merrors.InvalidArgument("provider settlement: payment_ref is required") - } - metadata := cloneMetadata(payment.Metadata) - if metadata == nil { - metadata = map[string]string{} - } - metadata[providerSettlementMetaPaymentIntentID] = paymentRef - if quoteRef := paymentGatewayQuoteRef(payment, quote); quoteRef != "" { - metadata[providerSettlementMetaQuoteRef] = quoteRef - } - if chatID := paymentGatewayTargetChatID(payment); chatID != "" { - metadata[providerSettlementMetaTargetChatID] = chatID - } - if strings.TrimSpace(metadata[providerSettlementMetaOutgoingLeg]) == "" { - metadata[providerSettlementMetaOutgoingLeg] = strings.ToLower(strings.TrimSpace(string(step.Rail))) - } - if strings.TrimSpace(metadata[providerSettlementMetaSourceAmount]) == "" { - metadata[providerSettlementMetaSourceAmount] = strings.TrimSpace(amount.Amount) - } - if strings.TrimSpace(metadata[providerSettlementMetaSourceCurrency]) == "" { - metadata[providerSettlementMetaSourceCurrency] = strings.TrimSpace(amount.Currency) - } - - sourceWalletRef := "" - if payment.Intent.Source.ManagedWallet != nil { - sourceWalletRef = strings.TrimSpace(payment.Intent.Source.ManagedWallet.ManagedWalletRef) - } - if sourceWalletRef == "" { - return rail.TransferRequest{}, merrors.InvalidArgument("provider settlement: source managed wallet is required") - } - - destRef := "" - if payment.Intent.Destination.Type == model.EndpointTypeCard { - if route, err := p.resolveCardRoute(payment.Intent); err == nil { - destRef = strings.TrimSpace(route.FundingAddress) - } - } - if destRef == "" { - destRef = paymentRef - } - - req := rail.TransferRequest{ - OrganizationRef: payment.OrganizationRef.Hex(), - FromAccountID: sourceWalletRef, - ToAccountID: destRef, - Currency: strings.TrimSpace(amount.GetCurrency()), - Amount: strings.TrimSpace(amount.GetAmount()), - IdempotencyKey: requestID, - DestinationMemo: paymentRef, - Metadata: metadata, - PaymentRef: paymentRef, - OperationRef: operationRef, - IntentRef: intentRef, - } - if fromRole != nil { - req.FromRole = *fromRole - } - if toRole != nil { - req.ToRole = *toRole - } - return req, nil -} - -func paymentGatewayQuoteRef(payment *model.Payment, quote *sharedv1.PaymentQuote) string { - if quote != nil { - if ref := strings.TrimSpace(quote.GetQuoteRef()); ref != "" { - return ref - } - } - if payment != nil && payment.LastQuote != nil { - return strings.TrimSpace(payment.LastQuote.QuoteRef) - } - return "" -} - -func paymentGatewayTargetChatID(payment *model.Payment) string { - if payment == nil { - return "" - } - if payment.Intent.Attributes != nil { - if chatID := strings.TrimSpace(payment.Intent.Attributes["target_chat_id"]); chatID != "" { - return chatID - } - } - if payment.Metadata != nil { - return strings.TrimSpace(payment.Metadata["target_chat_id"]) - } - return "" -} - -func linkProviderSettlementObservation(payment *model.Payment, requestID string) { - linkRailObservation(payment, model.RailProviderSettlement, requestID, "") -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/provider_settlement_gateway.go b/api/payments/orchestrator/internal/service/orchestrator/provider_settlement_gateway.go deleted file mode 100644 index dbdc8093..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/provider_settlement_gateway.go +++ /dev/null @@ -1,180 +0,0 @@ -package orchestrator - -import ( - "context" - "strings" - - chainclient "github.com/tech/sendico/gateway/chain/client" - "github.com/tech/sendico/pkg/merrors" - "github.com/tech/sendico/pkg/payments/rail" - moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" - chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" -) - -type providerSettlementGateway struct { - client chainclient.Client - rail string - network string - capabilities rail.RailCapabilities -} - -func NewProviderSettlementGateway(client chainclient.Client, cfg chainclient.RailGatewayConfig) rail.RailGateway { - railName := strings.ToUpper(strings.TrimSpace(cfg.Rail)) - if railName == "" { - railName = "PROVIDER_SETTLEMENT" - } - return &providerSettlementGateway{ - client: client, - rail: railName, - network: strings.ToUpper(strings.TrimSpace(cfg.Network)), - capabilities: cfg.Capabilities, - } -} - -func (g *providerSettlementGateway) Rail() string { - return g.rail -} - -func (g *providerSettlementGateway) Network() string { - return g.network -} - -func (g *providerSettlementGateway) Capabilities() rail.RailCapabilities { - return g.capabilities -} - -func (g *providerSettlementGateway) Send(ctx context.Context, req rail.TransferRequest) (rail.RailResult, error) { - if g.client == nil { - return rail.RailResult{}, merrors.Internal("provider settlement gateway: client is required") - } - idempotencyKey := strings.TrimSpace(req.IdempotencyKey) - if idempotencyKey == "" { - return rail.RailResult{}, merrors.InvalidArgument("provider settlement gateway: idempotency_key is required") - } - currency := strings.TrimSpace(req.Currency) - amount := strings.TrimSpace(req.Amount) - if currency == "" || amount == "" { - return rail.RailResult{}, merrors.InvalidArgument("provider settlement gateway: amount is required") - } - metadata := cloneMetadata(req.Metadata) - if metadata == nil { - metadata = map[string]string{} - } - if strings.TrimSpace(metadata[providerSettlementMetaPaymentIntentID]) == "" { - if ref := strings.TrimSpace(req.PaymentRef); ref != "" { - metadata[providerSettlementMetaPaymentIntentID] = ref - } - } - if strings.TrimSpace(metadata[providerSettlementMetaPaymentIntentID]) == "" { - return rail.RailResult{}, merrors.InvalidArgument("provider settlement gateway: payment_intent_id is required") - } - if strings.TrimSpace(metadata[providerSettlementMetaOutgoingLeg]) == "" && g.rail != "" { - metadata[providerSettlementMetaOutgoingLeg] = strings.ToLower(strings.TrimSpace(g.rail)) - } - submitReq := &chainv1.SubmitTransferRequest{ - IdempotencyKey: idempotencyKey, - OrganizationRef: strings.TrimSpace(req.OrganizationRef), - SourceWalletRef: strings.TrimSpace(req.FromAccountID), - Amount: &moneyv1.Money{ - Currency: currency, - Amount: amount, - }, - Metadata: metadata, - PaymentRef: strings.TrimSpace(req.PaymentRef), - IntentRef: req.IntentRef, - OperationRef: req.OperationRef, - } - if dest := buildProviderSettlementDestination(req); dest != nil { - submitReq.Destination = dest - } - resp, err := g.client.SubmitTransfer(ctx, submitReq) - if err != nil { - return rail.RailResult{}, err - } - if resp == nil || resp.GetTransfer() == nil { - return rail.RailResult{}, merrors.Internal("provider settlement gateway: missing transfer response") - } - transfer := resp.GetTransfer() - return rail.RailResult{ - ReferenceID: strings.TrimSpace(transfer.GetTransferRef()), - Status: providerSettlementStatusFromTransfer(transfer.GetStatus()), - FinalAmount: railMoneyFromProto(transfer.GetNetAmount()), - }, nil -} - -func (g *providerSettlementGateway) Observe(ctx context.Context, referenceID string) (rail.ObserveResult, error) { - if g.client == nil { - return rail.ObserveResult{}, merrors.Internal("provider settlement gateway: client is required") - } - ref := strings.TrimSpace(referenceID) - if ref == "" { - return rail.ObserveResult{}, merrors.InvalidArgument("provider settlement gateway: reference_id is required") - } - resp, err := g.client.GetTransfer(ctx, &chainv1.GetTransferRequest{TransferRef: ref}) - if err != nil { - return rail.ObserveResult{}, err - } - if resp == nil || resp.GetTransfer() == nil { - return rail.ObserveResult{}, merrors.Internal("provider settlement gateway: missing transfer response") - } - transfer := resp.GetTransfer() - return rail.ObserveResult{ - ReferenceID: ref, - Status: providerSettlementStatusFromTransfer(transfer.GetStatus()), - FinalAmount: railMoneyFromProto(transfer.GetNetAmount()), - }, nil -} - -func (g *providerSettlementGateway) Block(ctx context.Context, req rail.BlockRequest) (rail.RailResult, error) { - return rail.RailResult{}, merrors.NotImplemented("provider settlement gateway: block not supported") -} - -func (g *providerSettlementGateway) Release(ctx context.Context, req rail.ReleaseRequest) (rail.RailResult, error) { - return rail.RailResult{}, merrors.NotImplemented("provider settlement gateway: release not supported") -} - -func buildProviderSettlementDestination(req rail.TransferRequest) *chainv1.TransferDestination { - destRef := strings.TrimSpace(req.ToAccountID) - memo := strings.TrimSpace(req.DestinationMemo) - if destRef == "" && memo == "" { - return nil - } - return &chainv1.TransferDestination{ - Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: destRef}, - Memo: memo, - } -} - -func providerSettlementStatusFromTransfer(status chainv1.TransferStatus) rail.TransferStatus { - switch status { - - case chainv1.TransferStatus_TRANSFER_SUCCESS: - return rail.TransferStatusSuccess - - case chainv1.TransferStatus_TRANSFER_FAILED: - return rail.TransferStatusFailed - - case chainv1.TransferStatus_TRANSFER_CANCELLED: - // our cancellation, not from provider - return rail.TransferStatusFailed - - default: - // CREATED, PROCESSING, WAITING - return rail.TransferStatusWaiting - } -} - -func railMoneyFromProto(src *moneyv1.Money) *rail.Money { - if src == nil { - return nil - } - currency := strings.TrimSpace(src.GetCurrency()) - amount := strings.TrimSpace(src.GetAmount()) - if currency == "" || amount == "" { - return nil - } - return &rail.Money{ - Amount: amount, - Currency: currency, - } -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/rail_endpoints.go b/api/payments/orchestrator/internal/service/orchestrator/rail_endpoints.go deleted file mode 100644 index b9f5c7bb..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/rail_endpoints.go +++ /dev/null @@ -1,99 +0,0 @@ -package orchestrator - -import ( - "strings" - "time" - - "github.com/tech/sendico/payments/storage/model" - "github.com/tech/sendico/pkg/merrors" - "github.com/tech/sendico/pkg/model/account_role" - paymenttypes "github.com/tech/sendico/pkg/payments/types" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" -) - -func railFromEndpoint(endpoint model.PaymentEndpoint, attrs map[string]string, isSource bool) (model.Rail, string, error) { - override := railOverrideFromAttributes(attrs, isSource) - if override != model.RailUnspecified { - return override, networkFromEndpoint(endpoint), nil - } - switch endpoint.Type { - case model.EndpointTypeLedger: - return model.RailLedger, "", nil - case model.EndpointTypeManagedWallet, model.EndpointTypeExternalChain: - return model.RailCrypto, networkFromEndpoint(endpoint), nil - case model.EndpointTypeCard: - return model.RailCardPayout, "", nil - default: - return model.RailUnspecified, "", merrors.InvalidArgument("plan builder: unsupported payment endpoint") - } -} - -func railOverrideFromAttributes(attrs map[string]string, isSource bool) model.Rail { - if len(attrs) == 0 { - return model.RailUnspecified - } - keys := []string{"source_rail", "sourceRail"} - if !isSource { - keys = []string{"destination_rail", "destinationRail"} - } - lookup := map[string]struct{}{} - for _, key := range keys { - lookup[strings.ToLower(key)] = struct{}{} - } - for key, value := range attrs { - if _, ok := lookup[strings.ToLower(strings.TrimSpace(key))]; !ok { - continue - } - rail := parseRailValue(value) - if rail != model.RailUnspecified { - return rail - } - } - return model.RailUnspecified -} - -func cloneAccountRole(role *account_role.AccountRole) *account_role.AccountRole { - if role == nil { - return nil - } - cloned := *role - return &cloned -} - -func resolveFeeAmount(payment *model.Payment, quote *sharedv1.PaymentQuote) *paymenttypes.Money { - if quote != nil && quote.GetExpectedFeeTotal() != nil { - return moneyFromProto(quote.GetExpectedFeeTotal()) - } - if payment != nil && payment.LastQuote != nil { - return cloneMoney(payment.LastQuote.ExpectedFeeTotal) - } - return nil -} - -func planTimestamp(payment *model.Payment) time.Time { - if payment != nil && !payment.CreatedAt.IsZero() { - return payment.CreatedAt.UTC() - } - return time.Now().UTC() -} - -func requireMoney(amount *paymenttypes.Money, label string) (*paymenttypes.Money, error) { - if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" { - return nil, merrors.InvalidArgument("plan builder: " + label + " is required") - } - return amount, nil -} - -func networkFromEndpoint(endpoint model.PaymentEndpoint) string { - switch endpoint.Type { - case model.EndpointTypeManagedWallet: - if endpoint.ManagedWallet != nil && endpoint.ManagedWallet.Asset != nil { - return strings.ToUpper(strings.TrimSpace(endpoint.ManagedWallet.Asset.GetChain())) - } - case model.EndpointTypeExternalChain: - if endpoint.ExternalChain != nil && endpoint.ExternalChain.Asset != nil { - return strings.ToUpper(strings.TrimSpace(endpoint.ExternalChain.Asset.GetChain())) - } - } - return "" -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/rail_gateway_fake_test.go b/api/payments/orchestrator/internal/service/orchestrator/rail_gateway_fake_test.go deleted file mode 100644 index 765b3745..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/rail_gateway_fake_test.go +++ /dev/null @@ -1,57 +0,0 @@ -package orchestrator - -import ( - "context" - - "github.com/tech/sendico/pkg/payments/rail" -) - -type fakeRailGateway struct { - rail string - network string - capabilities rail.RailCapabilities - sendFn func(context.Context, rail.TransferRequest) (rail.RailResult, error) - observeFn func(context.Context, string) (rail.ObserveResult, error) - blockFn func(context.Context, rail.BlockRequest) (rail.RailResult, error) - releaseFn func(context.Context, rail.ReleaseRequest) (rail.RailResult, error) -} - -func (f *fakeRailGateway) Rail() string { - return f.rail -} - -func (f *fakeRailGateway) Network() string { - return f.network -} - -func (f *fakeRailGateway) Capabilities() rail.RailCapabilities { - return f.capabilities -} - -func (f *fakeRailGateway) Send(ctx context.Context, req rail.TransferRequest) (rail.RailResult, error) { - if f.sendFn != nil { - return f.sendFn(ctx, req) - } - return rail.RailResult{ReferenceID: "transfer-1", Status: rail.TransferStatusWaiting}, nil -} - -func (f *fakeRailGateway) Observe(ctx context.Context, referenceID string) (rail.ObserveResult, error) { - if f.observeFn != nil { - return f.observeFn(ctx, referenceID) - } - return rail.ObserveResult{ReferenceID: referenceID, Status: rail.TransferStatusWaiting}, nil -} - -func (f *fakeRailGateway) Block(ctx context.Context, req rail.BlockRequest) (rail.RailResult, error) { - if f.blockFn != nil { - return f.blockFn(ctx, req) - } - return rail.RailResult{ReferenceID: req.IdempotencyKey, Status: rail.TransferStatusWaiting}, nil -} - -func (f *fakeRailGateway) Release(ctx context.Context, req rail.ReleaseRequest) (rail.RailResult, error) { - if f.releaseFn != nil { - return f.releaseFn(ctx, req) - } - return rail.RailResult{ReferenceID: req.ReferenceID, Status: rail.TransferStatusWaiting}, nil -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/service.go b/api/payments/orchestrator/internal/service/orchestrator/service.go index ace88660..be53a50a 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/service.go +++ b/api/payments/orchestrator/internal/service/orchestrator/service.go @@ -1,91 +1,32 @@ package orchestrator import ( - "context" - "time" - - "github.com/tech/sendico/payments/orchestrator/internal/service/plan_builder" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/psvc" "github.com/tech/sendico/payments/storage" - "github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/pkg/api/routers" - clockpkg "github.com/tech/sendico/pkg/clock" - msg "github.com/tech/sendico/pkg/messaging" - mb "github.com/tech/sendico/pkg/messaging/broker" "github.com/tech/sendico/pkg/mlogger" - orchestrationv1 "github.com/tech/sendico/pkg/proto/payments/orchestration/v1" - orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestration/v1" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" + orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2" + "go.uber.org/zap" "google.golang.org/grpc" ) -type serviceError string - -func (e serviceError) Error() string { - return string(e) -} - -const ( - defaultMaxFXQuoteTTL = 10 * time.Minute - defaultMaxFXQuoteTTLMillis = int64(defaultMaxFXQuoteTTL / time.Millisecond) -) - -var ( - errStorageUnavailable = serviceError("payments.orchestrator: storage not initialised") - errQuotationUnavailable = serviceError("payments.orchestrator: quotation service not configured") -) - -// Service orchestrates payments across ledger, billing, FX, and chain domains. +// Service is a v2-only payment orchestrator gRPC adapter. type Service struct { - logger mlogger.Logger - storage storage.Repository - clock clockpkg.Clock - maxFXQuoteTTLMillis int64 - - deps serviceDependencies - h handlerSet - comp componentSet - - gatewayBroker mb.Broker - gatewayConsumers []msg.Consumer - - orchestrationv1.UnimplementedPaymentExecutionServiceServer + logger mlogger.Logger + repo storage.Repository + v2 psvc.Service } -type serviceDependencies struct { - quotation quotationDependency - fees feesDependency - ledger ledgerDependency - gateway gatewayDependency - railGateways railGatewayDependency - providerGateway providerGatewayDependency - - mntx mntxDependency - gatewayRegistry plan_builder.GatewayRegistry - gatewayInvokeResolver GatewayInvokeResolver - cardRoutes map[string]CardGatewayRoute - feeLedgerAccounts map[string]string -} - -type handlerSet struct { - commands *paymentCommandFactory - queries *paymentQueryHandler - events *paymentEventHandler -} - -type componentSet struct { - executor *paymentExecutor -} - -// NewService constructs a payment orchestrator service. +// NewService constructs the v2 orchestrator service. func NewService(logger mlogger.Logger, repo storage.Repository, opts ...Option) *Service { - svc := &Service{ - logger: logger.Named("payment_orchestrator"), - storage: repo, - clock: clockpkg.NewSystem(), - maxFXQuoteTTLMillis: defaultMaxFXQuoteTTLMillis, + if logger == nil { + logger = zap.NewNop() } - initMetrics() + svc := &Service{ + logger: logger.Named("payment_orchestrator"), + repo: repo, + } for _, opt := range opts { if opt != nil { @@ -93,116 +34,19 @@ func NewService(logger mlogger.Logger, repo storage.Repository, opts ...Option) } } - if svc.clock == nil { - svc.clock = clockpkg.NewSystem() - } - if svc.maxFXQuoteTTLMillis <= 0 { - svc.maxFXQuoteTTLMillis = defaultMaxFXQuoteTTLMillis - } - - engine := defaultPaymentEngine{svc: svc} - svc.h.commands = newPaymentCommandFactory(engine, svc.logger) - svc.h.queries = newPaymentQueryHandler(svc.storage, svc.ensureRepository, svc.logger.Named("queries")) - svc.h.events = newPaymentEventHandler(svc.storage, svc.ensureRepository, svc.logger.Named("events"), svc.submitCardPayout, svc.resumePaymentPlan, svc.releasePaymentHold) - svc.comp.executor = newPaymentExecutor(&svc.deps, svc.logger.Named("payment_executor"), svc) - svc.startGatewayConsumers() - + svc.v2 = newOrchestrationV2Service(svc.logger, repo) return svc } -func (s *Service) ensureHandlers() { - if s.h.commands == nil { - s.h.commands = newPaymentCommandFactory(defaultPaymentEngine{svc: s}, s.logger) - } - if s.h.queries == nil { - s.h.queries = newPaymentQueryHandler(s.storage, s.ensureRepository, s.logger.Named("queries")) - } - if s.h.events == nil { - s.h.events = newPaymentEventHandler(s.storage, s.ensureRepository, s.logger.Named("events"), s.submitCardPayout, s.resumePaymentPlan, s.releasePaymentHold) - } - if s.comp.executor == nil { - s.comp.executor = newPaymentExecutor(&s.deps, s.logger.Named("payment_executor"), s) - } -} - // Register attaches the service to the supplied gRPC router. func (s *Service) Register(router routers.GRPC) error { + if s == nil || s.v2 == nil { + return nil + } return router.Register(func(reg grpc.ServiceRegistrar) { - orchestrationv1.RegisterPaymentExecutionServiceServer(reg, s) + orchestrationv2.RegisterPaymentOrchestratorServiceServer(reg, newV2GRPCServer(s.v2)) }) } -// InitiatePayment captures a payment intent and reserves funds orchestration. -func (s *Service) InitiatePayment(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error) { - s.ensureHandlers() - return executeUnary(ctx, s, "InitiatePayment", s.h.commands.InitiatePayment().Execute, req) -} - -// InitiatePayments executes multiple payments using a stored quote reference. -func (s *Service) InitiatePayments(ctx context.Context, req *orchestratorv1.InitiatePaymentsRequest) (*orchestratorv1.InitiatePaymentsResponse, error) { - s.ensureHandlers() - return executeUnary(ctx, s, "InitiatePayments", s.h.commands.InitiatePayments().Execute, req) -} - -// CancelPayment attempts to cancel an in-flight payment. -func (s *Service) CancelPayment(ctx context.Context, req *orchestratorv1.CancelPaymentRequest) (*orchestratorv1.CancelPaymentResponse, error) { - s.ensureHandlers() - return executeUnary(ctx, s, "CancelPayment", s.h.commands.CancelPayment().Execute, req) -} - -// GetPayment returns a stored payment record. -func (s *Service) GetPayment(ctx context.Context, req *orchestratorv1.GetPaymentRequest) (*orchestratorv1.GetPaymentResponse, error) { - s.ensureHandlers() - return executeUnary(ctx, s, "GetPayment", s.h.queries.getPayment, req) -} - -// ListPayments lists stored payment records. -func (s *Service) ListPayments(ctx context.Context, req *orchestratorv1.ListPaymentsRequest) (*orchestratorv1.ListPaymentsResponse, error) { - s.ensureHandlers() - return executeUnary(ctx, s, "ListPayments", s.h.queries.listPayments, req) -} - -// InitiateConversion orchestrates standalone FX conversions. -func (s *Service) InitiateConversion(ctx context.Context, req *orchestratorv1.InitiateConversionRequest) (*orchestratorv1.InitiateConversionResponse, error) { - s.ensureHandlers() - return executeUnary(ctx, s, "InitiateConversion", s.h.commands.InitiateConversion().Execute, req) -} - -// ProcessTransferUpdate reconciles chain events back into payment state. -func (s *Service) ProcessTransferUpdate(ctx context.Context, req *orchestratorv1.ProcessTransferUpdateRequest) (*orchestratorv1.ProcessTransferUpdateResponse, error) { - s.ensureHandlers() - return executeUnary(ctx, s, "ProcessTransferUpdate", s.h.events.processTransferUpdate, req) -} - -// ProcessDepositObserved reconciles deposit events to ledger. -func (s *Service) ProcessDepositObserved(ctx context.Context, req *orchestratorv1.ProcessDepositObservedRequest) (*orchestratorv1.ProcessDepositObservedResponse, error) { - s.ensureHandlers() - return executeUnary(ctx, s, "ProcessDepositObserved", s.h.events.processDepositObserved, req) -} - -// ProcessCardPayoutUpdate reconciles card payout events back into payment state. -func (s *Service) ProcessCardPayoutUpdate(ctx context.Context, req *orchestratorv1.ProcessCardPayoutUpdateRequest) (*orchestratorv1.ProcessCardPayoutUpdateResponse, error) { - s.ensureHandlers() - return executeUnary(ctx, s, "ProcessCardPayoutUpdate", s.h.events.processCardPayoutUpdate, req) -} - -func (s *Service) executePayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, quote *sharedv1.PaymentQuote) error { - s.ensureHandlers() - return s.comp.executor.executePayment(ctx, store, payment, quote) -} - -func (s *Service) resumePaymentPlan(ctx context.Context, store storage.PaymentsStore, payment *model.Payment) error { - if payment == nil || payment.PaymentPlan == nil || len(payment.PaymentPlan.Steps) == 0 { - return nil - } - s.ensureHandlers() - return s.comp.executor.executePaymentPlan(ctx, store, payment, nil) -} - -func (s *Service) releasePaymentHold(ctx context.Context, store storage.PaymentsStore, payment *model.Payment) error { - if payment == nil || payment.PaymentPlan == nil || len(payment.PaymentPlan.Steps) == 0 { - return nil - } - s.ensureHandlers() - return s.comp.executor.releasePaymentHold(ctx, store, payment) -} +// Shutdown releases runtime resources. Orchestration v2 currently has no background workers. +func (s *Service) Shutdown() {} diff --git a/api/payments/orchestrator/internal/service/orchestrator/service_helpers.go b/api/payments/orchestrator/internal/service/orchestrator/service_helpers.go deleted file mode 100644 index ea3ebefa..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/service_helpers.go +++ /dev/null @@ -1,269 +0,0 @@ -package orchestrator - -import ( - "context" - "errors" - "strings" - - "github.com/tech/sendico/payments/storage" - "github.com/tech/sendico/payments/storage/model" - quotestorage "github.com/tech/sendico/payments/storage/quote" - "github.com/tech/sendico/pkg/api/routers/gsresponse" - "github.com/tech/sendico/pkg/merrors" - "github.com/tech/sendico/pkg/mlogger" - "github.com/tech/sendico/pkg/mservice" - quotationv1 "github.com/tech/sendico/pkg/proto/payments/quotation/v1" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" - "go.mongodb.org/mongo-driver/v2/bson" - "google.golang.org/protobuf/proto" -) - -func validateMetaAndOrgRef(meta *sharedv1.RequestMeta) (string, bson.ObjectID, error) { - if meta == nil { - return "", bson.NilObjectID, merrors.InvalidArgument("meta is required") - } - orgRef := strings.TrimSpace(meta.GetOrganizationRef()) - if orgRef == "" { - return "", bson.NilObjectID, merrors.InvalidArgument("organization_ref is required") - } - orgID, err := bson.ObjectIDFromHex(orgRef) - if err != nil { - return "", bson.NilObjectID, merrors.InvalidArgument("organization_ref must be a valid objectID") - } - return orgRef, orgID, nil -} - -func requireIdempotencyKey(k string) (string, error) { - key := strings.TrimSpace(k) - if key == "" { - return "", merrors.InvalidArgument("idempotency_key is required") - } - return key, nil -} - -func requirePaymentRef(ref string) (string, error) { - val := strings.TrimSpace(ref) - if val == "" { - return "", merrors.InvalidArgument("payment_ref is required") - } - return val, nil -} - -func requireNonNilIntent(intent *sharedv1.PaymentIntent) error { - if intent == nil { - return merrors.InvalidArgument("intent is required") - } - if intent.GetAmount() == nil { - return merrors.InvalidArgument("intent.amount is required") - } - if strings.TrimSpace(intent.GetSettlementCurrency()) == "" { - return merrors.InvalidArgument("intent.settlement_currency is required") - } - return nil -} - -func ensurePaymentsStore(repo storage.Repository) (storage.PaymentsStore, error) { - if repo == nil { - return nil, errStorageUnavailable - } - store := repo.Payments() - if store == nil { - return nil, errStorageUnavailable - } - return store, nil -} - -func ensureQuotesStore(repo storage.Repository) (quotestorage.QuotesStore, error) { - if repo == nil { - return nil, errStorageUnavailable - } - store := repo.Quotes() - if store == nil { - return nil, errStorageUnavailable - } - return store, nil -} - -func getPaymentByIdempotencyKey(ctx context.Context, store storage.PaymentsStore, orgID bson.ObjectID, key string) (*model.Payment, error) { - payment, err := store.GetByIdempotencyKey(ctx, orgID, key) - if err != nil { - return nil, err - } - return payment, nil -} - -type quoteResolutionInput struct { - OrgRef string - OrgID bson.ObjectID - Meta *sharedv1.RequestMeta - Intent *sharedv1.PaymentIntent - QuoteRef string - IdempotencyKey string -} - -type quoteResolutionError struct { - code string - err error -} - -func (e quoteResolutionError) Error() string { return e.err.Error() } - -func (s *Service) clampFXIntentTTL(intent *sharedv1.PaymentIntent) *sharedv1.PaymentIntent { - if intent == nil { - return nil - } - cloned, ok := proto.Clone(intent).(*sharedv1.PaymentIntent) - if !ok || cloned == nil { - return intent - } - if s == nil || s.maxFXQuoteTTLMillis <= 0 { - return cloned - } - if fx := cloned.GetFx(); fx != nil && fx.GetTtlMs() > s.maxFXQuoteTTLMillis { - fx.TtlMs = s.maxFXQuoteTTLMillis - } - return cloned -} - -func (s *Service) resolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*sharedv1.PaymentQuote, *sharedv1.PaymentIntent, *model.PaymentPlan, error) { - if ref := strings.TrimSpace(in.QuoteRef); ref != "" { - quotesStore, err := ensureQuotesStore(s.storage) - if err != nil { - return nil, nil, nil, err - } - record, err := quotesStore.GetByRef(ctx, in.OrgID, ref) - if err != nil { - if errors.Is(err, quotestorage.ErrQuoteNotFound) { - return nil, nil, nil, quoteResolutionError{code: "quote_not_found", err: merrors.InvalidArgument("quote_ref not found or expired")} - } - return nil, nil, nil, err - } - if !record.ExpiresAt.IsZero() && s.clock.Now().After(record.ExpiresAt) { - return nil, nil, nil, quoteResolutionError{code: "quote_expired", err: merrors.InvalidArgument("quote_ref expired")} - } - if note := strings.TrimSpace(record.ExecutionNote); note != "" { - return nil, nil, nil, quoteResolutionError{code: "quote_not_executable", err: merrors.InvalidArgument(note)} - } - intent, err := recordIntentFromQuote(record) - if err != nil { - return nil, nil, nil, err - } - if in.Intent != nil && !proto.Equal(intent, in.Intent) { - return nil, nil, nil, quoteResolutionError{code: "quote_intent_mismatch", err: merrors.InvalidArgument("quote_ref does not match intent")} - } - quote, err := recordQuoteFromQuote(record) - if err != nil { - return nil, nil, nil, err - } - quote.QuoteRef = ref - plan, err := recordPlanFromQuote(record) - if err != nil { - return nil, nil, nil, err - } - return quote, intent, plan, nil - } - - if in.Intent == nil { - return nil, nil, nil, merrors.InvalidArgument("intent is required") - } - intent := s.clampFXIntentTTL(in.Intent) - req := "ationv1.QuotePaymentRequest{ - Meta: in.Meta, - IdempotencyKey: in.IdempotencyKey, - Intent: intent, - PreviewOnly: false, - } - if !s.deps.quotation.available() { - return nil, nil, nil, errQuotationUnavailable - } - quoteResp, err := s.deps.quotation.client.QuotePayment(ctx, req) - if err != nil { - return nil, nil, nil, err - } - quote := quoteResp.GetQuote() - if quote == nil { - return nil, nil, nil, merrors.InvalidArgument("stored quote is empty") - } - ref := strings.TrimSpace(quote.GetQuoteRef()) - if ref == "" { - return nil, nil, nil, merrors.InvalidArgument("quotation response does not include quote_ref") - } - - return s.resolvePaymentQuote(ctx, quoteResolutionInput{ - OrgRef: in.OrgRef, - OrgID: in.OrgID, - Meta: in.Meta, - Intent: intent, - QuoteRef: ref, - IdempotencyKey: in.IdempotencyKey, - }) -} - -func recordIntentFromQuote(record *model.PaymentQuoteRecord) (*sharedv1.PaymentIntent, error) { - if record == nil { - return nil, merrors.InvalidArgument("stored quote payload is incomplete") - } - if len(record.Intents) > 0 { - if len(record.Intents) != 1 { - return nil, merrors.InvalidArgument("stored quote payload is incomplete") - } - return protoIntentFromModel(record.Intents[0]), nil - } - if record.Intent.Amount == nil && (record.Intent.Kind == "" || record.Intent.Kind == model.PaymentKindUnspecified) { - return nil, merrors.InvalidArgument("stored quote payload is incomplete") - } - return protoIntentFromModel(record.Intent), nil -} - -func recordQuoteFromQuote(record *model.PaymentQuoteRecord) (*sharedv1.PaymentQuote, error) { - if record == nil { - return nil, merrors.InvalidArgument("stored quote is empty") - } - if record.Quote != nil { - return modelQuoteToProto(record.Quote), nil - } - if len(record.Quotes) > 0 { - if len(record.Quotes) != 1 { - return nil, merrors.InvalidArgument("stored quote payload is incomplete") - } - return modelQuoteToProto(record.Quotes[0]), nil - } - return nil, merrors.InvalidArgument("stored quote is empty") -} - -func recordPlanFromQuote(record *model.PaymentQuoteRecord) (*model.PaymentPlan, error) { - if record == nil { - return nil, merrors.InvalidArgument("stored quote payload is incomplete") - } - if len(record.Plans) > 0 { - if len(record.Plans) != 1 { - return nil, merrors.InvalidArgument("stored quote payload is incomplete") - } - return cloneStoredPaymentPlan(record.Plans[0]), nil - } - if record.Plan != nil { - return cloneStoredPaymentPlan(record.Plan), nil - } - return nil, nil -} - -func newPayment(orgID bson.ObjectID, intent *sharedv1.PaymentIntent, idempotencyKey string, metadata map[string]string, quote *sharedv1.PaymentQuote) *model.Payment { - entity := &model.Payment{} - entity.SetID(bson.NewObjectID()) - entity.SetOrganizationRef(orgID) - entity.PaymentRef = entity.GetID().Hex() - entity.IdempotencyKey = idempotencyKey - entity.State = model.PaymentStateAccepted - entity.Intent = intentFromProto(intent) - entity.Metadata = cloneMetadata(metadata) - entity.LastQuote = quoteSnapshotToModel(quote) - entity.Normalize() - return entity -} - -func paymentNotFoundResponder[T any](svc mservice.Type, logger mlogger.Logger, err error) gsresponse.Responder[T] { - if errors.Is(err, storage.ErrPaymentNotFound) { - return gsresponse.NotFound[T](logger, svc, err) - } - return gsresponse.Auto[T](logger, svc, err) -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/service_helpers_test.go b/api/payments/orchestrator/internal/service/orchestrator/service_helpers_test.go deleted file mode 100644 index 3461166e..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/service_helpers_test.go +++ /dev/null @@ -1,610 +0,0 @@ -package orchestrator - -import ( - "context" - "testing" - "time" - - ledgerclient "github.com/tech/sendico/ledger/client" - "github.com/tech/sendico/payments/storage" - "github.com/tech/sendico/payments/storage/model" - quotestorage "github.com/tech/sendico/payments/storage/quote" - clockpkg "github.com/tech/sendico/pkg/clock" - mloggerfactory "github.com/tech/sendico/pkg/mlogger/factory" - "github.com/tech/sendico/pkg/model/account_role" - paymenttypes "github.com/tech/sendico/pkg/payments/types" - 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" - connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1" - ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1" - orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestration/v1" - quotationv1 "github.com/tech/sendico/pkg/proto/payments/quotation/v1" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" - "go.mongodb.org/mongo-driver/v2/bson" - "google.golang.org/grpc" - "google.golang.org/protobuf/types/known/structpb" -) - -func TestValidateMetaAndOrgRef(t *testing.T) { - org := bson.NewObjectID() - meta := &sharedv1.RequestMeta{OrganizationRef: org.Hex()} - ref, id, err := validateMetaAndOrgRef(meta) - if err != nil { - t.Fatalf("expected nil error: %v", err) - } - if ref != org.Hex() || id != org { - t.Fatalf("unexpected org parsing: %s %s", ref, id.Hex()) - } - if _, _, err := validateMetaAndOrgRef(nil); err == nil { - t.Fatalf("expected error on nil meta") - } - if _, _, err := validateMetaAndOrgRef(&sharedv1.RequestMeta{OrganizationRef: ""}); err == nil { - t.Fatalf("expected error on empty orgRef") - } - if _, _, err := validateMetaAndOrgRef(&sharedv1.RequestMeta{OrganizationRef: "bad"}); err == nil { - t.Fatalf("expected error on invalid orgRef") - } -} - -func TestRequireIdempotencyKey(t *testing.T) { - if _, err := requireIdempotencyKey(" "); err == nil { - t.Fatalf("expected error for empty key") - } - val, err := requireIdempotencyKey(" key ") - if err != nil || val != "key" { - t.Fatalf("unexpected result %s err %v", val, err) - } -} - -func TestNewPayment(t *testing.T) { - org := bson.NewObjectID() - intent := &sharedv1.PaymentIntent{ - Ref: "ref-1", - Amount: &moneyv1.Money{Currency: "USD", Amount: "10"}, - SettlementMode: paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED, - SettlementCurrency: "USD", - } - quote := &sharedv1.PaymentQuote{QuoteRef: "q1"} - p := newPayment(org, intent, "idem", map[string]string{"k": "v"}, quote) - if p.PaymentRef == "" || p.IdempotencyKey != "idem" || p.State != model.PaymentStateAccepted { - t.Fatalf("unexpected payment fields: %+v", p) - } - if p.Intent.Amount == nil || p.Intent.Amount.GetAmount() != "10" { - t.Fatalf("intent not copied") - } - if p.Intent.SettlementMode != model.SettlementModeFixReceived { - t.Fatalf("settlement mode not preserved") - } - if p.LastQuote == nil || p.LastQuote.QuoteRef != "q1" { - t.Fatalf("quote not copied") - } -} - -func TestResolvePaymentQuote_NotFound(t *testing.T) { - org := bson.NewObjectID() - svc := &Service{ - storage: stubRepo{quotes: &helperQuotesStore{}}, - clock: clockpkg.NewSystem(), - } - _, _, _, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{ - OrgRef: org.Hex(), - OrgID: org, - Meta: &sharedv1.RequestMeta{OrganizationRef: org.Hex()}, - Intent: &sharedv1.PaymentIntent{ - Ref: "ref-1", - Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}, SettlementCurrency: "USD"}, - QuoteRef: "missing", - }) - if qerr, ok := err.(quoteResolutionError); !ok || qerr.code != "quote_not_found" { - t.Fatalf("expected quote_not_found, got %v", err) - } -} - -func TestResolvePaymentQuote_Expired(t *testing.T) { - org := bson.NewObjectID() - intent := &sharedv1.PaymentIntent{ - Ref: "ref-1", - Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}, SettlementCurrency: "USD"} - record := &model.PaymentQuoteRecord{ - QuoteRef: "q1", - Intent: intentFromProto(intent), - Quote: &model.PaymentQuoteSnapshot{}, - ExpiresAt: time.Now().Add(-time.Minute), - } - svc := &Service{ - storage: stubRepo{quotes: &helperQuotesStore{records: map[string]*model.PaymentQuoteRecord{"q1": record}}}, - clock: clockpkg.NewSystem(), - } - _, _, _, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{ - OrgRef: org.Hex(), - OrgID: org, - Meta: &sharedv1.RequestMeta{OrganizationRef: org.Hex()}, - Intent: intent, - QuoteRef: "q1", - }) - if qerr, ok := err.(quoteResolutionError); !ok || qerr.code != "quote_expired" { - t.Fatalf("expected quote_expired, got %v", err) - } -} - -func TestResolvePaymentQuote_NotExecutable(t *testing.T) { - org := bson.NewObjectID() - intent := &sharedv1.PaymentIntent{ - Ref: "ref-1", - Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}, - SettlementCurrency: "USD", - } - record := &model.PaymentQuoteRecord{ - QuoteRef: "q1", - Intent: intentFromProto(intent), - Quote: &model.PaymentQuoteSnapshot{}, - ExecutionNote: "quote will not be executed: amount 1 USD below per-tx min limit 10", - ExpiresAt: time.Now().Add(time.Minute), - IdempotencyKey: "idem-1", - } - svc := &Service{ - storage: stubRepo{quotes: &helperQuotesStore{records: map[string]*model.PaymentQuoteRecord{"q1": record}}}, - clock: clockpkg.NewSystem(), - } - - _, _, _, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{ - OrgRef: org.Hex(), - OrgID: org, - Meta: &sharedv1.RequestMeta{OrganizationRef: org.Hex()}, - Intent: intent, - QuoteRef: "q1", - }) - if qerr, ok := err.(quoteResolutionError); !ok || qerr.code != "quote_not_executable" { - t.Fatalf("expected quote_not_executable, got %v", err) - } -} - -func TestResolvePaymentQuote_QuoteRefUsesStoredIntent(t *testing.T) { - org := bson.NewObjectID() - intent := &sharedv1.PaymentIntent{ - Ref: "ref-1", - Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}, SettlementCurrency: "USD"} - record := &model.PaymentQuoteRecord{ - QuoteRef: "q1", - Intent: intentFromProto(intent), - Quote: &model.PaymentQuoteSnapshot{}, - } - svc := &Service{ - storage: stubRepo{quotes: &helperQuotesStore{records: map[string]*model.PaymentQuoteRecord{"q1": record}}}, - clock: clockpkg.NewSystem(), - } - quote, resolvedIntent, _, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{ - OrgRef: org.Hex(), - OrgID: org, - Meta: &sharedv1.RequestMeta{OrganizationRef: org.Hex()}, - QuoteRef: "q1", - }) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if quote == nil || quote.GetQuoteRef() != "q1" { - t.Fatalf("expected quote_ref q1, got %#v", quote) - } - if resolvedIntent == nil || resolvedIntent.GetAmount().GetAmount() != "1" { - t.Fatalf("expected resolved intent with amount, got %#v", resolvedIntent) - } -} - -func TestResolvePaymentQuote_QuoteRefSkipsQuoteRecompute(t *testing.T) { - org := bson.NewObjectID() - intent := &sharedv1.PaymentIntent{ - Ref: "ref-1", - Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}, - SettlementCurrency: "USD", - } - record := &model.PaymentQuoteRecord{ - QuoteRef: "q1", - Intent: intentFromProto(intent), - Quote: &model.PaymentQuoteSnapshot{}, - } - - svc := &Service{ - storage: stubRepo{quotes: &helperQuotesStore{records: map[string]*model.PaymentQuoteRecord{"q1": record}}}, - clock: clockpkg.NewSystem(), - } - - _, _, _, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{ - OrgRef: org.Hex(), - OrgID: org, - Meta: &sharedv1.RequestMeta{OrganizationRef: org.Hex()}, - QuoteRef: "q1", - }) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestResolvePaymentQuote_ClampsForwardedFXTTL(t *testing.T) { - const ( - requestedTTL = int64((15 * time.Minute) / time.Millisecond) - maxTTL = int64((10 * time.Minute) / time.Millisecond) - ) - - org := bson.NewObjectID() - intent := &sharedv1.PaymentIntent{ - Ref: "ref-1", - Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}, - SettlementCurrency: "EUR", - Fx: &sharedv1.FXIntent{ - Pair: &fxv1.CurrencyPair{Base: "USD", Quote: "EUR"}, - Side: fxv1.Side_SELL_BASE_BUY_QUOTE, - Firm: true, - TtlMs: requestedTTL, - }, - } - intent = protoIntentFromModel(intentFromProto(intent)) - intent.Fx.TtlMs = requestedTTL - - recordIntent := protoIntentFromModel(intentFromProto(intent)) - recordIntent.Fx.TtlMs = maxTTL - - var capturedTTLMs int64 - svc := &Service{ - storage: stubRepo{ - quotes: &helperQuotesStore{ - records: map[string]*model.PaymentQuoteRecord{ - "q1": { - QuoteRef: "q1", - Intent: intentFromProto(recordIntent), - Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q1"}, - }, - }, - }, - }, - clock: clockpkg.NewSystem(), - maxFXQuoteTTLMillis: maxTTL, - deps: serviceDependencies{ - quotation: quotationDependency{ - client: &helperQuotationClient{ - quotePaymentFn: func(ctx context.Context, req *quotationv1.QuotePaymentRequest, opts ...grpc.CallOption) (*quotationv1.QuotePaymentResponse, error) { - capturedTTLMs = req.GetIntent().GetFx().GetTtlMs() - return "ationv1.QuotePaymentResponse{ - Quote: &sharedv1.PaymentQuote{ - QuoteRef: "q1", - }, - IdempotencyKey: req.GetIdempotencyKey(), - }, nil - }, - }, - }, - }, - } - - _, resolvedIntent, _, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{ - OrgRef: org.Hex(), - OrgID: org, - Meta: &sharedv1.RequestMeta{OrganizationRef: org.Hex()}, - Intent: intent, - IdempotencyKey: "idem-1", - }) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if capturedTTLMs != maxTTL { - t.Fatalf("expected forwarded ttl_ms to be clamped to %d, got %d", maxTTL, capturedTTLMs) - } - if intent.GetFx().GetTtlMs() != requestedTTL { - t.Fatalf("expected original intent ttl to stay unchanged, got %d", intent.GetFx().GetTtlMs()) - } - if resolvedIntent == nil || resolvedIntent.GetFx().GetTtlMs() != maxTTL { - t.Fatalf("expected resolved intent ttl to match stored clamped value") - } -} - -func TestInitiatePaymentIdempotency(t *testing.T) { - logger := mloggerfactory.NewLogger(false) - org := bson.NewObjectID() - store := newHelperPaymentStore() - intent := &sharedv1.PaymentIntent{ - Ref: "ref-1", - Source: &sharedv1.PaymentEndpoint{ - Endpoint: &sharedv1.PaymentEndpoint_Ledger{Ledger: &sharedv1.LedgerEndpoint{LedgerAccountRef: "ledger:source"}}, - }, - Destination: &sharedv1.PaymentEndpoint{ - Endpoint: &sharedv1.PaymentEndpoint_Ledger{Ledger: &sharedv1.LedgerEndpoint{LedgerAccountRef: "ledger:dest"}}, - }, - Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}, - SettlementCurrency: "USD", - } - record := &model.PaymentQuoteRecord{ - QuoteRef: "q1", - Intent: intentFromProto(intent), - Quote: &model.PaymentQuoteSnapshot{ - DebitAmount: &paymenttypes.Money{Currency: "USD", Amount: "1"}, - ExpectedSettlementAmount: &paymenttypes.Money{Currency: "USD", Amount: "1"}, - QuoteRef: "q1", - }, - Plan: &model.PaymentPlan{ - Steps: []*model.PaymentStep{ - { - StepID: "ledger_move", - Rail: model.RailLedger, - Action: model.RailOperationMove, - Amount: &paymenttypes.Money{Currency: "USD", Amount: "1"}, - FromRole: rolePtr(account_role.AccountRoleOperating), - ToRole: rolePtr(account_role.AccountRoleTransit), - }, - }, - }, - } - ledgerFake := &ledgerclient.Fake{ - ListConnectorAccountsFn: func(ctx context.Context, req *connectorv1.ListAccountsRequest) (*connectorv1.ListAccountsResponse, error) { - operatingDetails, _ := structpb.NewStruct(map[string]interface{}{"role": "ACCOUNT_ROLE_OPERATING"}) - transitDetails, _ := structpb.NewStruct(map[string]interface{}{"role": "ACCOUNT_ROLE_TRANSIT"}) - return &connectorv1.ListAccountsResponse{ - Accounts: []*connectorv1.Account{ - {Ref: &connectorv1.AccountRef{ConnectorId: "ledger", AccountId: "ledger:operating"}, Kind: connectorv1.AccountKind_LEDGER_ACCOUNT, Asset: "USD", ProviderDetails: operatingDetails}, - {Ref: &connectorv1.AccountRef{ConnectorId: "ledger", AccountId: "ledger:transit"}, Kind: connectorv1.AccountKind_LEDGER_ACCOUNT, Asset: "USD", ProviderDetails: transitDetails}, - }, - }, nil - }, - TransferInternalFn: func(ctx context.Context, req *ledgerv1.TransferRequest) (*ledgerv1.PostResponse, error) { - return &ledgerv1.PostResponse{JournalEntryRef: "move-1"}, nil - }, - } - svc := NewService(logger, stubRepo{ - payments: store, - quotes: &helperQuotesStore{records: map[string]*model.PaymentQuoteRecord{"q1": record}}, - }, WithClock(clockpkg.NewSystem()), WithLedgerClient(ledgerFake), WithQuotationService(&helperQuotationClient{ - quotePaymentFn: func(ctx context.Context, req *quotationv1.QuotePaymentRequest, opts ...grpc.CallOption) (*quotationv1.QuotePaymentResponse, error) { - amount := req.GetIntent().GetAmount() - return "ationv1.QuotePaymentResponse{ - Quote: &sharedv1.PaymentQuote{ - QuoteRef: "q1", - DebitAmount: amount, - ExpectedSettlementAmount: amount, - }, - IdempotencyKey: req.GetIdempotencyKey(), - }, nil - }, - })) - svc.ensureHandlers() - req := &orchestratorv1.InitiatePaymentRequest{ - Meta: &sharedv1.RequestMeta{OrganizationRef: org.Hex()}, - Intent: intent, - IdempotencyKey: "k1", - } - resp, err := svc.h.commands.InitiatePayment().Execute(context.Background(), req)(context.Background()) - if err != nil { - t.Fatalf("first call failed: %v", err) - } - resp2, err := svc.h.commands.InitiatePayment().Execute(context.Background(), req)(context.Background()) - if err != nil { - t.Fatalf("second call failed: %v", err) - } - if resp == nil || resp2 == nil || resp.Payment.GetPaymentRef() != resp2.Payment.GetPaymentRef() { - t.Fatalf("idempotent call returned different payments") - } -} - -func TestInitiatePaymentByQuoteRef(t *testing.T) { - logger := mloggerfactory.NewLogger(false) - org := bson.NewObjectID() - store := newHelperPaymentStore() - intent := &sharedv1.PaymentIntent{ - Ref: "ref-1", - Source: &sharedv1.PaymentEndpoint{ - Endpoint: &sharedv1.PaymentEndpoint_Ledger{Ledger: &sharedv1.LedgerEndpoint{LedgerAccountRef: "ledger:source"}}, - }, - Destination: &sharedv1.PaymentEndpoint{ - Endpoint: &sharedv1.PaymentEndpoint_Ledger{Ledger: &sharedv1.LedgerEndpoint{LedgerAccountRef: "ledger:dest"}}, - }, - Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}, - SettlementCurrency: "USD", - } - record := &model.PaymentQuoteRecord{ - QuoteRef: "q1", - Intent: intentFromProto(intent), - Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q1"}, - Plan: &model.PaymentPlan{ - Steps: []*model.PaymentStep{ - { - StepID: "ledger_move", - Rail: model.RailLedger, - Action: model.RailOperationMove, - Amount: &paymenttypes.Money{Currency: "USD", Amount: "1"}, - FromRole: rolePtr(account_role.AccountRoleOperating), - ToRole: rolePtr(account_role.AccountRoleTransit), - }, - }, - }, - } - ledgerFake := &ledgerclient.Fake{ - ListConnectorAccountsFn: func(ctx context.Context, req *connectorv1.ListAccountsRequest) (*connectorv1.ListAccountsResponse, error) { - operatingDetails, _ := structpb.NewStruct(map[string]interface{}{"role": "ACCOUNT_ROLE_OPERATING"}) - transitDetails, _ := structpb.NewStruct(map[string]interface{}{"role": "ACCOUNT_ROLE_TRANSIT"}) - return &connectorv1.ListAccountsResponse{ - Accounts: []*connectorv1.Account{ - {Ref: &connectorv1.AccountRef{ConnectorId: "ledger", AccountId: "ledger:operating"}, Kind: connectorv1.AccountKind_LEDGER_ACCOUNT, Asset: "USD", ProviderDetails: operatingDetails}, - {Ref: &connectorv1.AccountRef{ConnectorId: "ledger", AccountId: "ledger:transit"}, Kind: connectorv1.AccountKind_LEDGER_ACCOUNT, Asset: "USD", ProviderDetails: transitDetails}, - }, - }, nil - }, - TransferInternalFn: func(ctx context.Context, req *ledgerv1.TransferRequest) (*ledgerv1.PostResponse, error) { - return &ledgerv1.PostResponse{JournalEntryRef: "move-1"}, nil - }, - } - svc := NewService(logger, stubRepo{ - payments: store, - quotes: &helperQuotesStore{records: map[string]*model.PaymentQuoteRecord{"q1": record}}, - }, WithClock(clockpkg.NewSystem()), WithLedgerClient(ledgerFake)) - svc.ensureHandlers() - - req := &orchestratorv1.InitiatePaymentRequest{ - Meta: &sharedv1.RequestMeta{OrganizationRef: org.Hex()}, - QuoteRef: "q1", - IdempotencyKey: "k1", - } - resp, err := svc.h.commands.InitiatePayment().Execute(context.Background(), req)(context.Background()) - if err != nil { - t.Fatalf("initiate by quote_ref failed: %v", err) - } - if resp == nil || resp.GetPayment() == nil { - t.Fatalf("expected payment response") - } - if resp.GetPayment().GetIntent().GetAmount().GetAmount() != "1" { - t.Fatalf("expected intent amount to be resolved from quote") - } - if resp.GetPayment().GetLastQuote().GetQuoteRef() != "q1" { - t.Fatalf("expected last quote_ref to be set from stored quote") - } -} - -// --- test doubles --- - -type stubRepo struct { - payments storage.PaymentsStore - quotes quotestorage.QuotesStore - routes storage.RoutesStore - plans storage.PlanTemplatesStore - pingErr error -} - -func (s stubRepo) Ping(context.Context) error { return s.pingErr } -func (s stubRepo) Payments() storage.PaymentsStore { return s.payments } -func (s stubRepo) PaymentMethods() storage.PaymentMethodsStore { - return nil -} -func (s stubRepo) Quotes() quotestorage.QuotesStore { return s.quotes } -func (s stubRepo) Routes() storage.RoutesStore { return s.routes } -func (s stubRepo) PlanTemplates() storage.PlanTemplatesStore { - if s.plans != nil { - return s.plans - } - return &stubPlanTemplatesStore{} -} - -type helperPaymentStore struct { - byRef map[string]*model.Payment - byIdem map[string]*model.Payment - byChain map[string]*model.Payment -} - -func newHelperPaymentStore() *helperPaymentStore { - return &helperPaymentStore{ - byRef: make(map[string]*model.Payment), - byIdem: make(map[string]*model.Payment), - byChain: make(map[string]*model.Payment), - } -} - -func (s *helperPaymentStore) Create(_ context.Context, p *model.Payment) error { - if _, ok := s.byRef[p.PaymentRef]; ok { - return storage.ErrDuplicatePayment - } - s.byRef[p.PaymentRef] = p - if p.IdempotencyKey != "" { - s.byIdem[p.IdempotencyKey] = p - } - if p.Execution != nil && p.Execution.ChainTransferRef != "" { - s.byChain[p.Execution.ChainTransferRef] = p - } - return nil -} - -func (s *helperPaymentStore) Update(_ context.Context, p *model.Payment) error { - if p == nil { - return storage.ErrPaymentNotFound - } - if _, ok := s.byRef[p.PaymentRef]; !ok { - return storage.ErrPaymentNotFound - } - s.byRef[p.PaymentRef] = p - if p.IdempotencyKey != "" { - s.byIdem[p.IdempotencyKey] = p - } - return nil -} - -func (s *helperPaymentStore) GetByPaymentRef(_ context.Context, ref string) (*model.Payment, error) { - if p, ok := s.byRef[ref]; ok { - return p, nil - } - return nil, storage.ErrPaymentNotFound -} - -func (s *helperPaymentStore) GetByIdempotencyKey(_ context.Context, _ bson.ObjectID, key string) (*model.Payment, error) { - if p, ok := s.byIdem[key]; ok { - return p, nil - } - return nil, storage.ErrPaymentNotFound -} - -func (s *helperPaymentStore) GetByChainTransferRef(_ context.Context, ref string) (*model.Payment, error) { - if p, ok := s.byChain[ref]; ok { - return p, nil - } - return nil, storage.ErrPaymentNotFound -} - -func (s *helperPaymentStore) List(_ context.Context, _ *model.PaymentFilter) (*model.PaymentList, error) { - return &model.PaymentList{}, nil -} - -type helperQuotesStore struct { - records map[string]*model.PaymentQuoteRecord -} - -func (s *helperQuotesStore) Create(_ context.Context, _ *model.PaymentQuoteRecord) error { return nil } - -func (s *helperQuotesStore) GetByRef(_ context.Context, _ bson.ObjectID, ref string) (*model.PaymentQuoteRecord, error) { - if s.records == nil { - return nil, quotestorage.ErrQuoteNotFound - } - if rec, ok := s.records[ref]; ok { - return rec, nil - } - return nil, quotestorage.ErrQuoteNotFound -} - -func (s *helperQuotesStore) GetByIdempotencyKey(_ context.Context, orgRef bson.ObjectID, ref string) (*model.PaymentQuoteRecord, error) { - if s.records == nil { - return nil, quotestorage.ErrQuoteNotFound - } - for _, rec := range s.records { - if rec.OrganizationRef != orgRef { - continue - } - if rec.IdempotencyKey == ref { - return rec, nil - } - } - return nil, quotestorage.ErrQuoteNotFound -} - -type helperQuotationClient struct { - quotePaymentFn func(ctx context.Context, req *quotationv1.QuotePaymentRequest, opts ...grpc.CallOption) (*quotationv1.QuotePaymentResponse, error) - quotePaymentsFn func(ctx context.Context, req *quotationv1.QuotePaymentsRequest, opts ...grpc.CallOption) (*quotationv1.QuotePaymentsResponse, error) -} - -func (c *helperQuotationClient) QuotePayment(ctx context.Context, req *quotationv1.QuotePaymentRequest, opts ...grpc.CallOption) (*quotationv1.QuotePaymentResponse, error) { - if c.quotePaymentFn != nil { - return c.quotePaymentFn(ctx, req, opts...) - } - return "ationv1.QuotePaymentResponse{}, nil -} - -func (c *helperQuotationClient) QuotePayments(ctx context.Context, req *quotationv1.QuotePaymentsRequest, opts ...grpc.CallOption) (*quotationv1.QuotePaymentsResponse, error) { - if c.quotePaymentsFn != nil { - return c.quotePaymentsFn(ctx, req, opts...) - } - return "ationv1.QuotePaymentsResponse{}, nil -} - -func rolePtr(role account_role.AccountRole) *account_role.AccountRole { - return &role -} - -type stubGatewayRegistry struct { - items []*model.GatewayInstanceDescriptor -} - -func (s *stubGatewayRegistry) List(_ context.Context) ([]*model.GatewayInstanceDescriptor, error) { - return s.items, nil -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/service_registration_test.go b/api/payments/orchestrator/internal/service/orchestrator/service_registration_test.go new file mode 100644 index 00000000..0d3ffdb2 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/service_registration_test.go @@ -0,0 +1,74 @@ +package orchestrator + +import ( + "context" + "net" + "testing" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/psvc" + "github.com/tech/sendico/pkg/api/routers" + orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2" + "google.golang.org/grpc" +) + +func TestService_RegisterV2Only(t *testing.T) { + svc := &Service{v2: fakeOrchestrationV2Service{}} + router := newGRPCCaptureRouterV2() + + if err := svc.Register(router); err != nil { + t.Fatalf("Register returned error: %v", err) + } + + services := router.server.GetServiceInfo() + if _, ok := services[orchestrationv2.PaymentOrchestratorService_ServiceDesc.ServiceName]; !ok { + t.Fatalf("expected %q service to be registered", orchestrationv2.PaymentOrchestratorService_ServiceDesc.ServiceName) + } + if len(services) != 1 { + t.Fatalf("expected exactly one registered service, got %d", len(services)) + } +} + +type fakeOrchestrationV2Service struct{} + +func (fakeOrchestrationV2Service) ExecutePayment(context.Context, *orchestrationv2.ExecutePaymentRequest) (*orchestrationv2.ExecutePaymentResponse, error) { + return &orchestrationv2.ExecutePaymentResponse{}, nil +} + +func (fakeOrchestrationV2Service) GetPayment(context.Context, *orchestrationv2.GetPaymentRequest) (*orchestrationv2.GetPaymentResponse, error) { + return &orchestrationv2.GetPaymentResponse{}, nil +} + +func (fakeOrchestrationV2Service) ListPayments(context.Context, *orchestrationv2.ListPaymentsRequest) (*orchestrationv2.ListPaymentsResponse, error) { + return &orchestrationv2.ListPaymentsResponse{}, nil +} + +func (fakeOrchestrationV2Service) ReconcileExternal(context.Context, psvc.ReconcileExternalInput) (*psvc.ReconcileExternalOutput, error) { + return &psvc.ReconcileExternalOutput{}, nil +} + +type grpcCaptureRouterV2 struct { + server *grpc.Server + done chan error +} + +func newGRPCCaptureRouterV2() *grpcCaptureRouterV2 { + return &grpcCaptureRouterV2{ + server: grpc.NewServer(), + done: make(chan error), + } +} + +func (r *grpcCaptureRouterV2) Register(registration routers.GRPCServiceRegistration) error { + registration(r.server) + return nil +} + +func (r *grpcCaptureRouterV2) Start(context.Context) error { return nil } + +func (r *grpcCaptureRouterV2) Finish(context.Context) error { return nil } + +func (r *grpcCaptureRouterV2) Addr() net.Addr { return nil } + +func (r *grpcCaptureRouterV2) Done() <-chan error { return r.done } + +var _ routers.GRPC = (*grpcCaptureRouterV2)(nil) diff --git a/api/payments/orchestrator/internal/service/orchestrator/service_test.go b/api/payments/orchestrator/internal/service/orchestrator/service_test.go deleted file mode 100644 index ff2384c8..00000000 --- a/api/payments/orchestrator/internal/service/orchestrator/service_test.go +++ /dev/null @@ -1,646 +0,0 @@ -package orchestrator - -import ( - "context" - "errors" - "strings" - "testing" - "time" - - ledgerclient "github.com/tech/sendico/ledger/client" - "github.com/tech/sendico/payments/storage" - "github.com/tech/sendico/payments/storage/model" - quotestorage "github.com/tech/sendico/payments/storage/quote" - "github.com/tech/sendico/pkg/api/routers/gsresponse" - "github.com/tech/sendico/pkg/merrors" - mo "github.com/tech/sendico/pkg/model" - "github.com/tech/sendico/pkg/model/account_role" - "github.com/tech/sendico/pkg/payments/rail" - paymenttypes "github.com/tech/sendico/pkg/payments/types" - moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" - chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" - ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1" - oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" - orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestration/v1" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" - "go.mongodb.org/mongo-driver/v2/bson" - "go.uber.org/zap" -) - -func TestExecutePayment_FXConversionSettled(t *testing.T) { - ctx := context.Background() - - store := newStubPaymentsStore() - repo := &stubRepository{store: store} - svc := &Service{ - logger: zap.NewNop(), - clock: testClock{now: time.Now()}, - storage: repo, - deps: serviceDependencies{ - ledger: func() ledgerDependency { - fake := &ledgerclient.Fake{ - ApplyFXWithChargesFn: func(ctx context.Context, req *ledgerv1.FXRequest) (*ledgerv1.PostResponse, error) { - return &ledgerv1.PostResponse{JournalEntryRef: "fx-entry"}, nil - }, - } - return ledgerDependency{ - client: fake, - internal: fake, - } - }(), - }, - } - - payment := &model.Payment{ - PaymentRef: "fx-1", - IdempotencyKey: "fx-1", - OrganizationBoundBase: mo.OrganizationBoundBase{OrganizationRef: bson.NewObjectID()}, - Intent: model.PaymentIntent{ - Ref: "ref-1", - Kind: model.PaymentKindFXConversion, - Source: model.PaymentEndpoint{ - Type: model.EndpointTypeLedger, - Ledger: &model.LedgerEndpoint{LedgerAccountRef: "ledger:source"}, - }, - Destination: model.PaymentEndpoint{ - Type: model.EndpointTypeLedger, - Ledger: &model.LedgerEndpoint{LedgerAccountRef: "ledger:dest"}, - }, - Amount: &paymenttypes.Money{Currency: "USD", Amount: "100"}, - SettlementCurrency: "USD", - }, - } - store.payments[payment.PaymentRef] = payment - - quote := &sharedv1.PaymentQuote{ - FxQuote: &oraclev1.Quote{ - QuoteRef: "quote-1", - BaseAmount: &moneyv1.Money{Currency: "USD", Amount: "100"}, - QuoteAmount: &moneyv1.Money{Currency: "EUR", Amount: "90"}, - Price: &moneyv1.Decimal{Value: "0.9"}, - }, - } - attachStoredPlan(payment, &model.PaymentPlan{ - Steps: []*model.PaymentStep{ - { - StepID: "fx_convert", - Rail: model.RailLedger, - Action: model.RailOperationFXConvert, - }, - }, - }, payment.IdempotencyKey) - if err := svc.executePayment(ctx, store, payment, quote); err != nil { - t.Fatalf("executePayment returned error: %v", err) - } - - if payment.State != model.PaymentStateSettled { - t.Fatalf("expected payment settled, got %s", payment.State) - } - - if payment.Execution == nil || payment.Execution.FXEntryRef == "" { - t.Fatal("expected FX entry ref set on payment execution") - } -} - -func TestExecutePayment_ChainFailure(t *testing.T) { - ctx := context.Background() - - store := newStubPaymentsStore() - routes := &stubRoutesStore{ - routes: []*model.PaymentRoute{ - {FromRail: model.RailCrypto, ToRail: model.RailLedger, Network: "TRON_MAINNET", IsEnabled: true}, - }, - } - plans := &stubPlanTemplatesStore{ - templates: []*model.PaymentPlanTemplate{ - { - FromRail: model.RailCrypto, - ToRail: model.RailLedger, - Network: "TRON_MAINNET", - IsEnabled: true, - Steps: []model.OrchestrationStep{ - {StepID: "crypto_send", Rail: model.RailCrypto, Operation: "payout.crypto"}, - {StepID: "crypto_observe", Rail: model.RailCrypto, Operation: "observe.confirm", DependsOn: []string{"crypto_send"}}, - {StepID: "ledger_credit", Rail: model.RailLedger, Operation: "ledger.move", DependsOn: []string{"crypto_observe"}, FromRole: rolePtr(account_role.AccountRolePending), ToRole: rolePtr(account_role.AccountRoleOperating)}, - }, - }, - }, - } - repo := &stubRepository{store: store, routes: routes, plans: plans} - svc := &Service{ - logger: zap.NewNop(), - clock: testClock{now: time.Now()}, - storage: repo, - deps: serviceDependencies{ - railGateways: buildRailGatewayDependency(map[string]rail.RailGateway{ - "crypto-tron": &fakeRailGateway{ - rail: "CRYPTO", - sendFn: func(ctx context.Context, req rail.TransferRequest) (rail.RailResult, error) { - return rail.RailResult{}, errors.New("chain failure") - }, - }, - }, nil, nil, nil, nil), - gatewayRegistry: &stubGatewayRegistry{ - items: []*model.GatewayInstanceDescriptor{ - { - ID: "crypto-tron", - InstanceID: "crypto-tron-1", - Rail: model.RailCrypto, - Network: "TRON_MAINNET", - Currencies: []string{"USDT"}, - Capabilities: model.RailCapabilities{ - CanPayOut: true, - RequiresObserveConfirm: true, - }, - Limits: model.Limits{MinAmount: "0", MaxAmount: "100000"}, - IsEnabled: true, - }, - }, - }, - cardRoutes: map[string]CardGatewayRoute{ - defaultCardGateway: { - FundingAddress: "funding-address", - }, - }, - }, - } - - payment := &model.Payment{ - PaymentRef: "chain-1", - IdempotencyKey: "chain-1", - OrganizationBoundBase: mo.OrganizationBoundBase{OrganizationRef: bson.NewObjectID()}, - Intent: model.PaymentIntent{ - Ref: "ref-1", - Kind: model.PaymentKindPayout, - Source: model.PaymentEndpoint{ - Type: model.EndpointTypeManagedWallet, - ManagedWallet: &model.ManagedWalletEndpoint{ - ManagedWalletRef: "wallet-src", - Asset: &paymenttypes.Asset{ - Chain: "TRON_MAINNET", - TokenSymbol: "USDT", - }, - }, - }, - Destination: model.PaymentEndpoint{ - Type: model.EndpointTypeLedger, - Ledger: &model.LedgerEndpoint{LedgerAccountRef: "ledger:dest"}, - }, - Amount: &paymenttypes.Money{Currency: "USDT", Amount: "50"}, - SettlementCurrency: "USDT", - }, - } - store.payments[payment.PaymentRef] = payment - - attachStoredPlan(payment, &model.PaymentPlan{ - Steps: []*model.PaymentStep{ - { - StepID: "crypto_send", - Rail: model.RailCrypto, - Action: model.RailOperationSend, - Amount: &paymenttypes.Money{Currency: "USDT", Amount: "50"}, - }, - }, - }, payment.IdempotencyKey) - err := svc.executePayment(ctx, store, payment, &sharedv1.PaymentQuote{}) - if err == nil || err.Error() != "chain failure" { - t.Fatalf("expected chain failure error, got %v", err) - } - if payment.State != model.PaymentStateFailed { - t.Fatalf("expected payment failed, got %s", payment.State) - } - if payment.FailureCode != model.PaymentFailureCodeChain { - t.Fatalf("expected failure code chain, got %s", payment.FailureCode) - } -} - -func TestProcessTransferUpdateHandler_Settled(t *testing.T) { - ctx := context.Background() - payment := &model.Payment{ - PaymentRef: "pay-1", - State: model.PaymentStateSubmitted, - Execution: &model.ExecutionRefs{ChainTransferRef: "transfer-1"}, - } - store := newStubPaymentsStore() - store.payments[payment.PaymentRef] = payment - store.byChain["transfer-1"] = payment - - svc := &Service{ - logger: zap.NewNop(), - clock: testClock{now: time.Now()}, - storage: &stubRepository{store: store}, - } - svc.h.events = newPaymentEventHandler(svc.storage, svc.ensureRepository, svc.logger, nil, nil, nil) - - req := &orchestratorv1.ProcessTransferUpdateRequest{ - Event: &chainv1.TransferStatusChangedEvent{ - Transfer: &chainv1.Transfer{ - TransferRef: "transfer-1", - Status: chainv1.TransferStatus_TRANSFER_SUCCESS, - }, - }, - } - - reSP, err := gsresponse.Execute(ctx, svc.h.events.processTransferUpdate(ctx, req)) - if err != nil { - t.Fatalf("handler returned error: %v", err) - } - if reSP.GetPayment().GetState() != sharedv1.PaymentState_PAYMENT_STATE_SETTLED { - t.Fatalf("expected settled state, got %s", reSP.GetPayment().GetState()) - } -} - -func TestProcessTransferUpdateHandler_CardFundingWaitsForSources(t *testing.T) { - ctx := context.Background() - - payment := &model.Payment{ - PaymentRef: "pay-card", - State: model.PaymentStateSubmitted, - Intent: model.PaymentIntent{ - Ref: "ref-1", - Destination: model.PaymentEndpoint{ - Type: model.EndpointTypeCard, - Card: &model.CardEndpoint{MaskedPan: "4111"}, - }, - }, - Execution: &model.ExecutionRefs{ChainTransferRef: "fund-1"}, - } - - plan := ensureExecutionPlan(payment) - fundStep := ensureExecutionStep(plan, stepCodeFundingTransfer) - fundStep.TransferRef = "fund-1" - setExecutionStepRole(fundStep, executionStepRoleSource) - setExecutionStepStatus(fundStep, model.OperationStateWaiting) - - feeStep := ensureExecutionStep(plan, stepCodeFeeTransfer) - feeStep.TransferRef = "fee-1" - setExecutionStepRole(feeStep, executionStepRoleSource) - setExecutionStepStatus(feeStep, model.OperationStateWaiting) - - cardStep := ensureExecutionStep(plan, stepCodeCardPayout) - setExecutionStepRole(cardStep, executionStepRoleConsumer) - setExecutionStepStatus(cardStep, model.OperationStatePlanned) - - store := newStubPaymentsStore() - store.payments[payment.PaymentRef] = payment - store.indexTransfers(payment) - - svc := &Service{ - logger: zap.NewNop(), - clock: testClock{now: time.Now()}, - storage: &stubRepository{store: store}, - } - - payoutCalls := 0 - submit := func(ctx context.Context, operationRef string, payment *model.Payment) error { - payoutCalls++ - if payment.Execution == nil { - payment.Execution = &model.ExecutionRefs{} - } - payment.Execution.CardPayoutRef = "payout-1" - plan := ensureExecutionPlan(payment) - step := ensureExecutionStep(plan, stepCodeCardPayout) - setExecutionStepRole(step, executionStepRoleConsumer) - step.TransferRef = "payout-1" - step.OperationRef = operationRef - setExecutionStepStatus(step, model.OperationStateWaiting) - return nil - } - svc.h.events = newPaymentEventHandler(svc.storage, svc.ensureRepository, svc.logger, submit, nil, nil) - - req := &orchestratorv1.ProcessTransferUpdateRequest{ - Event: &chainv1.TransferStatusChangedEvent{ - Transfer: &chainv1.Transfer{ - TransferRef: "fund-1", - Status: chainv1.TransferStatus_TRANSFER_SUCCESS, - }, - }, - } - resp, err := gsresponse.Execute(ctx, svc.h.events.processTransferUpdate(ctx, req)) - if err != nil { - t.Fatalf("handler returned error: %v", err) - } - if payoutCalls != 0 { - t.Fatalf("expected no payout on first confirmation, got %d", payoutCalls) - } - if fundStep.State != model.OperationStateSuccess { - t.Fatalf("expected funding step confirmed, got %s", feeStep.State) - } - if resp.GetPayment().GetState() != sharedv1.PaymentState_PAYMENT_STATE_SUBMITTED { - t.Fatalf("expected submitted state, got %s", resp.GetPayment().GetState()) - } - - req = &orchestratorv1.ProcessTransferUpdateRequest{ - Event: &chainv1.TransferStatusChangedEvent{ - Transfer: &chainv1.Transfer{ - TransferRef: "fee-1", - Status: chainv1.TransferStatus_TRANSFER_SUCCESS, - }, - }, - } - resp, err = gsresponse.Execute(ctx, svc.h.events.processTransferUpdate(ctx, req)) - if err != nil { - t.Fatalf("handler returned error: %v", err) - } - if payoutCalls != 1 { - t.Fatalf("expected payout after all sources confirmed, got %d", payoutCalls) - } - if feeStep.State != model.OperationStateSuccess { - t.Fatalf("expected fee step confirmed, got %s", string(model.OperationStateSuccess)) - } - if resp.GetPayment().GetExecution().GetCardPayoutRef() != "payout-1" { - t.Fatalf("expected card payout ref set, got %s", resp.GetPayment().GetExecution().GetCardPayoutRef()) - } -} - -func TestProcessDepositObservedHandler_MatchesPayment(t *testing.T) { - ctx := context.Background() - payment := &model.Payment{ - PaymentRef: "pay-2", - State: model.PaymentStateSubmitted, - Intent: model.PaymentIntent{ - Ref: "ref-2", - Destination: model.PaymentEndpoint{ - Type: model.EndpointTypeManagedWallet, - ManagedWallet: &model.ManagedWalletEndpoint{ - ManagedWalletRef: "wallet-dst", - }, - }, - Amount: &paymenttypes.Money{Currency: "USD", Amount: "40"}, - }, - } - store := newStubPaymentsStore() - store.listResp = &model.PaymentList{Items: []*model.Payment{payment}} - store.payments[payment.PaymentRef] = payment - - svc := &Service{ - logger: zap.NewNop(), - clock: testClock{now: time.Now()}, - storage: &stubRepository{store: store}, - } - svc.h.events = newPaymentEventHandler(svc.storage, svc.ensureRepository, svc.logger, nil, nil, nil) - - req := &orchestratorv1.ProcessDepositObservedRequest{ - Event: &chainv1.WalletDepositObservedEvent{ - WalletRef: "wallet-dst", - Amount: &moneyv1.Money{Currency: "USD", Amount: "40"}, - }, - } - - reSP, err := gsresponse.Execute(ctx, svc.h.events.processDepositObserved(ctx, req)) - if err != nil { - t.Fatalf("handler returned error: %v", err) - } - if reSP.GetPayment().GetState() != sharedv1.PaymentState_PAYMENT_STATE_SETTLED { - t.Fatalf("expected settled state, got %s", reSP.GetPayment().GetState()) - } -} - -// ---------------------------------------------------------------------- - -type stubRepository struct { - store *stubPaymentsStore - quotes quotestorage.QuotesStore - routes storage.RoutesStore - plans storage.PlanTemplatesStore -} - -func (r *stubRepository) Ping(context.Context) error { return nil } -func (r *stubRepository) Payments() storage.PaymentsStore { return r.store } -func (r *stubRepository) PaymentMethods() storage.PaymentMethodsStore { - return nil -} -func (r *stubRepository) Quotes() quotestorage.QuotesStore { - if r.quotes != nil { - return r.quotes - } - return &stubQuotesStore{} -} -func (r *stubRepository) Routes() storage.RoutesStore { - if r.routes != nil { - return r.routes - } - return &stubRoutesStore{} -} - -func (r *stubRepository) PlanTemplates() storage.PlanTemplatesStore { - if r.plans != nil { - return r.plans - } - return &stubPlanTemplatesStore{} -} - -type stubQuotesStore struct { - quotes map[string]*model.PaymentQuoteRecord -} - -func (s *stubQuotesStore) Create(ctx context.Context, quote *model.PaymentQuoteRecord) error { - if quote == nil { - return merrors.InvalidArgument("nil quote") - } - if s.quotes == nil { - s.quotes = map[string]*model.PaymentQuoteRecord{} - } - s.quotes[strings.TrimSpace(quote.QuoteRef)] = quote - return nil -} - -func (s *stubQuotesStore) GetByRef(ctx context.Context, orgRef bson.ObjectID, quoteRef string) (*model.PaymentQuoteRecord, error) { - if s.quotes == nil { - return nil, quotestorage.ErrQuoteNotFound - } - if q, ok := s.quotes[strings.TrimSpace(quoteRef)]; ok { - return q, nil - } - return nil, quotestorage.ErrQuoteNotFound -} - -func (s *stubQuotesStore) GetByIdempotencyKey(ctx context.Context, orgRef bson.ObjectID, idempotencyKey string) (*model.PaymentQuoteRecord, error) { - if s.quotes == nil { - return nil, quotestorage.ErrQuoteNotFound - } - for _, q := range s.quotes { - if q.OrganizationRef != orgRef { - continue - } - if q.IdempotencyKey == idempotencyKey { - return q, nil - } - } - return nil, quotestorage.ErrQuoteNotFound -} - -type stubRoutesStore struct { - routes []*model.PaymentRoute -} - -func (s *stubRoutesStore) Create(ctx context.Context, route *model.PaymentRoute) error { - return merrors.InvalidArgument("routes store not implemented") -} - -func (s *stubRoutesStore) Update(ctx context.Context, route *model.PaymentRoute) error { - return merrors.InvalidArgument("routes store not implemented") -} - -func (s *stubRoutesStore) GetByID(ctx context.Context, id bson.ObjectID) (*model.PaymentRoute, error) { - return nil, storage.ErrRouteNotFound -} - -func (s *stubRoutesStore) List(ctx context.Context, filter *model.PaymentRouteFilter) (*model.PaymentRouteList, error) { - items := make([]*model.PaymentRoute, 0, len(s.routes)) - for _, route := range s.routes { - if route == nil { - continue - } - if filter != nil { - if filter.FromRail != "" && route.FromRail != filter.FromRail { - continue - } - if filter.ToRail != "" && route.ToRail != filter.ToRail { - continue - } - if filter.Network != "" && !strings.EqualFold(route.Network, filter.Network) { - continue - } - } - if filter != nil && filter.IsEnabled != nil { - if route.IsEnabled != *filter.IsEnabled { - continue - } - } - items = append(items, route) - } - return &model.PaymentRouteList{Items: items}, nil -} - -type stubPlanTemplatesStore struct { - templates []*model.PaymentPlanTemplate -} - -func (s *stubPlanTemplatesStore) Create(ctx context.Context, template *model.PaymentPlanTemplate) error { - return merrors.InvalidArgument("plan templates store not implemented") -} - -func (s *stubPlanTemplatesStore) Update(ctx context.Context, template *model.PaymentPlanTemplate) error { - return merrors.InvalidArgument("plan templates store not implemented") -} - -func (s *stubPlanTemplatesStore) GetByID(ctx context.Context, id bson.ObjectID) (*model.PaymentPlanTemplate, error) { - return nil, storage.ErrPlanTemplateNotFound -} - -func (s *stubPlanTemplatesStore) List(ctx context.Context, filter *model.PaymentPlanTemplateFilter) (*model.PaymentPlanTemplateList, error) { - items := make([]*model.PaymentPlanTemplate, 0, len(s.templates)) - for _, tpl := range s.templates { - if tpl == nil { - continue - } - if filter != nil { - if filter.FromRail != "" && tpl.FromRail != filter.FromRail { - continue - } - if filter.ToRail != "" && tpl.ToRail != filter.ToRail { - continue - } - if filter.Network != "" && !strings.EqualFold(tpl.Network, filter.Network) { - continue - } - } - if filter != nil && filter.IsEnabled != nil { - if tpl.IsEnabled != *filter.IsEnabled { - continue - } - } - items = append(items, tpl) - } - return &model.PaymentPlanTemplateList{Items: items}, nil -} - -type stubPaymentsStore struct { - payments map[string]*model.Payment - byChain map[string]*model.Payment - listResp *model.PaymentList -} - -func newStubPaymentsStore() *stubPaymentsStore { - return &stubPaymentsStore{ - payments: map[string]*model.Payment{}, - byChain: map[string]*model.Payment{}, - } -} - -func (s *stubPaymentsStore) Create(ctx context.Context, payment *model.Payment) error { - if _, exists := s.payments[payment.PaymentRef]; exists { - return storage.ErrDuplicatePayment - } - s.payments[payment.PaymentRef] = payment - s.indexTransfers(payment) - return nil -} - -func (s *stubPaymentsStore) Update(ctx context.Context, payment *model.Payment) error { - if _, exists := s.payments[payment.PaymentRef]; !exists { - return storage.ErrPaymentNotFound - } - s.payments[payment.PaymentRef] = payment - s.indexTransfers(payment) - return nil -} - -func (s *stubPaymentsStore) GetByPaymentRef(ctx context.Context, paymentRef string) (*model.Payment, error) { - if p, ok := s.payments[paymentRef]; ok { - return p, nil - } - return nil, storage.ErrPaymentNotFound -} - -func (s *stubPaymentsStore) GetByIdempotencyKey(ctx context.Context, orgRef bson.ObjectID, key string) (*model.Payment, error) { - for _, p := range s.payments { - if p.OrganizationRef == orgRef && strings.TrimSpace(p.IdempotencyKey) == key { - return p, nil - } - } - return nil, storage.ErrPaymentNotFound -} - -func (s *stubPaymentsStore) GetByChainTransferRef(ctx context.Context, transferRef string) (*model.Payment, error) { - if p, ok := s.byChain[transferRef]; ok { - return p, nil - } - return nil, storage.ErrPaymentNotFound -} - -func (s *stubPaymentsStore) List(ctx context.Context, filter *model.PaymentFilter) (*model.PaymentList, error) { - if s.listResp != nil { - return s.listResp, nil - } - return &model.PaymentList{}, nil -} - -func (s *stubPaymentsStore) indexTransfers(payment *model.Payment) { - if payment == nil { - return - } - if payment.Execution != nil && payment.Execution.ChainTransferRef != "" { - s.byChain[payment.Execution.ChainTransferRef] = payment - } - if payment.ExecutionPlan == nil { - return - } - for _, step := range payment.ExecutionPlan.Steps { - if step == nil || strings.TrimSpace(step.TransferRef) == "" { - continue - } - s.byChain[strings.TrimSpace(step.TransferRef)] = payment - } -} - -var _ storage.PaymentsStore = (*stubPaymentsStore)(nil) - -// testClock satisfies clock.Clock - -type testClock struct { - now time.Time -} - -func (c testClock) Now() time.Time { return c.now } diff --git a/api/payments/orchestrator/internal/service/orchestrator/service_v2.go b/api/payments/orchestrator/internal/service/orchestrator/service_v2.go new file mode 100644 index 00000000..ed6281a4 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/service_v2.go @@ -0,0 +1,112 @@ +package orchestrator + +import ( + "context" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/oobs" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/pquery" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/prepo" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/psvc" + "github.com/tech/sendico/payments/storage" + "github.com/tech/sendico/pkg/mlogger" + orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.uber.org/zap" +) + +type v2MongoDBProvider interface { + MongoDatabase() *mongo.Database +} + +func newOrchestrationV2Service(logger mlogger.Logger, repo storage.Repository) psvc.Service { + if logger == nil { + logger = zap.NewNop() + } + if repo == nil { + return nil + } + + paymentRepo := buildPaymentRepositoryV2(repo, logger) + if paymentRepo == nil { + if logger != nil { + logger.Warn("Orchestration v2 disabled: mongo database not available") + } + return nil + } + + query, err := pquery.New(pquery.Dependencies{ + Repository: paymentRepo, + Logger: logger.Named("orchestration_v2_pquery"), + }) + if err != nil { + if logger != nil { + logger.Warn("Orchestration v2 disabled: query service init failed", zap.Error(err)) + } + return nil + } + observer, err := oobs.New(oobs.Dependencies{Logger: logger.Named("orchestration_v2_observer")}) + if err != nil { + if logger != nil { + logger.Warn("Orchestration v2 disabled: observer init failed", zap.Error(err)) + } + return nil + } + + svc, err := psvc.New(psvc.Dependencies{ + Logger: logger.Named("orchestration_v2_psvc"), + QuoteStore: repo.Quotes(), + Repository: paymentRepo, + Query: query, + Observer: observer, + }) + if err != nil { + if logger != nil { + logger.Warn("Orchestration v2 disabled: service init failed", zap.Error(err)) + } + return nil + } + return svc +} + +func buildPaymentRepositoryV2(repo storage.Repository, logger mlogger.Logger) prepo.Repository { + if repo == nil { + return nil + } + provider, ok := repo.(v2MongoDBProvider) + if !ok { + return nil + } + db := provider.MongoDatabase() + if db == nil { + return nil + } + paymentRepo, err := prepo.NewMongo( + db.Collection("payments_v2"), + prepo.Dependencies{Logger: logger.Named("orchestration_v2_prepo")}, + ) + if err != nil { + return nil + } + return paymentRepo +} + +type v2GRPCServer struct { + orchestrationv2.UnimplementedPaymentOrchestratorServiceServer + svc psvc.Service +} + +func newV2GRPCServer(svc psvc.Service) *v2GRPCServer { + return &v2GRPCServer{svc: svc} +} + +func (s *v2GRPCServer) ExecutePayment(ctx context.Context, req *orchestrationv2.ExecutePaymentRequest) (*orchestrationv2.ExecutePaymentResponse, error) { + return s.svc.ExecutePayment(ctx, req) +} + +func (s *v2GRPCServer) GetPayment(ctx context.Context, req *orchestrationv2.GetPaymentRequest) (*orchestrationv2.GetPaymentResponse, error) { + return s.svc.GetPayment(ctx, req) +} + +func (s *v2GRPCServer) ListPayments(ctx context.Context, req *orchestrationv2.ListPaymentsRequest) (*orchestrationv2.ListPaymentsResponse, error) { + return s.svc.ListPayments(ctx, req) +} diff --git a/api/payments/orchestrator/internal/service/plan_builder/gateways.go b/api/payments/orchestrator/internal/service/plan_builder/gateways.go index 5316ebb0..50de480f 100644 --- a/api/payments/orchestrator/internal/service/plan_builder/gateways.go +++ b/api/payments/orchestrator/internal/service/plan_builder/gateways.go @@ -2,7 +2,6 @@ package plan_builder import ( "context" - "fmt" "sort" "strings" @@ -58,8 +57,8 @@ func validateGatewayAction(gw *model.GatewayInstanceDescriptor, network string, amt = value currency = strings.ToUpper(strings.TrimSpace(amount.GetCurrency())) } - if err := isGatewayEligible(gw, gw.Rail, network, currency, action, dir, amt); err != nil { - return merrors.InvalidArgument("plan builder: gateway instance is not eligible: " + err.Error()) + if err := model.IsGatewayEligible(gw, gw.Rail, network, currency, action, toGatewayDirection(dir), amt); err != nil { + return merrors.NoData(model.NoEligibleGatewayMessage(network, currency, action, toGatewayDirection(dir))) } return nil } @@ -105,19 +104,14 @@ func selectGateway(ctx context.Context, registry GatewayRegistry, rail model.Rai network = strings.ToUpper(strings.TrimSpace(network)) eligible := make([]*model.GatewayInstanceDescriptor, 0) - var lastErr error for _, gw := range all { - if err := isGatewayEligible(gw, rail, network, currency, action, dir, amt); err != nil { - lastErr = err + if err := model.IsGatewayEligible(gw, rail, network, currency, action, toGatewayDirection(dir), amt); err != nil { continue } eligible = append(eligible, gw) } if len(eligible) == 0 { - if lastErr != nil { - return nil, merrors.InvalidArgument("plan builder: no eligible gateway instance found, last error: " + lastErr.Error()) - } - return nil, merrors.InvalidArgument("plan builder: no eligible gateway instance found") + return nil, merrors.NoData(model.NoEligibleGatewayMessage(network, currency, action, toGatewayDirection(dir))) } sort.Slice(eligible, func(i, j int) bool { return eligible[i].ID < eligible[j].ID @@ -132,142 +126,17 @@ func selectGateway(ctx context.Context, registry GatewayRegistry, rail model.Rai return eligible[0], nil } -type gatewayIneligibleError struct { - reason string -} - -func (e gatewayIneligibleError) Error() string { - return e.reason -} - -func gatewayIneligible(gw *model.GatewayInstanceDescriptor, reason string) error { - if strings.TrimSpace(reason) == "" { - reason = "gateway instance is not eligible" - } - return gatewayIneligibleError{reason: fmt.Sprintf("gateway %s eligibility check error: %s", gw.InstanceID, reason)} -} - func sendDirectionLabel(dir sendDirection) string { + return toGatewayDirection(dir).String() +} + +func toGatewayDirection(dir sendDirection) model.GatewayDirection { switch dir { case sendDirectionOut: - return "out" + return model.GatewayDirectionOut case sendDirectionIn: - return "in" + return model.GatewayDirectionIn default: - return "any" + return model.GatewayDirectionAny } } - -func isGatewayEligible(gw *model.GatewayInstanceDescriptor, rail model.Rail, network, currency string, action model.RailOperation, dir sendDirection, amount decimal.Decimal) error { - if gw == nil { - return gatewayIneligible(gw, "gateway instance is required") - } - if !gw.IsEnabled { - return gatewayIneligible(gw, "gateway instance is disabled") - } - if gw.Rail != rail { - return gatewayIneligible(gw, fmt.Sprintf("rail mismatch: want %s got %s", rail, gw.Rail)) - } - if network != "" && gw.Network != "" && !strings.EqualFold(gw.Network, network) { - return gatewayIneligible(gw, fmt.Sprintf("network mismatch: want %s got %s", network, gw.Network)) - } - if currency != "" && len(gw.Currencies) > 0 { - found := false - for _, c := range gw.Currencies { - if strings.EqualFold(c, currency) { - found = true - break - } - } - if !found { - return gatewayIneligible(gw, "currency not supported: "+currency) - } - } - - if !capabilityAllowsAction(gw.Capabilities, action, dir) { - return gatewayIneligible(gw, fmt.Sprintf("capability does not allow action=%s dir=%s", action, sendDirectionLabel(dir))) - } - - if currency != "" { - if err := amountWithinLimits(gw, gw.Limits, currency, amount, action); err != nil { - return err - } - } - return nil -} - -func capabilityAllowsAction(cap model.RailCapabilities, action model.RailOperation, dir sendDirection) bool { - switch action { - case model.RailOperationSend: - switch dir { - case sendDirectionOut: - return cap.CanPayOut - case sendDirectionIn: - return cap.CanPayIn - default: - return cap.CanPayIn || cap.CanPayOut - } - case model.RailOperationFee: - return cap.CanSendFee - case model.RailOperationObserveConfirm: - return cap.RequiresObserveConfirm - case model.RailOperationBlock: - return cap.CanBlock - case model.RailOperationRelease: - return cap.CanRelease - default: - return true - } -} - -func amountWithinLimits(gw *model.GatewayInstanceDescriptor, limits model.Limits, currency string, amount decimal.Decimal, action model.RailOperation) error { - min := firstLimitValue(limits.MinAmount, "") - max := firstLimitValue(limits.MaxAmount, "") - perTxMin := firstLimitValue(limits.PerTxMinAmount, "") - perTxMax := firstLimitValue(limits.PerTxMaxAmount, "") - maxFee := firstLimitValue(limits.PerTxMaxFee, "") - - if override, ok := limits.CurrencyLimits[currency]; ok { - min = firstLimitValue(override.MinAmount, min) - max = firstLimitValue(override.MaxAmount, max) - if action == model.RailOperationFee { - maxFee = firstLimitValue(override.MaxFee, maxFee) - } - } - - if min != "" { - if val, err := decimal.NewFromString(min); err == nil && amount.LessThan(val) { - return gatewayIneligible(gw, fmt.Sprintf("amount %s %s below min limit %s", amount.String(), currency, val.String())) - } - } - if perTxMin != "" { - if val, err := decimal.NewFromString(perTxMin); err == nil && amount.LessThan(val) { - return gatewayIneligible(gw, fmt.Sprintf("amount %s %s below per-tx min limit %s", amount.String(), currency, val.String())) - } - } - if max != "" { - if val, err := decimal.NewFromString(max); err == nil && amount.GreaterThan(val) { - return gatewayIneligible(gw, fmt.Sprintf("amount %s %s exceeds max limit %s", amount.String(), currency, val.String())) - } - } - if perTxMax != "" { - if val, err := decimal.NewFromString(perTxMax); err == nil && amount.GreaterThan(val) { - return gatewayIneligible(gw, fmt.Sprintf("amount %s %s exceeds per-tx max limit %s", amount.String(), currency, val.String())) - } - } - if action == model.RailOperationFee && maxFee != "" { - if val, err := decimal.NewFromString(maxFee); err == nil && amount.GreaterThan(val) { - return gatewayIneligible(gw, fmt.Sprintf("fee amount %s %s exceeds max fee limit %s", amount.String(), currency, val.String())) - } - } - - return nil -} - -func firstLimitValue(primary, fallback string) string { - val := strings.TrimSpace(primary) - if val != "" { - return val - } - return strings.TrimSpace(fallback) -} diff --git a/api/payments/quotation/go.mod b/api/payments/quotation/go.mod index 89c1abea..e83786c5 100644 --- a/api/payments/quotation/go.mod +++ b/api/payments/quotation/go.mod @@ -17,7 +17,6 @@ replace github.com/tech/sendico/ledger => ../../ledger replace github.com/tech/sendico/payments/storage => ../storage require ( - github.com/prometheus/client_golang v1.23.2 github.com/shopspring/decimal v1.4.0 github.com/tech/sendico/fx/oracle v0.0.0-00010101000000-000000000000 github.com/tech/sendico/gateway/chain v0.0.0-00010101000000-000000000000 @@ -45,9 +44,10 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/mapstructure v1.5.0 // 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/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/common v0.67.5 // indirect github.com/prometheus/procfs v0.19.2 // indirect @@ -63,5 +63,5 @@ require ( golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.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 ) diff --git a/api/payments/quotation/go.sum b/api/payments/quotation/go.sum index 254466a3..3800b6fb 100644 --- a/api/payments/quotation/go.sum +++ b/api/payments/quotation/go.sum @@ -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/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/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U= -github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= +github.com/nats-io/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE= +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/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs= 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= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= -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 h1:51Wupg8spF+5FC6D+iMKbOddFjMckETnNnEiZ+HX37s= +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/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/payments/quotation/internal/server/internal/serverimp.go b/api/payments/quotation/internal/server/internal/serverimp.go index f14f6d63..b6b3fa7b 100644 --- a/api/payments/quotation/internal/server/internal/serverimp.go +++ b/api/payments/quotation/internal/server/internal/serverimp.go @@ -64,7 +64,7 @@ func (i *Imp) Start() error { return svc, nil } - app, err := grpcapp.NewApp(i.logger, "payments_quotation", cfg.Config, i.debug, repoFactory, serviceFactory) + app, err := grpcapp.NewApp(i.logger, "payments.quotation", cfg.Config, i.debug, repoFactory, serviceFactory) if err != nil { return err } diff --git a/api/payments/quotation/internal/service/plan/builder.go b/api/payments/quotation/internal/service/plan/builder.go index a33d086f..b8b6a1d5 100644 --- a/api/payments/quotation/internal/service/plan/builder.go +++ b/api/payments/quotation/internal/service/plan/builder.go @@ -58,7 +58,7 @@ func SendDirectionForRail(rail model.Rail) SendDirection { } func IsGatewayEligible(gw *model.GatewayInstanceDescriptor, rail model.Rail, network, currency string, action model.RailOperation, dir SendDirection, amount decimal.Decimal) error { - return isGatewayEligible(gw, rail, network, currency, action, sendDirection(dir), amount) + return model.IsGatewayEligible(gw, rail, network, currency, action, toGatewayDirection(sendDirection(dir)), amount) } func ParseRailValue(value string) model.Rail { diff --git a/api/payments/quotation/internal/service/plan/plan_builder_gateways.go b/api/payments/quotation/internal/service/plan/plan_builder_gateways.go index ffa34496..e982dfba 100644 --- a/api/payments/quotation/internal/service/plan/plan_builder_gateways.go +++ b/api/payments/quotation/internal/service/plan/plan_builder_gateways.go @@ -2,7 +2,6 @@ package plan import ( "context" - "fmt" "sort" "strings" @@ -58,8 +57,8 @@ func validateGatewayAction(gw *model.GatewayInstanceDescriptor, network string, amt = value currency = strings.ToUpper(strings.TrimSpace(amount.GetCurrency())) } - if err := isGatewayEligible(gw, gw.Rail, network, currency, action, dir, amt); err != nil { - return merrors.InvalidArgument("plan builder: gateway instance is not eligible: " + err.Error()) + if err := model.IsGatewayEligible(gw, gw.Rail, network, currency, action, toGatewayDirection(dir), amt); err != nil { + return merrors.NoData(model.NoEligibleGatewayMessage(network, currency, action, toGatewayDirection(dir))) } return nil } @@ -105,19 +104,14 @@ func selectGateway(ctx context.Context, registry GatewayRegistry, rail model.Rai network = strings.ToUpper(strings.TrimSpace(network)) eligible := make([]*model.GatewayInstanceDescriptor, 0) - var lastErr error for _, gw := range all { - if err := isGatewayEligible(gw, rail, network, currency, action, dir, amt); err != nil { - lastErr = err + if err := model.IsGatewayEligible(gw, rail, network, currency, action, toGatewayDirection(dir), amt); err != nil { continue } eligible = append(eligible, gw) } if len(eligible) == 0 { - if lastErr != nil { - return nil, merrors.InvalidArgument("plan builder: no eligible gateway instance found, last error: " + lastErr.Error()) - } - return nil, merrors.InvalidArgument("plan builder: no eligible gateway instance found") + return nil, merrors.NoData(model.NoEligibleGatewayMessage(network, currency, action, toGatewayDirection(dir))) } sort.Slice(eligible, func(i, j int) bool { return eligible[i].ID < eligible[j].ID @@ -132,142 +126,17 @@ func selectGateway(ctx context.Context, registry GatewayRegistry, rail model.Rai return eligible[0], nil } -type gatewayIneligibleError struct { - reason string -} - -func (e gatewayIneligibleError) Error() string { - return e.reason -} - -func gatewayIneligible(gw *model.GatewayInstanceDescriptor, reason string) error { - if strings.TrimSpace(reason) == "" { - reason = "gateway instance is not eligible" - } - return gatewayIneligibleError{reason: fmt.Sprintf("gateway %s eligibility check error: %s", gw.InstanceID, reason)} -} - func sendDirectionLabel(dir sendDirection) string { + return toGatewayDirection(dir).String() +} + +func toGatewayDirection(dir sendDirection) model.GatewayDirection { switch dir { case sendDirectionOut: - return "out" + return model.GatewayDirectionOut case sendDirectionIn: - return "in" + return model.GatewayDirectionIn default: - return "any" + return model.GatewayDirectionAny } } - -func isGatewayEligible(gw *model.GatewayInstanceDescriptor, rail model.Rail, network, currency string, action model.RailOperation, dir sendDirection, amount decimal.Decimal) error { - if gw == nil { - return gatewayIneligible(gw, "gateway instance is required") - } - if !gw.IsEnabled { - return gatewayIneligible(gw, "gateway instance is disabled") - } - if gw.Rail != rail { - return gatewayIneligible(gw, fmt.Sprintf("rail mismatch: want %s got %s", rail, gw.Rail)) - } - if network != "" && gw.Network != "" && !strings.EqualFold(gw.Network, network) { - return gatewayIneligible(gw, fmt.Sprintf("network mismatch: want %s got %s", network, gw.Network)) - } - if currency != "" && len(gw.Currencies) > 0 { - found := false - for _, c := range gw.Currencies { - if strings.EqualFold(c, currency) { - found = true - break - } - } - if !found { - return gatewayIneligible(gw, "currency not supported: "+currency) - } - } - - if !capabilityAllowsAction(gw.Capabilities, action, dir) { - return gatewayIneligible(gw, fmt.Sprintf("capability does not allow action=%s dir=%s", action, sendDirectionLabel(dir))) - } - - if currency != "" { - if err := amountWithinLimits(gw, gw.Limits, currency, amount, action); err != nil { - return err - } - } - return nil -} - -func capabilityAllowsAction(cap model.RailCapabilities, action model.RailOperation, dir sendDirection) bool { - switch action { - case model.RailOperationSend: - switch dir { - case sendDirectionOut: - return cap.CanPayOut - case sendDirectionIn: - return cap.CanPayIn - default: - return cap.CanPayIn || cap.CanPayOut - } - case model.RailOperationFee: - return cap.CanSendFee - case model.RailOperationObserveConfirm: - return cap.RequiresObserveConfirm - case model.RailOperationBlock: - return cap.CanBlock - case model.RailOperationRelease: - return cap.CanRelease - default: - return true - } -} - -func amountWithinLimits(gw *model.GatewayInstanceDescriptor, limits model.Limits, currency string, amount decimal.Decimal, action model.RailOperation) error { - min := firstLimitValue(limits.MinAmount, "") - max := firstLimitValue(limits.MaxAmount, "") - perTxMin := firstLimitValue(limits.PerTxMinAmount, "") - perTxMax := firstLimitValue(limits.PerTxMaxAmount, "") - maxFee := firstLimitValue(limits.PerTxMaxFee, "") - - if override, ok := limits.CurrencyLimits[currency]; ok { - min = firstLimitValue(override.MinAmount, min) - max = firstLimitValue(override.MaxAmount, max) - if action == model.RailOperationFee { - maxFee = firstLimitValue(override.MaxFee, maxFee) - } - } - - if min != "" { - if val, err := decimal.NewFromString(min); err == nil && amount.LessThan(val) { - return gatewayIneligible(gw, fmt.Sprintf("amount %s %s below min limit %s", amount.String(), currency, val.String())) - } - } - if perTxMin != "" { - if val, err := decimal.NewFromString(perTxMin); err == nil && amount.LessThan(val) { - return gatewayIneligible(gw, fmt.Sprintf("amount %s %s below per-tx min limit %s", amount.String(), currency, val.String())) - } - } - if max != "" { - if val, err := decimal.NewFromString(max); err == nil && amount.GreaterThan(val) { - return gatewayIneligible(gw, fmt.Sprintf("amount %s %s exceeds max limit %s", amount.String(), currency, val.String())) - } - } - if perTxMax != "" { - if val, err := decimal.NewFromString(perTxMax); err == nil && amount.GreaterThan(val) { - return gatewayIneligible(gw, fmt.Sprintf("amount %s %s exceeds per-tx max limit %s", amount.String(), currency, val.String())) - } - } - if action == model.RailOperationFee && maxFee != "" { - if val, err := decimal.NewFromString(maxFee); err == nil && amount.GreaterThan(val) { - return gatewayIneligible(gw, fmt.Sprintf("fee amount %s %s exceeds max fee limit %s", amount.String(), currency, val.String())) - } - } - - return nil -} - -func firstLimitValue(primary, fallback string) string { - val := strings.TrimSpace(primary) - if val != "" { - return val - } - return strings.TrimSpace(fallback) -} diff --git a/api/payments/quotation/internal/service/quotation/batch_quote_processor_v2/service.go b/api/payments/quotation/internal/service/quotation/batch_quote_processor_v2/service.go index 9e592853..1b13a35a 100644 --- a/api/payments/quotation/internal/service/quotation/batch_quote_processor_v2/service.go +++ b/api/payments/quotation/internal/service/quotation/batch_quote_processor_v2/service.go @@ -2,6 +2,7 @@ package batch_quote_processor_v2 import ( "context" + "errors" "fmt" "strings" @@ -48,7 +49,7 @@ func (p *BatchQuoteProcessorV2) Process(ctx context.Context, in ProcessInput) (* Item: *item, }) if processErr != nil { - return nil, fmt.Errorf("intents[%d]: %w", item.Index, processErr) + return nil, wrapIndexedIntentError(item.Index, processErr) } if res == nil || res.Quote == nil { return nil, merrors.InvalidArgument(fmt.Sprintf("intents[%d]: quote is required", item.Index)) @@ -109,3 +110,11 @@ func buildBatchItems(ctx BatchContext, intents []*transfer_intent_hydrator.Quote return items, nil } + +func wrapIndexedIntentError(index int, err error) error { + msg := fmt.Sprintf("intents[%d]", index) + if errors.Is(err, merrors.ErrInvalidArg) { + return merrors.InvalidArgumentWrap(err, msg) + } + return merrors.InternalWrap(err, msg) +} diff --git a/api/payments/quotation/internal/service/quotation/command_factory.go b/api/payments/quotation/internal/service/quotation/command_factory.go deleted file mode 100644 index 2a9eef7d..00000000 --- a/api/payments/quotation/internal/service/quotation/command_factory.go +++ /dev/null @@ -1,71 +0,0 @@ -package quotation - -import ( - "context" - "time" - - "github.com/tech/sendico/payments/storage" - "github.com/tech/sendico/payments/storage/model" - "github.com/tech/sendico/pkg/mlogger" - quotationv1 "github.com/tech/sendico/pkg/proto/payments/quotation/v1" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" - "go.mongodb.org/mongo-driver/v2/bson" -) - -type paymentEngine interface { - EnsureRepository(ctx context.Context) error - BuildPaymentQuote(ctx context.Context, orgRef string, req *quotationv1.QuotePaymentRequest) (*sharedv1.PaymentQuote, time.Time, error) - BuildPaymentPlan(ctx context.Context, orgID bson.ObjectID, intent *sharedv1.PaymentIntent, idempotencyKey string, quote *sharedv1.PaymentQuote) (*model.PaymentPlan, error) - ResolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*sharedv1.PaymentQuote, *sharedv1.PaymentIntent, *model.PaymentPlan, error) - Repository() storage.Repository -} - -type defaultPaymentEngine struct { - svc *Service -} - -func (e defaultPaymentEngine) EnsureRepository(ctx context.Context) error { - return e.svc.ensureRepository(ctx) -} - -func (e defaultPaymentEngine) BuildPaymentQuote(ctx context.Context, orgRef string, req *quotationv1.QuotePaymentRequest) (*sharedv1.PaymentQuote, time.Time, error) { - return e.svc.buildPaymentQuote(ctx, orgRef, req) -} - -func (e defaultPaymentEngine) BuildPaymentPlan(ctx context.Context, orgID bson.ObjectID, intent *sharedv1.PaymentIntent, idempotencyKey string, quote *sharedv1.PaymentQuote) (*model.PaymentPlan, error) { - return e.svc.buildPaymentPlan(ctx, orgID, intent, idempotencyKey, quote) -} - -func (e defaultPaymentEngine) ResolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*sharedv1.PaymentQuote, *sharedv1.PaymentIntent, *model.PaymentPlan, error) { - return e.svc.resolvePaymentQuote(ctx, in) -} - -func (e defaultPaymentEngine) Repository() storage.Repository { - return e.svc.storage -} - -type paymentCommandFactory struct { - engine paymentEngine - logger mlogger.Logger -} - -func newPaymentCommandFactory(engine paymentEngine, logger mlogger.Logger) *paymentCommandFactory { - return &paymentCommandFactory{ - engine: engine, - logger: logger.Named("commands"), - } -} - -func (f *paymentCommandFactory) QuotePayment() *quotePaymentCommand { - return "ePaymentCommand{ - engine: f.engine, - logger: f.logger.Named("quote.payment"), - } -} - -func (f *paymentCommandFactory) QuotePayments() *quotePaymentsCommand { - return "ePaymentsCommand{ - engine: f.engine, - logger: f.logger.Named("quote.payments"), - } -} diff --git a/api/payments/quotation/internal/service/quotation/compat_helpers.go b/api/payments/quotation/internal/service/quotation/compat_helpers.go deleted file mode 100644 index 940912b9..00000000 --- a/api/payments/quotation/internal/service/quotation/compat_helpers.go +++ /dev/null @@ -1,6 +0,0 @@ -package quotation - -const ( - providerSettlementMetaPaymentIntentID = "payment_ref" - providerSettlementMetaOutgoingLeg = "outgoing_leg" -) diff --git a/api/payments/quotation/internal/service/quotation/composite_gateway_registry.go b/api/payments/quotation/internal/service/quotation/composite_gateway_registry.go deleted file mode 100644 index 01cc8dc0..00000000 --- a/api/payments/quotation/internal/service/quotation/composite_gateway_registry.go +++ /dev/null @@ -1,65 +0,0 @@ -package quotation - -import ( - "context" - "sort" - - "github.com/tech/sendico/payments/storage/model" - "github.com/tech/sendico/pkg/mlogger" - "go.uber.org/zap" -) - -type compositeGatewayRegistry struct { - logger mlogger.Logger - registries []GatewayRegistry -} - -func NewCompositeGatewayRegistry(logger mlogger.Logger, registries ...GatewayRegistry) GatewayRegistry { - items := make([]GatewayRegistry, 0, len(registries)) - for _, registry := range registries { - if registry != nil { - items = append(items, registry) - } - } - if len(items) == 0 { - return nil - } - if logger != nil { - logger = logger.Named("gateway_registry") - } - return &compositeGatewayRegistry{ - logger: logger, - registries: items, - } -} - -func (r *compositeGatewayRegistry) List(ctx context.Context) ([]*model.GatewayInstanceDescriptor, error) { - if r == nil || len(r.registries) == 0 { - return nil, nil - } - items := map[string]*model.GatewayInstanceDescriptor{} - for _, registry := range r.registries { - list, err := registry.List(ctx) - if err != nil { - if r.logger != nil { - r.logger.Warn("Failed to list gateway registry", zap.Error(err)) - } - continue - } - for _, entry := range list { - key := model.GatewayDescriptorIdentityKey(entry) - if key == "" { - continue - } - items[key] = entry - } - } - result := make([]*model.GatewayInstanceDescriptor, 0, len(items)) - for _, entry := range items { - result = append(result, entry) - } - sort.Slice(result, func(i, j int) bool { - return model.LessGatewayDescriptor(result[i], result[j]) - }) - return result, nil -} diff --git a/api/payments/quotation/internal/service/quotation/convert.go b/api/payments/quotation/internal/service/quotation/convert.go index 452690fd..024bca64 100644 --- a/api/payments/quotation/internal/service/quotation/convert.go +++ b/api/payments/quotation/internal/service/quotation/convert.go @@ -2,20 +2,16 @@ package quotation import ( "strings" - "time" "github.com/tech/sendico/payments/storage/model" chainasset "github.com/tech/sendico/pkg/chain" 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" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" - oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" - "google.golang.org/protobuf/types/known/timestamppb" ) func intentFromProto(src *sharedv1.PaymentIntent) model.PaymentIntent { @@ -106,23 +102,6 @@ func fxIntentFromProto(src *sharedv1.FXIntent) *model.FXIntent { } } -func quoteSnapshotToModel(src *sharedv1.PaymentQuote) *model.PaymentQuoteSnapshot { - if src == nil { - return nil - } - return &model.PaymentQuoteSnapshot{ - DebitAmount: moneyFromProto(src.GetDebitAmount()), - DebitSettlementAmount: moneyFromProto(src.GetDebitSettlementAmount()), - ExpectedSettlementAmount: moneyFromProto(src.GetExpectedSettlementAmount()), - ExpectedFeeTotal: moneyFromProto(src.GetExpectedFeeTotal()), - FeeLines: feeLinesFromProto(src.GetFeeLines()), - FeeRules: feeRulesFromProto(src.GetFeeRules()), - FXQuote: fxQuoteFromProto(src.GetFxQuote()), - NetworkFee: networkFeeFromProto(src.GetNetworkFee()), - QuoteRef: strings.TrimSpace(src.GetQuoteRef()), - } -} - func protoIntentFromModel(src model.PaymentIntent) *sharedv1.PaymentIntent { intent := &sharedv1.PaymentIntent{ Ref: src.Ref, @@ -251,23 +230,6 @@ func protoFXIntentFromModel(src *model.FXIntent) *sharedv1.FXIntent { } } -func modelQuoteToProto(src *model.PaymentQuoteSnapshot) *sharedv1.PaymentQuote { - if src == nil { - return nil - } - return &sharedv1.PaymentQuote{ - DebitAmount: protoMoney(src.DebitAmount), - DebitSettlementAmount: protoMoney(src.DebitSettlementAmount), - ExpectedSettlementAmount: protoMoney(src.ExpectedSettlementAmount), - ExpectedFeeTotal: protoMoney(src.ExpectedFeeTotal), - FeeLines: feeLinesToProto(src.FeeLines), - FeeRules: feeRulesToProto(src.FeeRules), - FxQuote: fxQuoteToProto(src.FXQuote), - NetworkFee: networkFeeToProto(src.NetworkFee), - QuoteRef: strings.TrimSpace(src.QuoteRef), - } -} - func protoKindFromModel(kind model.PaymentKind) sharedv1.PaymentKind { switch kind { case model.PaymentKindPayout: @@ -422,66 +384,6 @@ func fxSideToProto(side paymenttypes.FXSide) fxv1.Side { } } -func fxQuoteFromProto(quote *oraclev1.Quote) *paymenttypes.FXQuote { - if quote == nil { - return nil - } - pricedAtUnixMs := int64(0) - if ts := quote.GetPricedAt(); ts != nil { - pricedAtUnixMs = ts.AsTime().UnixMilli() - } - return &paymenttypes.FXQuote{ - QuoteRef: strings.TrimSpace(quote.GetQuoteRef()), - Pair: pairFromProto(quote.GetPair()), - Side: fxSideFromProto(quote.GetSide()), - Price: decimalFromProto(quote.GetPrice()), - BaseAmount: moneyFromProto(quote.GetBaseAmount()), - QuoteAmount: moneyFromProto(quote.GetQuoteAmount()), - ExpiresAtUnixMs: quote.GetExpiresAtUnixMs(), - PricedAtUnixMs: pricedAtUnixMs, - Provider: strings.TrimSpace(quote.GetProvider()), - RateRef: strings.TrimSpace(quote.GetRateRef()), - Firm: quote.GetFirm(), - } -} - -func fxQuoteToProto(quote *paymenttypes.FXQuote) *oraclev1.Quote { - if quote == nil { - return nil - } - var pricedAt *timestamppb.Timestamp - if quote.PricedAtUnixMs > 0 { - pricedAt = timestamppb.New(time.UnixMilli(quote.PricedAtUnixMs).UTC()) - } - return &oraclev1.Quote{ - QuoteRef: strings.TrimSpace(quote.QuoteRef), - Pair: pairToProto(quote.Pair), - Side: fxSideToProto(quote.Side), - Price: decimalToProto(quote.Price), - BaseAmount: protoMoney(quote.BaseAmount), - QuoteAmount: protoMoney(quote.QuoteAmount), - ExpiresAtUnixMs: quote.ExpiresAtUnixMs, - PricedAt: pricedAt, - Provider: strings.TrimSpace(quote.Provider), - RateRef: strings.TrimSpace(quote.RateRef), - Firm: quote.Firm, - } -} - -func decimalFromProto(value *moneyv1.Decimal) *paymenttypes.Decimal { - if value == nil { - return nil - } - return &paymenttypes.Decimal{Value: value.GetValue()} -} - -func decimalToProto(value *paymenttypes.Decimal) *moneyv1.Decimal { - if value == nil { - return nil - } - return &moneyv1.Decimal{Value: value.GetValue()} -} - func assetFromProto(asset *chainv1.Asset) *paymenttypes.Asset { if asset == nil { return nil @@ -503,197 +405,3 @@ func assetToProto(asset *paymenttypes.Asset) *chainv1.Asset { ContractAddress: asset.ContractAddress, } } - -func networkFeeFromProto(resp *chainv1.EstimateTransferFeeResponse) *paymenttypes.NetworkFeeEstimate { - if resp == nil { - return nil - } - return &paymenttypes.NetworkFeeEstimate{ - NetworkFee: moneyFromProto(resp.GetNetworkFee()), - EstimationContext: strings.TrimSpace(resp.GetEstimationContext()), - } -} - -func networkFeeToProto(resp *paymenttypes.NetworkFeeEstimate) *chainv1.EstimateTransferFeeResponse { - if resp == nil { - return nil - } - return &chainv1.EstimateTransferFeeResponse{ - NetworkFee: protoMoney(resp.NetworkFee), - EstimationContext: strings.TrimSpace(resp.EstimationContext), - } -} - -func feeLinesFromProto(lines []*feesv1.DerivedPostingLine) []*paymenttypes.FeeLine { - if len(lines) == 0 { - return nil - } - result := make([]*paymenttypes.FeeLine, 0, len(lines)) - for _, line := range lines { - if line == nil { - continue - } - result = append(result, &paymenttypes.FeeLine{ - LedgerAccountRef: strings.TrimSpace(line.GetLedgerAccountRef()), - Money: moneyFromProto(line.GetMoney()), - LineType: postingLineTypeFromProto(line.GetLineType()), - Side: entrySideFromProto(line.GetSide()), - Meta: cloneMetadata(line.GetMeta()), - }) - } - if len(result) == 0 { - return nil - } - return result -} - -func feeLinesToProto(lines []*paymenttypes.FeeLine) []*feesv1.DerivedPostingLine { - if len(lines) == 0 { - return nil - } - result := make([]*feesv1.DerivedPostingLine, 0, len(lines)) - for _, line := range lines { - if line == nil { - continue - } - result = append(result, &feesv1.DerivedPostingLine{ - LedgerAccountRef: strings.TrimSpace(line.LedgerAccountRef), - Money: protoMoney(line.Money), - LineType: postingLineTypeToProto(line.LineType), - Side: entrySideToProto(line.Side), - Meta: cloneMetadata(line.Meta), - }) - } - if len(result) == 0 { - return nil - } - return result -} - -func feeRulesFromProto(rules []*feesv1.AppliedRule) []*paymenttypes.AppliedRule { - if len(rules) == 0 { - return nil - } - result := make([]*paymenttypes.AppliedRule, 0, len(rules)) - for _, rule := range rules { - if rule == nil { - continue - } - result = append(result, &paymenttypes.AppliedRule{ - RuleID: strings.TrimSpace(rule.GetRuleId()), - RuleVersion: strings.TrimSpace(rule.GetRuleVersion()), - Formula: strings.TrimSpace(rule.GetFormula()), - Rounding: roundingModeFromProto(rule.GetRounding()), - TaxCode: strings.TrimSpace(rule.GetTaxCode()), - TaxRate: strings.TrimSpace(rule.GetTaxRate()), - Parameters: cloneMetadata(rule.GetParameters()), - }) - } - if len(result) == 0 { - return nil - } - return result -} - -func feeRulesToProto(rules []*paymenttypes.AppliedRule) []*feesv1.AppliedRule { - if len(rules) == 0 { - return nil - } - result := make([]*feesv1.AppliedRule, 0, len(rules)) - for _, rule := range rules { - if rule == nil { - continue - } - result = append(result, &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: cloneMetadata(rule.Parameters), - }) - } - if len(result) == 0 { - return nil - } - return result -} - -func entrySideFromProto(side accountingv1.EntrySide) paymenttypes.EntrySide { - switch side { - case accountingv1.EntrySide_ENTRY_SIDE_DEBIT: - return paymenttypes.EntrySideDebit - case accountingv1.EntrySide_ENTRY_SIDE_CREDIT: - return paymenttypes.EntrySideCredit - default: - return paymenttypes.EntrySideUnspecified - } -} - -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 postingLineTypeFromProto(lineType accountingv1.PostingLineType) paymenttypes.PostingLineType { - switch lineType { - case accountingv1.PostingLineType_POSTING_LINE_FEE: - return paymenttypes.PostingLineTypeFee - case accountingv1.PostingLineType_POSTING_LINE_TAX: - return paymenttypes.PostingLineTypeTax - case accountingv1.PostingLineType_POSTING_LINE_SPREAD: - return paymenttypes.PostingLineTypeSpread - case accountingv1.PostingLineType_POSTING_LINE_REVERSAL: - return paymenttypes.PostingLineTypeReversal - default: - return paymenttypes.PostingLineTypeUnspecified - } -} - -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 roundingModeFromProto(mode moneyv1.RoundingMode) paymenttypes.RoundingMode { - switch mode { - case moneyv1.RoundingMode_ROUND_HALF_EVEN: - return paymenttypes.RoundingModeHalfEven - case moneyv1.RoundingMode_ROUND_HALF_UP: - return paymenttypes.RoundingModeHalfUp - case moneyv1.RoundingMode_ROUND_DOWN: - return paymenttypes.RoundingModeDown - default: - return paymenttypes.RoundingModeUnspecified - } -} - -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 - } -} diff --git a/api/payments/quotation/internal/service/quotation/discovery_gateway_registry.go b/api/payments/quotation/internal/service/quotation/discovery_gateway_registry.go deleted file mode 100644 index 41905588..00000000 --- a/api/payments/quotation/internal/service/quotation/discovery_gateway_registry.go +++ /dev/null @@ -1,212 +0,0 @@ -package quotation - -import ( - "context" - "sort" - "strings" - "time" - - "github.com/tech/sendico/payments/storage/model" - "github.com/tech/sendico/pkg/discovery" - "github.com/tech/sendico/pkg/mlogger" -) - -type discoveryGatewayRegistry struct { - logger mlogger.Logger - registry *discovery.Registry -} - -func NewDiscoveryGatewayRegistry(logger mlogger.Logger, registry *discovery.Registry) GatewayRegistry { - if registry == nil { - return nil - } - if logger != nil { - logger = logger.Named("discovery_gateway_registry") - } - return &discoveryGatewayRegistry{ - logger: logger, - registry: registry, - } -} - -func (r *discoveryGatewayRegistry) List(_ context.Context) ([]*model.GatewayInstanceDescriptor, error) { - if r == nil || r.registry == nil { - return nil, nil - } - entries := r.registry.List(time.Now(), true) - items := make([]*model.GatewayInstanceDescriptor, 0, len(entries)) - for _, entry := range entries { - if entry.Rail == "" { - continue - } - rail := railFromDiscovery(entry.Rail) - if rail == model.RailUnspecified { - continue - } - items = append(items, &model.GatewayInstanceDescriptor{ - ID: entry.ID, - InstanceID: entry.InstanceID, - Rail: rail, - Network: entry.Network, - InvokeURI: strings.TrimSpace(entry.InvokeURI), - Currencies: normalizeCurrencies(entry.Currencies), - Capabilities: capabilitiesFromOps(entry.Operations), - Limits: limitsFromDiscovery(entry.Limits, entry.CurrencyMeta), - Version: entry.Version, - IsEnabled: entry.Healthy, - }) - } - sort.Slice(items, func(i, j int) bool { - return model.LessGatewayDescriptor(items[i], items[j]) - }) - return items, nil -} - -func railFromDiscovery(value string) model.Rail { - switch strings.ToUpper(strings.TrimSpace(value)) { - case string(model.RailCrypto): - return model.RailCrypto - case string(model.RailProviderSettlement): - return model.RailProviderSettlement - case string(model.RailLedger): - return model.RailLedger - case string(model.RailCardPayout): - return model.RailCardPayout - case string(model.RailFiatOnRamp): - return model.RailFiatOnRamp - default: - return model.RailUnspecified - } -} - -func capabilitiesFromOps(ops []string) model.RailCapabilities { - var cap model.RailCapabilities - for _, op := range ops { - switch strings.ToLower(strings.TrimSpace(op)) { - case "payin.crypto", "payin.card", "payin.fiat": - cap.CanPayIn = true - case "payout.crypto", "payout.card", "payout.fiat": - cap.CanPayOut = true - case "balance.read": - cap.CanReadBalance = true - case "fee.send": - cap.CanSendFee = true - case "observe.confirm", "observe.confirmation": - cap.RequiresObserveConfirm = true - case "block", "funds.block", "balance.block", "ledger.block": - cap.CanBlock = true - case "release", "funds.release", "balance.release", "ledger.release": - cap.CanRelease = true - } - } - return cap -} - -func limitsFromDiscovery(src *discovery.Limits, currencies []discovery.CurrencyAnnouncement) model.Limits { - limits := model.Limits{ - VolumeLimit: map[string]string{}, - VelocityLimit: map[string]int{}, - CurrencyLimits: map[string]model.LimitsOverride{}, - } - if src != nil { - limits.MinAmount = strings.TrimSpace(src.MinAmount) - limits.MaxAmount = strings.TrimSpace(src.MaxAmount) - for key, value := range src.VolumeLimit { - k := strings.TrimSpace(key) - v := strings.TrimSpace(value) - if k == "" || v == "" { - continue - } - limits.VolumeLimit[k] = v - } - for key, value := range src.VelocityLimit { - k := strings.TrimSpace(key) - if k == "" { - continue - } - limits.VelocityLimit[k] = value - } - } - applyCurrencyTransferLimits(&limits, currencies) - if len(limits.VolumeLimit) == 0 { - limits.VolumeLimit = nil - } - if len(limits.VelocityLimit) == 0 { - limits.VelocityLimit = nil - } - if len(limits.CurrencyLimits) == 0 { - limits.CurrencyLimits = nil - } - return limits -} - -func applyCurrencyTransferLimits(dst *model.Limits, currencies []discovery.CurrencyAnnouncement) { - if dst == nil || len(currencies) == 0 { - return - } - var ( - commonMin string - commonMax string - commonMinInit bool - commonMaxInit bool - commonMinConsistent = true - commonMaxConsistent = true - ) - - for _, currency := range currencies { - code := strings.ToUpper(strings.TrimSpace(currency.Currency)) - if code == "" || currency.Limits == nil || currency.Limits.Amount == nil { - commonMinConsistent = false - commonMaxConsistent = false - continue - } - min := strings.TrimSpace(currency.Limits.Amount.Min) - max := strings.TrimSpace(currency.Limits.Amount.Max) - - if min != "" || max != "" { - override := dst.CurrencyLimits[code] - if min != "" { - override.MinAmount = min - } - if max != "" { - override.MaxAmount = max - } - if override.MinAmount != "" || override.MaxAmount != "" || override.MaxFee != "" || override.MaxOps > 0 || override.MaxVolume != "" { - dst.CurrencyLimits[code] = override - } - } - - if min == "" { - commonMinConsistent = false - } else if !commonMinInit { - commonMin = min - commonMinInit = true - } else if commonMin != min { - commonMinConsistent = false - } - - if max == "" { - commonMaxConsistent = false - } else if !commonMaxInit { - commonMax = max - commonMaxInit = true - } else if commonMax != max { - commonMaxConsistent = false - } - } - - if commonMinInit && commonMinConsistent { - dst.PerTxMinAmount = firstLimitValue(dst.PerTxMinAmount, commonMin) - } - if commonMaxInit && commonMaxConsistent { - dst.PerTxMaxAmount = firstLimitValue(dst.PerTxMaxAmount, commonMax) - } -} - -func firstLimitValue(primary, fallback string) string { - primary = strings.TrimSpace(primary) - if primary != "" { - return primary - } - return strings.TrimSpace(fallback) -} diff --git a/api/payments/quotation/internal/service/quotation/discovery_gateway_registry_test.go b/api/payments/quotation/internal/service/quotation/discovery_gateway_registry_test.go deleted file mode 100644 index 91ff51e9..00000000 --- a/api/payments/quotation/internal/service/quotation/discovery_gateway_registry_test.go +++ /dev/null @@ -1,62 +0,0 @@ -package quotation - -import ( - "testing" - - "github.com/tech/sendico/pkg/discovery" -) - -func TestLimitsFromDiscovery_MapsPerTxMinimumFromCurrencyMeta(t *testing.T) { - limits := limitsFromDiscovery(nil, []discovery.CurrencyAnnouncement{ - { - Currency: "RUB", - Limits: &discovery.CurrencyLimits{ - Amount: &discovery.CurrencyAmount{ - Min: "100.00", - Max: "10000.00", - }, - }, - }, - }) - - if limits.PerTxMinAmount != "100.00" { - t.Fatalf("expected per tx min 100.00, got %q", limits.PerTxMinAmount) - } - if limits.PerTxMaxAmount != "10000.00" { - t.Fatalf("expected per tx max 10000.00, got %q", limits.PerTxMaxAmount) - } - override, ok := limits.CurrencyLimits["RUB"] - if !ok { - t.Fatalf("expected RUB currency override") - } - if override.MinAmount != "100.00" { - t.Fatalf("expected RUB min override 100.00, got %q", override.MinAmount) - } -} - -func TestLimitsFromDiscovery_DropsCommonPerTxMinimumWhenCurrenciesDiffer(t *testing.T) { - limits := limitsFromDiscovery(nil, []discovery.CurrencyAnnouncement{ - { - Currency: "USD", - Limits: &discovery.CurrencyLimits{ - Amount: &discovery.CurrencyAmount{Min: "10.00"}, - }, - }, - { - Currency: "EUR", - Limits: &discovery.CurrencyLimits{ - Amount: &discovery.CurrencyAmount{Min: "20.00"}, - }, - }, - }) - - if limits.PerTxMinAmount != "" { - t.Fatalf("expected empty common per tx min, got %q", limits.PerTxMinAmount) - } - if limits.CurrencyLimits["USD"].MinAmount != "10.00" { - t.Fatalf("expected USD min override 10.00, got %q", limits.CurrencyLimits["USD"].MinAmount) - } - if limits.CurrencyLimits["EUR"].MinAmount != "20.00" { - t.Fatalf("expected EUR min override 20.00, got %q", limits.CurrencyLimits["EUR"].MinAmount) - } -} diff --git a/api/payments/quotation/internal/service/quotation/gateway_execution_consumer.go b/api/payments/quotation/internal/service/quotation/gateway_execution_consumer.go deleted file mode 100644 index 49564387..00000000 --- a/api/payments/quotation/internal/service/quotation/gateway_execution_consumer.go +++ /dev/null @@ -1,12 +0,0 @@ -package quotation - -func (s *Service) Shutdown() { - if s == nil { - return - } - for _, consumer := range s.gatewayConsumers { - if consumer != nil { - consumer.Close() - } - } -} diff --git a/api/payments/quotation/internal/service/quotation/gateway_funding_profile/funding_profile_resolver_static.go b/api/payments/quotation/internal/service/quotation/gateway_funding_profile/funding_profile_resolver_static.go index 1c4dc231..485280f6 100644 --- a/api/payments/quotation/internal/service/quotation/gateway_funding_profile/funding_profile_resolver_static.go +++ b/api/payments/quotation/internal/service/quotation/gateway_funding_profile/funding_profile_resolver_static.go @@ -217,9 +217,6 @@ func (r *StaticFundingProfileResolver) gatewayKey(req FundingProfileRequest) str if key := normalizeGatewayKey(req.Attributes["gateway"]); key != "" { return key } - if req.Destination != nil && req.Destination.Card != nil { - return r.defaultCardGateway - } return "" } diff --git a/api/payments/quotation/internal/service/quotation/gateway_funding_profile/funding_profile_resolver_static_test.go b/api/payments/quotation/internal/service/quotation/gateway_funding_profile/funding_profile_resolver_static_test.go index 1b451b74..bdafd3b6 100644 --- a/api/payments/quotation/internal/service/quotation/gateway_funding_profile/funding_profile_resolver_static_test.go +++ b/api/payments/quotation/internal/service/quotation/gateway_funding_profile/funding_profile_resolver_static_test.go @@ -10,7 +10,7 @@ import ( moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" ) -func TestStaticFundingProfileResolver_DefaultCardRoute(t *testing.T) { +func TestStaticFundingProfileResolver_ExplicitCardRoute(t *testing.T) { resolver := NewStaticFundingProfileResolver(StaticFundingProfileResolverInput{ DefaultMode: model.FundingModeNone, CardRoutes: map[string]CardGatewayFundingRoute{ @@ -47,6 +47,7 @@ func TestStaticFundingProfileResolver_DefaultCardRoute(t *testing.T) { }, }, Attributes: map[string]string{ + "gateway": "monetix", "initiator_ref": "usr-1", }, }) diff --git a/api/payments/quotation/internal/service/quotation/gateway_registry.go b/api/payments/quotation/internal/service/quotation/gateway_registry.go deleted file mode 100644 index f41cdc02..00000000 --- a/api/payments/quotation/internal/service/quotation/gateway_registry.go +++ /dev/null @@ -1,117 +0,0 @@ -package quotation - -import ( - "context" - "sort" - "strings" - - "github.com/tech/sendico/payments/storage/model" - "github.com/tech/sendico/pkg/mlogger" -) - -type gatewayRegistry struct { - logger mlogger.Logger - static []*model.GatewayInstanceDescriptor -} - -// NewGatewayRegistry aggregates static gateway descriptors. -func NewGatewayRegistry(logger mlogger.Logger, static []*model.GatewayInstanceDescriptor) GatewayRegistry { - if len(static) == 0 { - return nil - } - if logger != nil { - logger = logger.Named("gateway_registry") - } - return &gatewayRegistry{ - logger: logger, - static: cloneGatewayDescriptors(static), - } -} - -func (r *gatewayRegistry) List(ctx context.Context) ([]*model.GatewayInstanceDescriptor, error) { - items := map[string]*model.GatewayInstanceDescriptor{} - for _, gw := range r.static { - key := model.GatewayDescriptorIdentityKey(gw) - if key == "" { - continue - } - items[key] = cloneGatewayDescriptor(gw) - } - - result := make([]*model.GatewayInstanceDescriptor, 0, len(items)) - for _, gw := range items { - result = append(result, gw) - } - sort.Slice(result, func(i, j int) bool { - return model.LessGatewayDescriptor(result[i], result[j]) - }) - return result, nil -} - -func normalizeCurrencies(values []string) []string { - if len(values) == 0 { - return nil - } - seen := map[string]bool{} - result := make([]string, 0, len(values)) - for _, value := range values { - clean := strings.ToUpper(strings.TrimSpace(value)) - if clean == "" || seen[clean] { - continue - } - seen[clean] = true - result = append(result, clean) - } - return result -} - -func cloneGatewayDescriptors(src []*model.GatewayInstanceDescriptor) []*model.GatewayInstanceDescriptor { - if len(src) == 0 { - return nil - } - result := make([]*model.GatewayInstanceDescriptor, 0, len(src)) - for _, item := range src { - if item == nil { - continue - } - if cloned := cloneGatewayDescriptor(item); cloned != nil { - result = append(result, cloned) - } - } - return result -} - -func cloneGatewayDescriptor(src *model.GatewayInstanceDescriptor) *model.GatewayInstanceDescriptor { - if src == nil { - return nil - } - dst := *src - if src.Currencies != nil { - dst.Currencies = append([]string(nil), src.Currencies...) - } - dst.Limits = cloneLimits(src.Limits) - return &dst -} - -func cloneLimits(src model.Limits) model.Limits { - dst := src - if src.VolumeLimit != nil { - dst.VolumeLimit = map[string]string{} - for key, value := range src.VolumeLimit { - dst.VolumeLimit[key] = value - } - } - if src.VelocityLimit != nil { - dst.VelocityLimit = map[string]int{} - for key, value := range src.VelocityLimit { - dst.VelocityLimit[key] = value - } - } - if src.CurrencyLimits != nil { - dst.CurrencyLimits = map[string]model.LimitsOverride{} - for key, value := range src.CurrencyLimits { - dst.CurrencyLimits[key] = value - } - } - return dst -} diff --git a/api/payments/quotation/internal/service/quotation/gateway_registry_identity_test.go b/api/payments/quotation/internal/service/quotation/gateway_registry_identity_test.go deleted file mode 100644 index 6e4b4abe..00000000 --- a/api/payments/quotation/internal/service/quotation/gateway_registry_identity_test.go +++ /dev/null @@ -1,72 +0,0 @@ -package quotation - -import ( - "context" - "testing" - - "github.com/tech/sendico/payments/storage/model" -) - -type identityGatewayRegistryStub struct { - items []*model.GatewayInstanceDescriptor -} - -func (s identityGatewayRegistryStub) List(context.Context) ([]*model.GatewayInstanceDescriptor, error) { - return s.items, nil -} - -func TestGatewayRegistry_ListKeepsDistinctInstancesPerGatewayID(t *testing.T) { - registry := NewGatewayRegistry(nil, []*model.GatewayInstanceDescriptor{ - {ID: "crypto_rail_gateway_tron", InstanceID: "inst-b", InvokeURI: "grpc://b"}, - {ID: "crypto_rail_gateway_tron", InstanceID: "inst-a", InvokeURI: "grpc://a"}, - {ID: "crypto_rail_gateway_tron", InstanceID: "inst-a", InvokeURI: "grpc://a-new"}, - }) - if registry == nil { - t.Fatalf("expected registry to be created") - } - - items, err := registry.List(context.Background()) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got, want := len(items), 2; got != want { - t.Fatalf("unexpected items count: got=%d want=%d", got, want) - } - if got, want := items[0].InstanceID, "inst-a"; got != want { - t.Fatalf("unexpected first instance id: got=%q want=%q", got, want) - } - if got, want := items[0].InvokeURI, "grpc://a-new"; got != want { - t.Fatalf("expected latest duplicate to win for same gateway+instance: got=%q want=%q", got, want) - } - if got, want := items[1].InstanceID, "inst-b"; got != want { - t.Fatalf("unexpected second instance id: got=%q want=%q", got, want) - } -} - -func TestCompositeGatewayRegistry_ListKeepsDistinctInstancesPerGatewayID(t *testing.T) { - registry := NewCompositeGatewayRegistry(nil, - identityGatewayRegistryStub{items: []*model.GatewayInstanceDescriptor{ - {ID: "crypto_rail_gateway_tron", InstanceID: "inst-b", InvokeURI: "grpc://b"}, - }}, - identityGatewayRegistryStub{items: []*model.GatewayInstanceDescriptor{ - {ID: "crypto_rail_gateway_tron", InstanceID: "inst-a", InvokeURI: "grpc://a"}, - }}, - ) - if registry == nil { - t.Fatalf("expected registry to be created") - } - - items, err := registry.List(context.Background()) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got, want := len(items), 2; got != want { - t.Fatalf("unexpected items count: got=%d want=%d", got, want) - } - if got, want := items[0].InstanceID, "inst-a"; got != want { - t.Fatalf("unexpected first instance id: got=%q want=%q", got, want) - } - if got, want := items[1].InstanceID, "inst-b"; got != want { - t.Fatalf("unexpected second instance id: got=%q want=%q", got, want) - } -} diff --git a/api/payments/quotation/internal/service/quotation/gateway_resolution.go b/api/payments/quotation/internal/service/quotation/gateway_resolution.go index b43677ac..7c82c703 100644 --- a/api/payments/quotation/internal/service/quotation/gateway_resolution.go +++ b/api/payments/quotation/internal/service/quotation/gateway_resolution.go @@ -83,7 +83,6 @@ func selectGatewayForActions(ctx context.Context, registry GatewayRegistry, rail network = strings.ToUpper(strings.TrimSpace(network)) eligible := make([]*model.GatewayInstanceDescriptor, 0) - var lastErr error for _, entry := range all { if entry == nil || !entry.IsEnabled { continue @@ -94,7 +93,6 @@ func selectGatewayForActions(ctx context.Context, registry GatewayRegistry, rail ok := true for _, action := range actions { if err := isGatewayEligible(entry, rail, network, currency, action, dir, amt); err != nil { - lastErr = err ok = false break } @@ -106,10 +104,11 @@ func selectGatewayForActions(ctx context.Context, registry GatewayRegistry, rail } if len(eligible) == 0 { - if lastErr != nil { - return nil, merrors.NoData("no eligible gateway instance found: " + lastErr.Error()) + action := model.RailOperationUnspecified + if len(actions) > 0 { + action = actions[0] } - return nil, merrors.NoData("no eligible gateway instance found") + return nil, merrors.NoData(model.NoEligibleGatewayMessage(network, currency, action, toGatewayDirection(dir))) } sort.Slice(eligible, func(i, j int) bool { return eligible[i].ID < eligible[j].ID @@ -124,6 +123,17 @@ func selectGatewayForActions(ctx context.Context, registry GatewayRegistry, rail return eligible[0], nil } +func toGatewayDirection(dir plan.SendDirection) model.GatewayDirection { + switch dir { + case plan.SendDirectionOut: + return model.GatewayDirectionOut + case plan.SendDirectionIn: + return model.GatewayDirectionIn + default: + return model.GatewayDirectionAny + } +} + func railActionNames(actions []model.RailOperation) []string { if len(actions) == 0 { return nil diff --git a/api/payments/quotation/internal/service/quotation/handlers_commands.go b/api/payments/quotation/internal/service/quotation/handlers_commands.go deleted file mode 100644 index 09245da1..00000000 --- a/api/payments/quotation/internal/service/quotation/handlers_commands.go +++ /dev/null @@ -1,640 +0,0 @@ -package quotation - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "errors" - "sort" - "strings" - "time" - - "github.com/tech/sendico/payments/storage/model" - quotestorage "github.com/tech/sendico/payments/storage/quote" - "github.com/tech/sendico/pkg/api/routers/gsresponse" - "github.com/tech/sendico/pkg/merrors" - "github.com/tech/sendico/pkg/mlogger" - "github.com/tech/sendico/pkg/mservice" - "github.com/tech/sendico/pkg/mutil/mzap" - quotationv1 "github.com/tech/sendico/pkg/proto/payments/quotation/v1" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" - "go.mongodb.org/mongo-driver/v2/bson" - "go.uber.org/zap" - "google.golang.org/protobuf/proto" -) - -type quotePaymentCommand struct { - engine paymentEngine - logger mlogger.Logger -} - -var ( - errIdempotencyRequired = errors.New("idempotency key is required") - errPreviewWithIdempotency = errors.New("preview requests must not use idempotency key") - errIdempotencyParamMismatch = errors.New("idempotency key reuse with different parameters") -) - -type quoteCtx struct { - orgID string - orgRef bson.ObjectID - intent *sharedv1.PaymentIntent - previewOnly bool - idempotencyKey string - hash string -} - -type quotePaymentResult struct { - quote *sharedv1.PaymentQuote - executionNote string -} - -func (h *quotePaymentCommand) Execute( - ctx context.Context, - req *quotationv1.QuotePaymentRequest, -) gsresponse.Responder[quotationv1.QuotePaymentResponse] { - - if err := h.engine.EnsureRepository(ctx); err != nil { - return gsresponse.Unavailable[quotationv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err) - } - if req == nil { - return gsresponse.InvalidArgument[quotationv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request")) - } - - qc, err := h.prepareQuoteCtx(req) - if err != nil { - return h.mapQuoteErr(err) - } - - quotesStore, err := ensureQuotesStore(h.engine.Repository()) - if err != nil { - return gsresponse.Unavailable[quotationv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err) - } - - result, err := h.quotePayment(ctx, quotesStore, qc, req) - if err != nil { - return h.mapQuoteErr(err) - } - - return gsresponse.Success("ationv1.QuotePaymentResponse{ - IdempotencyKey: req.GetIdempotencyKey(), - Quote: result.quote, - ExecutionNote: result.executionNote, - }) -} - -func (h *quotePaymentCommand) prepareQuoteCtx(req *quotationv1.QuotePaymentRequest) (*quoteCtx, error) { - orgRef, orgID, err := validateMetaAndOrgRef(req.GetMeta()) - if err != nil { - return nil, err - } - if err := requireNonNilIntent(req.GetIntent()); err != nil { - return nil, err - } - - intent := req.GetIntent() - preview := req.GetPreviewOnly() - idem := strings.TrimSpace(req.GetIdempotencyKey()) - - if preview && idem != "" { - return nil, errPreviewWithIdempotency - } - if !preview && idem == "" { - return nil, errIdempotencyRequired - } - - return "eCtx{ - orgID: orgRef, - orgRef: orgID, - intent: intent, - previewOnly: preview, - idempotencyKey: idem, - hash: hashQuoteRequest(req), - }, nil -} - -func (h *quotePaymentCommand) quotePayment( - ctx context.Context, - quotesStore quotestorage.QuotesStore, - qc *quoteCtx, - req *quotationv1.QuotePaymentRequest, -) (*quotePaymentResult, error) { - - if qc.previewOnly { - quote, _, err := h.engine.BuildPaymentQuote(ctx, qc.orgID, req) - if err != nil { - h.logger.Warn("Failed to build preview payment quote", zap.Error(err), zap.String("org_ref", qc.orgID)) - return nil, err - } - quote.QuoteRef = bson.NewObjectID().Hex() - return "ePaymentResult{quote: quote}, nil - } - - existing, err := quotesStore.GetByIdempotencyKey(ctx, qc.orgRef, qc.idempotencyKey) - if err != nil && !errors.Is(err, quotestorage.ErrQuoteNotFound) && !errors.Is(err, merrors.ErrNoData) { - h.logger.Warn("Failed to lookup quote by idempotency key", zap.Error(err), - mzap.ObjRef("org_ref", qc.orgRef), zap.String("idempotency_key", qc.idempotencyKey), - ) - return nil, err - } - if existing != nil { - if existing.Hash != qc.hash { - return nil, errIdempotencyParamMismatch - } - h.logger.Debug( - "Idempotent quote reused", - mzap.ObjRef("org_ref", qc.orgRef), - zap.String("idempotency_key", qc.idempotencyKey), - zap.String("quote_ref", existing.QuoteRef), - ) - return "ePaymentResult{ - quote: modelQuoteToProto(existing.Quote), - executionNote: strings.TrimSpace(existing.ExecutionNote), - }, nil - } - - quote, expiresAt, err := h.engine.BuildPaymentQuote(ctx, qc.orgID, req) - if err != nil { - h.logger.Warn( - "Failed to build payment quote", - zap.Error(err), - mzap.ObjRef("org_ref", qc.orgRef), - zap.String("idempotency_key", qc.idempotencyKey), - ) - return nil, err - } - - quoteRef := bson.NewObjectID().Hex() - quote.QuoteRef = quoteRef - - executionNote := "" - plan, err := h.engine.BuildPaymentPlan(ctx, qc.orgRef, qc.intent, qc.idempotencyKey, quote) - if err != nil { - if errors.Is(err, merrors.ErrInvalidArg) { - executionNote = quoteNonExecutableNote(err) - h.logger.Info( - "Payment quote marked as non-executable", - mzap.ObjRef("org_ref", qc.orgRef), - zap.String("idempotency_key", qc.idempotencyKey), - zap.String("quote_ref", quoteRef), - zap.String("execution_note", executionNote), - ) - } else { - h.logger.Warn( - "Failed to build payment plan", - zap.Error(err), - mzap.ObjRef("org_ref", qc.orgRef), - zap.String("idempotency_key", qc.idempotencyKey), - ) - return nil, err - } - } - record := &model.PaymentQuoteRecord{ - QuoteRef: quoteRef, - IdempotencyKey: qc.idempotencyKey, - Hash: qc.hash, - Intent: intentFromProto(qc.intent), - Quote: quoteSnapshotToModel(quote), - Plan: cloneStoredPaymentPlan(plan), - ExecutionNote: executionNote, - ExpiresAt: expiresAt, - } - record.SetID(bson.NewObjectID()) - record.SetOrganizationRef(qc.orgRef) - - if err := quotesStore.Create(ctx, record); err != nil { - if errors.Is(err, quotestorage.ErrDuplicateQuote) { - existing, getErr := quotesStore.GetByIdempotencyKey(ctx, qc.orgRef, qc.idempotencyKey) - if getErr == nil && existing != nil { - if existing.Hash != qc.hash { - return nil, errIdempotencyParamMismatch - } - return "ePaymentResult{ - quote: modelQuoteToProto(existing.Quote), - executionNote: strings.TrimSpace(existing.ExecutionNote), - }, nil - } - } - return nil, err - } - - h.logger.Info( - "Stored payment quote", - zap.String("quote_ref", quoteRef), - mzap.ObjRef("org_ref", qc.orgRef), - zap.String("idempotency_key", qc.idempotencyKey), - zap.String("kind", qc.intent.GetKind().String()), - ) - - return "ePaymentResult{ - quote: quote, - executionNote: executionNote, - }, nil -} - -func (h *quotePaymentCommand) mapQuoteErr(err error) gsresponse.Responder[quotationv1.QuotePaymentResponse] { - if errors.Is(err, errIdempotencyRequired) || - errors.Is(err, errPreviewWithIdempotency) || - errors.Is(err, errIdempotencyParamMismatch) { - return gsresponse.InvalidArgument[quotationv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err) - } - return gsresponse.Auto[quotationv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err) -} - -func quoteNonExecutableNote(err error) string { - reason := strings.TrimSpace(err.Error()) - reason = strings.TrimPrefix(reason, merrors.ErrInvalidArg.Error()+":") - reason = strings.TrimSpace(reason) - if reason == "" { - return "quote will not be executed" - } - return "quote will not be executed: " + reason -} - -// TODO: temprorarary hashing function, replace with a proper solution later -func hashQuoteRequest(req *quotationv1.QuotePaymentRequest) string { - cloned := proto.Clone(req).(*quotationv1.QuotePaymentRequest) - cloned.Meta = nil - cloned.IdempotencyKey = "" - cloned.PreviewOnly = false - - b, err := proto.MarshalOptions{Deterministic: true}.Marshal(cloned) - if err != nil { - sum := sha256.Sum256([]byte("marshal_error")) - return hex.EncodeToString(sum[:]) - } - - sum := sha256.Sum256(b) - return hex.EncodeToString(sum[:]) -} - -type quotePaymentsCommand struct { - engine paymentEngine - logger mlogger.Logger -} - -var ( - errBatchIdempotencyRequired = errors.New("idempotency key is required") - errBatchPreviewWithIdempotency = errors.New("preview requests must not use idempotency key") - errBatchIdempotencyParamMismatch = errors.New("idempotency key reuse with different parameters") - errBatchIdempotencyShapeMismatch = errors.New("idempotency key already used for a different quote shape") -) - -type quotePaymentsCtx struct { - orgID string - orgRef bson.ObjectID - previewOnly bool - idempotencyKey string - hash string - intentCount int -} - -func (h *quotePaymentsCommand) Execute( - ctx context.Context, - req *quotationv1.QuotePaymentsRequest, -) gsresponse.Responder[quotationv1.QuotePaymentsResponse] { - - if err := h.engine.EnsureRepository(ctx); err != nil { - return gsresponse.Unavailable[quotationv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) - } - if req == nil { - return gsresponse.InvalidArgument[quotationv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request")) - } - - qc, intents, err := h.prepare(req) - if err != nil { - return h.mapErr(err) - } - - quotesStore, err := ensureQuotesStore(h.engine.Repository()) - if err != nil { - return gsresponse.Unavailable[quotationv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) - } - - if qc.previewOnly { - quotes, _, expires, err := h.buildQuotes(ctx, req.GetMeta(), qc.orgRef, qc.idempotencyKey, intents, true) - if err != nil { - return gsresponse.Auto[quotationv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) - } - aggregate, expiresAt, err := h.aggregate(quotes, expires) - if err != nil { - return gsresponse.Auto[quotationv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) - } - _ = expiresAt - return gsresponse.Success("ationv1.QuotePaymentsResponse{ - QuoteRef: "", - Aggregate: aggregate, - Quotes: quotes, - }) - } - - if rec, ok, err := h.tryReuse(ctx, quotesStore, qc); err != nil { - return gsresponse.Auto[quotationv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) - } else if ok { - return gsresponse.Success(h.responseFromRecord(rec)) - } - - quotes, plans, expires, err := h.buildQuotes(ctx, req.GetMeta(), qc.orgRef, qc.idempotencyKey, intents, false) - if err != nil { - return gsresponse.Auto[quotationv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) - } - - aggregate, expiresAt, err := h.aggregate(quotes, expires) - if err != nil { - return gsresponse.Auto[quotationv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) - } - - quoteRef := bson.NewObjectID().Hex() - for _, q := range quotes { - if q != nil { - q.QuoteRef = quoteRef - } - } - - rec, err := h.storeBatch(ctx, quotesStore, qc, quoteRef, intents, quotes, plans, expiresAt) - if err != nil { - return gsresponse.Auto[quotationv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) - } - - if rec != nil { - return gsresponse.Success(h.responseFromRecord(rec)) - } - - h.logger.Info( - "Stored payment quotes", - h.logFields(qc, quoteRef, expiresAt, len(quotes))..., - ) - - return gsresponse.Success("ationv1.QuotePaymentsResponse{ - IdempotencyKey: req.GetIdempotencyKey(), - QuoteRef: quoteRef, - Aggregate: aggregate, - Quotes: quotes, - }) -} - -func (h *quotePaymentsCommand) prepare(req *quotationv1.QuotePaymentsRequest) (*quotePaymentsCtx, []*sharedv1.PaymentIntent, error) { - orgRefStr, orgID, err := validateMetaAndOrgRef(req.GetMeta()) - if err != nil { - return nil, nil, err - } - - intents := req.GetIntents() - if len(intents) == 0 { - return nil, nil, merrors.InvalidArgument("intents are required") - } - for _, intent := range intents { - if err := requireNonNilIntent(intent); err != nil { - return nil, nil, err - } - } - - preview := req.GetPreviewOnly() - idem := strings.TrimSpace(req.GetIdempotencyKey()) - - if preview && idem != "" { - return nil, nil, errBatchPreviewWithIdempotency - } - if !preview && idem == "" { - return nil, nil, errBatchIdempotencyRequired - } - - hash, err := hashQuotePaymentsIntents(intents) - if err != nil { - return nil, nil, err - } - - return "ePaymentsCtx{ - orgID: orgRefStr, - orgRef: orgID, - previewOnly: preview, - idempotencyKey: idem, - hash: hash, - intentCount: len(intents), - }, intents, nil -} - -func (h *quotePaymentsCommand) tryReuse( - ctx context.Context, - quotesStore quotestorage.QuotesStore, - qc *quotePaymentsCtx, -) (*model.PaymentQuoteRecord, bool, error) { - - rec, err := quotesStore.GetByIdempotencyKey(ctx, qc.orgRef, qc.idempotencyKey) - if err != nil { - if errors.Is(err, quotestorage.ErrQuoteNotFound) { - return nil, false, nil - } - h.logger.Warn( - "Failed to lookup payment quotes by idempotency key", - h.logFields(qc, "", time.Time{}, 0)..., - ) - return nil, false, err - } - - if len(rec.Quotes) == 0 { - return nil, false, errBatchIdempotencyShapeMismatch - } - if rec.Hash != qc.hash { - return nil, false, errBatchIdempotencyParamMismatch - } - - h.logger.Debug( - "Idempotent payment quotes reused", - h.logFields(qc, rec.QuoteRef, rec.ExpiresAt, len(rec.Quotes))..., - ) - - return rec, true, nil -} - -func (h *quotePaymentsCommand) buildQuotes( - ctx context.Context, - meta *sharedv1.RequestMeta, - orgRef bson.ObjectID, - baseKey string, - intents []*sharedv1.PaymentIntent, - preview bool, -) ([]*sharedv1.PaymentQuote, []*model.PaymentPlan, []time.Time, error) { - - quotes := make([]*sharedv1.PaymentQuote, 0, len(intents)) - plans := make([]*model.PaymentPlan, 0, len(intents)) - expires := make([]time.Time, 0, len(intents)) - - for i, intent := range intents { - perKey := perIntentIdempotencyKey(baseKey, i, len(intents)) - req := "ationv1.QuotePaymentRequest{ - Meta: meta, - IdempotencyKey: perKey, - Intent: intent, - PreviewOnly: preview, - } - q, exp, err := h.engine.BuildPaymentQuote(ctx, meta.GetOrganizationRef(), req) - if err != nil { - h.logger.Warn( - "Failed to build payment quote (batch item)", - zap.Int("idx", i), - zap.Error(err), - ) - return nil, nil, nil, err - } - if !preview { - plan, err := h.engine.BuildPaymentPlan(ctx, orgRef, intent, perKey, q) - if err != nil { - h.logger.Warn( - "Failed to build payment plan (batch item)", - zap.Int("idx", i), - zap.Error(err), - ) - return nil, nil, nil, err - } - plans = append(plans, cloneStoredPaymentPlan(plan)) - } - quotes = append(quotes, q) - expires = append(expires, exp) - } - - return quotes, plans, expires, nil -} - -func (h *quotePaymentsCommand) aggregate( - quotes []*sharedv1.PaymentQuote, - expires []time.Time, -) (*sharedv1.PaymentQuoteAggregate, time.Time, error) { - - agg, err := aggregatePaymentQuotes(quotes) - if err != nil { - return nil, time.Time{}, merrors.InternalWrap(err, "quote aggregation failed") - } - - expiresAt, ok := minQuoteExpiry(expires) - if !ok { - return nil, time.Time{}, merrors.Internal("quote expiry missing") - } - - return agg, expiresAt, nil -} - -func (h *quotePaymentsCommand) storeBatch( - ctx context.Context, - quotesStore quotestorage.QuotesStore, - qc *quotePaymentsCtx, - quoteRef string, - intents []*sharedv1.PaymentIntent, - quotes []*sharedv1.PaymentQuote, - plans []*model.PaymentPlan, - expiresAt time.Time, -) (*model.PaymentQuoteRecord, error) { - - record := &model.PaymentQuoteRecord{ - QuoteRef: quoteRef, - IdempotencyKey: qc.idempotencyKey, - Hash: qc.hash, - Intents: intentsFromProto(intents), - Quotes: quoteSnapshotsFromProto(quotes), - Plans: cloneStoredPaymentPlans(plans), - ExpiresAt: expiresAt, - } - record.SetID(bson.NewObjectID()) - record.SetOrganizationRef(qc.orgRef) - - if err := quotesStore.Create(ctx, record); err != nil { - if errors.Is(err, quotestorage.ErrDuplicateQuote) { - rec, ok, reuseErr := h.tryReuse(ctx, quotesStore, qc) - if reuseErr != nil { - return nil, reuseErr - } - if ok { - return rec, nil - } - return nil, err - } - return nil, err - } - - return nil, nil -} - -func (h *quotePaymentsCommand) responseFromRecord(rec *model.PaymentQuoteRecord) *quotationv1.QuotePaymentsResponse { - quotes := modelQuotesToProto(rec.Quotes) - for _, q := range quotes { - if q != nil { - q.QuoteRef = rec.QuoteRef - } - } - aggregate, _ := aggregatePaymentQuotes(quotes) - - return "ationv1.QuotePaymentsResponse{ - QuoteRef: rec.QuoteRef, - Aggregate: aggregate, - Quotes: quotes, - } -} - -func (h *quotePaymentsCommand) logFields(qc *quotePaymentsCtx, quoteRef string, expiresAt time.Time, quoteCount int) []zap.Field { - fields := []zap.Field{ - mzap.ObjRef("org_ref", qc.orgRef), - zap.String("org_ref_str", qc.orgID), - zap.String("idempotency_key", qc.idempotencyKey), - zap.String("hash", qc.hash), - zap.Bool("preview_only", qc.previewOnly), - zap.Int("intent_count", qc.intentCount), - } - if quoteRef != "" { - fields = append(fields, zap.String("quote_ref", quoteRef)) - } - if !expiresAt.IsZero() { - fields = append(fields, zap.Time("expires_at", expiresAt)) - } - if quoteCount > 0 { - fields = append(fields, zap.Int("quote_count", quoteCount)) - } - return fields -} - -func (h *quotePaymentsCommand) mapErr(err error) gsresponse.Responder[quotationv1.QuotePaymentsResponse] { - if errors.Is(err, errBatchIdempotencyRequired) || - errors.Is(err, errBatchPreviewWithIdempotency) || - errors.Is(err, errBatchIdempotencyParamMismatch) || - errors.Is(err, errBatchIdempotencyShapeMismatch) { - return gsresponse.InvalidArgument[quotationv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) - } - return gsresponse.Auto[quotationv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) -} - -func modelQuotesToProto(snaps []*model.PaymentQuoteSnapshot) []*sharedv1.PaymentQuote { - if len(snaps) == 0 { - return nil - } - out := make([]*sharedv1.PaymentQuote, 0, len(snaps)) - for _, s := range snaps { - out = append(out, modelQuoteToProto(s)) - } - return out -} - -func hashQuotePaymentsIntents(intents []*sharedv1.PaymentIntent) (string, error) { - type item struct { - Idx int - H [32]byte - } - items := make([]item, 0, len(intents)) - - for i, intent := range intents { - b, err := proto.MarshalOptions{Deterministic: true}.Marshal(intent) - if err != nil { - return "", err - } - items = append(items, item{Idx: i, H: sha256.Sum256(b)}) - } - - sort.Slice(items, func(i, j int) bool { return items[i].Idx < items[j].Idx }) - - h := sha256.New() - h.Write([]byte("quote-payments-fp/v1")) - h.Write([]byte{0}) - for _, it := range items { - h.Write(it.H[:]) - h.Write([]byte{0}) - } - - return hex.EncodeToString(h.Sum(nil)), nil -} diff --git a/api/payments/quotation/internal/service/quotation/handlers_commands_test.go b/api/payments/quotation/internal/service/quotation/handlers_commands_test.go deleted file mode 100644 index 23305110..00000000 --- a/api/payments/quotation/internal/service/quotation/handlers_commands_test.go +++ /dev/null @@ -1,195 +0,0 @@ -package quotation - -import ( - "context" - "strings" - "testing" - "time" - - "github.com/tech/sendico/payments/storage" - "github.com/tech/sendico/payments/storage/model" - quotestorage "github.com/tech/sendico/payments/storage/quote" - "github.com/tech/sendico/pkg/merrors" - mloggerfactory "github.com/tech/sendico/pkg/mlogger/factory" - moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" - quotationv1 "github.com/tech/sendico/pkg/proto/payments/quotation/v1" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" - "go.mongodb.org/mongo-driver/v2/bson" -) - -func TestQuotePaymentStoresNonExecutableQuoteWhenPlanInvalid(t *testing.T) { - org := bson.NewObjectID() - req := "ationv1.QuotePaymentRequest{ - Meta: &sharedv1.RequestMeta{OrganizationRef: org.Hex()}, - IdempotencyKey: "idem-1", - Intent: &sharedv1.PaymentIntent{ - Kind: sharedv1.PaymentKind_PAYMENT_KIND_PAYOUT, - Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}, - SettlementCurrency: "USD", - }, - } - - quotesStore := "eCommandTestQuotesStore{ - byID: make(map[string]*model.PaymentQuoteRecord), - } - engine := "eCommandTestEngine{ - repo: quoteCommandTestRepo{quotes: quotesStore}, - buildQuoteFn: func(context.Context, string, *quotationv1.QuotePaymentRequest) (*sharedv1.PaymentQuote, time.Time, error) { - return &sharedv1.PaymentQuote{ - DebitAmount: &moneyv1.Money{Currency: "USD", Amount: "1"}, - }, time.Now().Add(time.Hour), nil - }, - buildPlanFn: func(context.Context, bson.ObjectID, *sharedv1.PaymentIntent, string, *sharedv1.PaymentQuote) (*model.PaymentPlan, error) { - return nil, merrors.InvalidArgument("plan builder: no eligible gateway instance found, last error: gateway mntx eligibility check error: amount 1 USD below per-tx min limit 10") - }, - } - cmd := "ePaymentCommand{ - engine: engine, - logger: mloggerfactory.NewLogger(false), - } - - resp, err := cmd.Execute(context.Background(), req)(context.Background()) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if resp == nil || resp.GetQuote() == nil { - t.Fatalf("expected quote response, got %#v", resp) - } - if note := resp.GetExecutionNote(); !strings.Contains(note, "quote will not be executed") { - t.Fatalf("expected non-executable note, got %q", note) - } - - stored := quotesStore.byID[req.GetIdempotencyKey()] - if stored == nil { - t.Fatalf("expected stored quote record") - } - if stored.Plan != nil { - t.Fatalf("expected no stored payment plan for non-executable quote") - } - if stored.ExecutionNote != resp.GetExecutionNote() { - t.Fatalf("expected stored execution note %q, got %q", resp.GetExecutionNote(), stored.ExecutionNote) - } -} - -func TestQuotePaymentReuseReturnsStoredExecutionNote(t *testing.T) { - org := bson.NewObjectID() - req := "ationv1.QuotePaymentRequest{ - Meta: &sharedv1.RequestMeta{OrganizationRef: org.Hex()}, - IdempotencyKey: "idem-1", - Intent: &sharedv1.PaymentIntent{ - Kind: sharedv1.PaymentKind_PAYMENT_KIND_PAYOUT, - Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}, - SettlementCurrency: "USD", - }, - } - - existing := &model.PaymentQuoteRecord{ - QuoteRef: "q1", - IdempotencyKey: req.GetIdempotencyKey(), - Hash: hashQuoteRequest(req), - Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q1"}, - ExecutionNote: "quote will not be executed: amount 1 USD below per-tx min limit 10", - } - quotesStore := "eCommandTestQuotesStore{ - byID: map[string]*model.PaymentQuoteRecord{ - req.GetIdempotencyKey(): existing, - }, - } - engine := "eCommandTestEngine{ - repo: quoteCommandTestRepo{quotes: quotesStore}, - buildQuoteFn: func(context.Context, string, *quotationv1.QuotePaymentRequest) (*sharedv1.PaymentQuote, time.Time, error) { - t.Fatalf("build quote should not be called on idempotent reuse") - return nil, time.Time{}, nil - }, - buildPlanFn: func(context.Context, bson.ObjectID, *sharedv1.PaymentIntent, string, *sharedv1.PaymentQuote) (*model.PaymentPlan, error) { - t.Fatalf("build plan should not be called on idempotent reuse") - return nil, nil - }, - } - cmd := "ePaymentCommand{ - engine: engine, - logger: mloggerfactory.NewLogger(false), - } - - resp, err := cmd.Execute(context.Background(), req)(context.Background()) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if resp == nil { - t.Fatalf("expected response") - } - if got, want := resp.GetExecutionNote(), existing.ExecutionNote; got != want { - t.Fatalf("expected execution note %q, got %q", want, got) - } - if resp.GetQuote().GetQuoteRef() != "q1" { - t.Fatalf("expected quote_ref q1, got %q", resp.GetQuote().GetQuoteRef()) - } -} - -type quoteCommandTestEngine struct { - repo storage.Repository - ensureErr error - buildQuoteFn func(ctx context.Context, orgRef string, req *quotationv1.QuotePaymentRequest) (*sharedv1.PaymentQuote, time.Time, error) - buildPlanFn func(ctx context.Context, orgID bson.ObjectID, intent *sharedv1.PaymentIntent, idempotencyKey string, quote *sharedv1.PaymentQuote) (*model.PaymentPlan, error) -} - -func (e *quoteCommandTestEngine) EnsureRepository(context.Context) error { return e.ensureErr } - -func (e *quoteCommandTestEngine) BuildPaymentQuote(ctx context.Context, orgRef string, req *quotationv1.QuotePaymentRequest) (*sharedv1.PaymentQuote, time.Time, error) { - if e.buildQuoteFn == nil { - return nil, time.Time{}, nil - } - return e.buildQuoteFn(ctx, orgRef, req) -} - -func (e *quoteCommandTestEngine) BuildPaymentPlan(ctx context.Context, orgID bson.ObjectID, intent *sharedv1.PaymentIntent, idempotencyKey string, quote *sharedv1.PaymentQuote) (*model.PaymentPlan, error) { - if e.buildPlanFn == nil { - return nil, nil - } - return e.buildPlanFn(ctx, orgID, intent, idempotencyKey, quote) -} - -func (e *quoteCommandTestEngine) ResolvePaymentQuote(context.Context, quoteResolutionInput) (*sharedv1.PaymentQuote, *sharedv1.PaymentIntent, *model.PaymentPlan, error) { - return nil, nil, nil, nil -} - -func (e *quoteCommandTestEngine) Repository() storage.Repository { return e.repo } - -type quoteCommandTestRepo struct { - quotes quotestorage.QuotesStore -} - -func (r quoteCommandTestRepo) Ping(context.Context) error { return nil } -func (r quoteCommandTestRepo) Payments() storage.PaymentsStore { return nil } -func (r quoteCommandTestRepo) PaymentMethods() storage.PaymentMethodsStore { return nil } -func (r quoteCommandTestRepo) Quotes() quotestorage.QuotesStore { return r.quotes } -func (r quoteCommandTestRepo) Routes() storage.RoutesStore { return nil } -func (r quoteCommandTestRepo) PlanTemplates() storage.PlanTemplatesStore { return nil } - -type quoteCommandTestQuotesStore struct { - byID map[string]*model.PaymentQuoteRecord -} - -func (s *quoteCommandTestQuotesStore) Create(_ context.Context, rec *model.PaymentQuoteRecord) error { - if s.byID == nil { - s.byID = make(map[string]*model.PaymentQuoteRecord) - } - s.byID[rec.IdempotencyKey] = rec - return nil -} - -func (s *quoteCommandTestQuotesStore) GetByRef(_ context.Context, _ bson.ObjectID, quoteRef string) (*model.PaymentQuoteRecord, error) { - for _, rec := range s.byID { - if rec != nil && rec.QuoteRef == quoteRef { - return rec, nil - } - } - return nil, quotestorage.ErrQuoteNotFound -} - -func (s *quoteCommandTestQuotesStore) GetByIdempotencyKey(_ context.Context, _ bson.ObjectID, idempotencyKey string) (*model.PaymentQuoteRecord, error) { - if rec, ok := s.byID[idempotencyKey]; ok { - return rec, nil - } - return nil, quotestorage.ErrQuoteNotFound -} diff --git a/api/payments/quotation/internal/service/quotation/helpers.go b/api/payments/quotation/internal/service/quotation/helpers.go index 1bc1b1ec..68b7b4bc 100644 --- a/api/payments/quotation/internal/service/quotation/helpers.go +++ b/api/payments/quotation/internal/service/quotation/helpers.go @@ -51,24 +51,6 @@ func cloneMetadata(input map[string]string) map[string]string { return clone } -func cloneStringList(values []string) []string { - if len(values) == 0 { - return nil - } - result := make([]string, 0, len(values)) - for _, value := range values { - clean := strings.TrimSpace(value) - if clean == "" { - continue - } - result = append(result, clean) - } - if len(result) == 0 { - return nil - } - return result -} - func cloneFeeLines(lines []*feesv1.DerivedPostingLine) []*feesv1.DerivedPostingLine { if len(lines) == 0 { return nil diff --git a/api/payments/quotation/internal/service/quotation/internal_helpers.go b/api/payments/quotation/internal/service/quotation/internal_helpers.go index 136611b4..8357d43b 100644 --- a/api/payments/quotation/internal/service/quotation/internal_helpers.go +++ b/api/payments/quotation/internal/service/quotation/internal_helpers.go @@ -5,20 +5,11 @@ import ( "strings" "time" - "github.com/tech/sendico/pkg/api/routers/gsresponse" - "github.com/tech/sendico/pkg/mservice" feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1" sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" ) -func (s *Service) ensureRepository(ctx context.Context) error { - if s.storage == nil { - return errStorageUnavailable - } - return s.storage.Ping(ctx) -} - func (s *Service) withTimeout(ctx context.Context, d time.Duration) (context.Context, context.CancelFunc) { if d <= 0 { return context.WithCancel(ctx) @@ -26,13 +17,6 @@ func (s *Service) withTimeout(ctx context.Context, d time.Duration) (context.Con return context.WithTimeout(ctx, d) } -func executeUnary[TReq any, TResp any](ctx context.Context, svc *Service, method string, handler func(context.Context, *TReq) gsresponse.Responder[TResp], req *TReq) (*TResp, error) { - start := svc.clock.Now() - resp, err := gsresponse.Unary(svc.logger, mservice.PaymentOrchestrator, handler)(ctx, req) - observeRPC(method, err, svc.clock.Now().Sub(start)) - return resp, err -} - func triggerFromKind(kind sharedv1.PaymentKind, requiresFX bool) feesv1.Trigger { switch kind { case sharedv1.PaymentKind_PAYMENT_KIND_PAYOUT: diff --git a/api/payments/quotation/internal/service/quotation/metrics.go b/api/payments/quotation/internal/service/quotation/metrics.go deleted file mode 100644 index d6a5bf19..00000000 --- a/api/payments/quotation/internal/service/quotation/metrics.go +++ /dev/null @@ -1,65 +0,0 @@ -package quotation - -import ( - "errors" - "sync" - "time" - - "github.com/prometheus/client_golang/prometheus" - "github.com/prometheus/client_golang/prometheus/promauto" - "github.com/tech/sendico/pkg/merrors" -) - -var ( - metricsOnce sync.Once - - rpcLatency *prometheus.HistogramVec - rpcStatus *prometheus.CounterVec -) - -func initMetrics() { - metricsOnce.Do(func() { - rpcLatency = promauto.NewHistogramVec(prometheus.HistogramOpts{ - Namespace: "sendico", - Subsystem: "payment_orchestrator", - Name: "rpc_latency_seconds", - Help: "Latency distribution for payment orchestrator RPC handlers.", - Buckets: prometheus.DefBuckets, - }, []string{"method"}) - - rpcStatus = promauto.NewCounterVec(prometheus.CounterOpts{ - Namespace: "sendico", - Subsystem: "payment_orchestrator", - Name: "rpc_requests_total", - Help: "Total number of RPC invocations grouped by method and status.", - }, []string{"method", "status"}) - }) -} - -func observeRPC(method string, err error, duration time.Duration) { - if rpcLatency != nil { - rpcLatency.WithLabelValues(method).Observe(duration.Seconds()) - } - if rpcStatus != nil { - rpcStatus.WithLabelValues(method, statusLabel(err)).Inc() - } -} - -func statusLabel(err error) string { - switch { - case err == nil: - return "ok" - case errors.Is(err, merrors.ErrInvalidArg): - return "invalid_argument" - case errors.Is(err, merrors.ErrNoData): - return "not_found" - case errors.Is(err, merrors.ErrDataConflict): - return "conflict" - case errors.Is(err, merrors.ErrAccessDenied): - return "denied" - case errors.Is(err, merrors.ErrInternal): - return "internal" - default: - return "error" - } -} diff --git a/api/payments/quotation/internal/service/quotation/model_money.go b/api/payments/quotation/internal/service/quotation/model_money.go deleted file mode 100644 index cc9d09e0..00000000 --- a/api/payments/quotation/internal/service/quotation/model_money.go +++ /dev/null @@ -1,13 +0,0 @@ -package quotation - -import paymenttypes "github.com/tech/sendico/pkg/payments/types" - -func cloneMoney(input *paymenttypes.Money) *paymenttypes.Money { - if input == nil { - return nil - } - return &paymenttypes.Money{ - Currency: input.GetCurrency(), - Amount: input.GetAmount(), - } -} diff --git a/api/payments/quotation/internal/service/quotation/options.go b/api/payments/quotation/internal/service/quotation/options.go index 576c2af2..ac04dad1 100644 --- a/api/payments/quotation/internal/service/quotation/options.go +++ b/api/payments/quotation/internal/service/quotation/options.go @@ -2,7 +2,6 @@ package quotation import ( "context" - "sort" "strings" "time" @@ -11,8 +10,7 @@ import ( ledgerclient "github.com/tech/sendico/ledger/client" "github.com/tech/sendico/payments/storage/model" clockpkg "github.com/tech/sendico/pkg/clock" - "github.com/tech/sendico/pkg/merrors" - mb "github.com/tech/sendico/pkg/messaging/broker" + "github.com/tech/sendico/pkg/discovery" "github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/payments/rail" feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" @@ -31,6 +29,18 @@ type ChainGatewayResolver interface { Resolve(ctx context.Context, network string) (chainclient.Client, error) } +// GatewayRegistry exposes gateway instances for capability-based selection. +type GatewayRegistry interface { + List(ctx context.Context) ([]*model.GatewayInstanceDescriptor, error) +} + +// CardGatewayRoute maps a gateway to its funding and fee destinations. +type CardGatewayRoute struct { + FundingAddress string + FeeAddress string + FeeWalletRef string +} + type feesDependency struct { client feesv1.FeeEngineClient timeout time.Duration @@ -46,24 +56,10 @@ func (f feesDependency) available() bool { return true } -type ledgerDependency struct { - client ledgerclient.Client - internal rail.InternalLedger -} - type gatewayDependency struct { resolver ChainGatewayResolver } -type railGatewayDependency struct { - byID map[string]rail.RailGateway - byRail map[model.Rail][]rail.RailGateway - registry GatewayRegistry - chainResolver GatewayInvokeResolver - providerResolver GatewayInvokeResolver - logger mlogger.Logger -} - type oracleDependency struct { client oracleclient.Client } @@ -78,53 +74,25 @@ func (o oracleDependency) available() bool { return true } -type providerGatewayDependency struct { - resolver ChainGatewayResolver -} - type staticChainGatewayResolver struct { client chainclient.Client } -func (r staticChainGatewayResolver) Resolve(ctx context.Context, _ string) (chainclient.Client, error) { - if r.client == nil { - return nil, merrors.InvalidArgument("chain gateway client is required") - } +func (r staticChainGatewayResolver) Resolve(context.Context, string) (chainclient.Client, error) { return r.client, nil } -// CardGatewayRoute maps a gateway to its funding and fee destinations. -type CardGatewayRoute struct { - FundingAddress string - FeeAddress string - FeeWalletRef string -} - // WithFeeEngine wires the fee engine client. func WithFeeEngine(client feesv1.FeeEngineClient, timeout time.Duration) Option { return func(s *Service) { - s.deps.fees = feesDependency{ - client: client, - timeout: timeout, - } + s.deps.fees = feesDependency{client: client, timeout: timeout} } } -func WithPaymentGatewayBroker(broker mb.Broker) Option { +// WithOracleClient wires the FX oracle client. +func WithOracleClient(client oracleclient.Client) Option { return func(s *Service) { - if broker != nil { - s.gatewayBroker = broker - } - } -} - -// WithLedgerClient wires the ledger client. -func WithLedgerClient(client ledgerclient.Client) Option { - return func(s *Service) { - s.deps.ledger = ledgerDependency{ - client: client, - internal: client, - } + s.deps.oracle = oracleDependency{client: client} } } @@ -144,48 +112,21 @@ func WithChainGatewayResolver(resolver ChainGatewayResolver) Option { } } -// WithProviderSettlementGatewayClient wires the provider settlement gateway client. -func WithProviderSettlementGatewayClient(client chainclient.Client) Option { - return func(s *Service) { - s.deps.providerGateway = providerGatewayDependency{resolver: staticChainGatewayResolver{client: client}} - } -} - -// WithProviderSettlementGatewayResolver wires a resolver for provider settlement gateway clients. -func WithProviderSettlementGatewayResolver(resolver ChainGatewayResolver) Option { - return func(s *Service) { - if resolver != nil { - s.deps.providerGateway = providerGatewayDependency{resolver: resolver} - } - } -} - -// WithGatewayInvokeResolver wires a resolver for gateway invoke URIs. +// WithGatewayInvokeResolver wires a resolver for invoke URIs. func WithGatewayInvokeResolver(resolver GatewayInvokeResolver) Option { return func(s *Service) { - if resolver == nil { - return + if resolver != nil { + s.deps.gatewayInvokeResolver = resolver } - s.deps.gatewayInvokeResolver = resolver - s.deps.railGateways.chainResolver = resolver - s.deps.railGateways.providerResolver = resolver } } -// WithRailGateways wires rail gateway adapters by instance ID. -func WithRailGateways(gateways map[string]rail.RailGateway) Option { +// WithGatewayRegistry wires gateway descriptors used by quote computation/gateway selection. +func WithGatewayRegistry(registry GatewayRegistry) Option { return func(s *Service) { - if len(gateways) == 0 { - return + if registry != nil { + s.deps.gatewayRegistry = registry } - s.deps.railGateways = buildRailGatewayDependency(gateways, s.deps.gatewayRegistry, s.deps.gatewayInvokeResolver, s.deps.gatewayInvokeResolver, s.logger) - } -} - -// WithOracleClient wires the FX oracle client. -func WithOracleClient(client oracleclient.Client) Option { - return func(s *Service) { - s.deps.oracle = oracleDependency{client: client} } } @@ -196,51 +137,30 @@ func WithCardGatewayRoutes(routes map[string]CardGatewayRoute) Option { return } s.deps.cardRoutes = make(map[string]CardGatewayRoute, len(routes)) - for k, v := range routes { - s.deps.cardRoutes[strings.ToLower(strings.TrimSpace(k))] = v - } - } -} - -// WithFeeLedgerAccounts maps gateway identifiers to ledger accounts used for fees. -func WithFeeLedgerAccounts(routes map[string]string) Option { - return func(s *Service) { - if len(routes) == 0 { - return - } - s.deps.feeLedgerAccounts = make(map[string]string, len(routes)) - for k, v := range routes { - key := strings.ToLower(strings.TrimSpace(k)) - val := strings.TrimSpace(v) - if key == "" || val == "" { + for key, route := range routes { + normalized := strings.ToLower(strings.TrimSpace(key)) + if normalized == "" { continue } - s.deps.feeLedgerAccounts[key] = val + s.deps.cardRoutes[normalized] = route } } } -// WithPlanBuilder wires a payment plan builder implementation. -func WithPlanBuilder(builder PlanBuilder) Option { +// WithFeeLedgerAccounts maps gateway IDs to fee ledger accounts. +func WithFeeLedgerAccounts(accounts map[string]string) Option { return func(s *Service) { - if builder != nil { - s.deps.planBuilder = builder + if len(accounts) == 0 { + return } - } -} - -// WithGatewayRegistry wires a registry of gateway instances for routing. -func WithGatewayRegistry(registry GatewayRegistry) Option { - return func(s *Service) { - if registry != nil { - s.deps.gatewayRegistry = registry - s.deps.railGateways.registry = registry - s.deps.railGateways.chainResolver = s.deps.gatewayInvokeResolver - s.deps.railGateways.providerResolver = s.deps.gatewayInvokeResolver - s.deps.railGateways.logger = s.logger.Named("rail_gateways") - if s.deps.planBuilder == nil { - s.deps.planBuilder = newDefaultPlanBuilder(s.logger) + s.deps.feeLedgerAccounts = make(map[string]string, len(accounts)) + for key, account := range accounts { + normalized := strings.ToLower(strings.TrimSpace(key)) + value := strings.TrimSpace(account) + if normalized == "" || value == "" { + continue } + s.deps.feeLedgerAccounts[normalized] = value } } } @@ -254,46 +174,139 @@ func WithClock(clock clockpkg.Clock) Option { } } -func buildRailGatewayDependency(gateways map[string]rail.RailGateway, registry GatewayRegistry, chainResolver GatewayInvokeResolver, providerResolver GatewayInvokeResolver, logger mlogger.Logger) railGatewayDependency { - result := railGatewayDependency{ - byID: map[string]rail.RailGateway{}, - byRail: map[model.Rail][]rail.RailGateway{}, - registry: registry, - chainResolver: chainResolver, - providerResolver: providerResolver, - logger: logger, +// WithLedgerClient is retained for backward compatibility and is currently a no-op. +func WithLedgerClient(_ ledgerclient.Client) Option { + return func(*Service) {} +} + +// WithProviderSettlementGatewayClient is retained for backward compatibility and is currently a no-op. +func WithProviderSettlementGatewayClient(_ chainclient.Client) Option { + return func(*Service) {} +} + +// WithProviderSettlementGatewayResolver is retained for backward compatibility and is currently a no-op. +func WithProviderSettlementGatewayResolver(_ ChainGatewayResolver) Option { + return func(*Service) {} +} + +// WithRailGateways is retained for backward compatibility and is currently a no-op. +func WithRailGateways(_ map[string]rail.RailGateway) Option { + return func(*Service) {} +} + +type discoveryGatewayRegistry struct { + registry *discovery.Registry +} + +// NewDiscoveryGatewayRegistry adapts discovery entries into gateway descriptors. +func NewDiscoveryGatewayRegistry(_ mlogger.Logger, registry *discovery.Registry) GatewayRegistry { + if registry == nil { + return nil } - if len(gateways) == 0 { - return result + return &discoveryGatewayRegistry{registry: registry} +} + +func (r *discoveryGatewayRegistry) List(_ context.Context) ([]*model.GatewayInstanceDescriptor, error) { + if r == nil || r.registry == nil { + return nil, nil } - type item struct { - id string - gw rail.RailGateway - } - itemsByRail := map[model.Rail][]item{} - - for id, gw := range gateways { - cleanID := strings.TrimSpace(id) - if cleanID == "" || gw == nil { - continue - } - result.byID[cleanID] = gw - railID := parseRailValue(gw.Rail()) + entries := r.registry.List(time.Now(), true) + items := make([]*model.GatewayInstanceDescriptor, 0, len(entries)) + for _, entry := range entries { + railID := railFromDiscovery(entry.Rail) if railID == model.RailUnspecified { continue } - itemsByRail[railID] = append(itemsByRail[railID], item{id: cleanID, gw: gw}) + operations := operationsFromDiscovery(entry.Operations) + items = append(items, &model.GatewayInstanceDescriptor{ + ID: strings.TrimSpace(entry.ID), + InstanceID: strings.TrimSpace(entry.InstanceID), + Rail: railID, + Network: strings.ToUpper(strings.TrimSpace(entry.Network)), + InvokeURI: strings.TrimSpace(entry.InvokeURI), + Currencies: currenciesFromDiscovery(entry.Currencies), + Operations: operations, + Capabilities: model.RailCapabilitiesFromOperations(operations), + Limits: limitsFromDiscovery(entry.Limits), + IsEnabled: entry.Healthy, + }) + } + return items, nil +} + +func railFromDiscovery(value string) model.Rail { + switch discovery.NormalizeRail(value) { + case discovery.RailCrypto: + return model.RailCrypto + case discovery.RailProviderSettlement: + return model.RailProviderSettlement + case discovery.RailLedger: + return model.RailLedger + case discovery.RailCardPayout: + return model.RailCardPayout + case discovery.RailFiatOnRamp: + return model.RailFiatOnRamp + default: + return model.RailUnspecified + } +} + +func operationsFromDiscovery(values []string) []model.RailOperation { + return model.NormalizeRailOperationStrings(discovery.NormalizeRailOperations(values)) +} + +func currenciesFromDiscovery(values []string) []string { + if len(values) == 0 { + return nil + } + result := make([]string, 0, len(values)) + seen := map[string]bool{} + for _, value := range values { + currency := strings.ToUpper(strings.TrimSpace(value)) + if currency == "" || seen[currency] { + continue + } + seen[currency] = true + result = append(result, currency) + } + if len(result) == 0 { + return nil + } + return result +} + +func limitsFromDiscovery(src *discovery.Limits) model.Limits { + limits := model.Limits{} + if src == nil { + return limits } - for railID, items := range itemsByRail { - sort.Slice(items, func(i, j int) bool { - return items[i].id < items[j].id - }) - for _, entry := range items { - result.byRail[railID] = append(result.byRail[railID], entry.gw) + limits.MinAmount = strings.TrimSpace(src.MinAmount) + limits.MaxAmount = strings.TrimSpace(src.MaxAmount) + + if len(src.VolumeLimit) > 0 { + limits.VolumeLimit = map[string]string{} + for bucket, value := range src.VolumeLimit { + key := strings.TrimSpace(bucket) + amount := strings.TrimSpace(value) + if key == "" || amount == "" { + continue + } + limits.VolumeLimit[key] = amount } } - return result + if len(src.VelocityLimit) > 0 { + limits.VelocityLimit = map[string]int{} + for bucket, value := range src.VelocityLimit { + key := strings.TrimSpace(bucket) + if key == "" || value <= 0 { + continue + } + limits.VelocityLimit[key] = value + } + } + + return limits } diff --git a/api/payments/quotation/internal/service/quotation/payment_plan_factory.go b/api/payments/quotation/internal/service/quotation/payment_plan_factory.go deleted file mode 100644 index c171c54c..00000000 --- a/api/payments/quotation/internal/service/quotation/payment_plan_factory.go +++ /dev/null @@ -1,161 +0,0 @@ -package quotation - -import ( - "context" - "strings" - - "github.com/tech/sendico/payments/quotation/internal/shared" - "github.com/tech/sendico/payments/storage/model" - "github.com/tech/sendico/pkg/merrors" - paymenttypes "github.com/tech/sendico/pkg/payments/types" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" - "go.mongodb.org/mongo-driver/v2/bson" -) - -func (s *Service) buildPaymentPlan( - ctx context.Context, - orgID bson.ObjectID, - intent *sharedv1.PaymentIntent, - idempotencyKey string, - quote *sharedv1.PaymentQuote, -) (*model.PaymentPlan, error) { - if s == nil || s.storage == nil { - return nil, errStorageUnavailable - } - if err := requireNonNilIntent(intent); err != nil { - return nil, err - } - - routeStore := s.storage.Routes() - if routeStore == nil { - return nil, merrors.InvalidArgument("routes store is required") - } - planTemplates := s.storage.PlanTemplates() - if planTemplates == nil { - return nil, merrors.InvalidArgument("plan templates store is required") - } - - builder := s.deps.planBuilder - if builder == nil { - builder = newDefaultPlanBuilder(s.logger.Named("plan_builder")) - } - - planQuote := quote - if planQuote == nil { - planQuote = &sharedv1.PaymentQuote{} - } - payment := newPayment(orgID, intent, strings.TrimSpace(idempotencyKey), nil, planQuote) - if ref := strings.TrimSpace(planQuote.GetQuoteRef()); ref != "" { - payment.PaymentRef = ref - } - - plan, err := builder.Build(ctx, payment, planQuote, routeStore, planTemplates, s.deps.gatewayRegistry) - if err != nil { - return nil, err - } - if plan == nil || len(plan.Steps) == 0 { - return nil, merrors.InvalidArgument("payment plan is required") - } - return plan, nil -} - -func cloneStoredPaymentPlans(plans []*model.PaymentPlan) []*model.PaymentPlan { - if len(plans) == 0 { - return nil - } - out := make([]*model.PaymentPlan, 0, len(plans)) - for _, p := range plans { - if p == nil { - out = append(out, nil) - continue - } - out = append(out, cloneStoredPaymentPlan(p)) - } - return out -} - -func cloneStoredPaymentPlan(src *model.PaymentPlan) *model.PaymentPlan { - if src == nil { - return nil - } - clone := &model.PaymentPlan{ - ID: strings.TrimSpace(src.ID), - IdempotencyKey: strings.TrimSpace(src.IdempotencyKey), - CreatedAt: src.CreatedAt, - FXQuote: cloneStoredFXQuote(src.FXQuote), - Fees: cloneStoredFeeLines(src.Fees), - } - if len(src.Steps) > 0 { - clone.Steps = make([]*model.PaymentStep, 0, len(src.Steps)) - for _, step := range src.Steps { - if step == nil { - clone.Steps = append(clone.Steps, nil) - continue - } - stepClone := &model.PaymentStep{ - StepID: strings.TrimSpace(step.StepID), - Rail: step.Rail, - GatewayID: strings.TrimSpace(step.GatewayID), - InstanceID: strings.TrimSpace(step.InstanceID), - GatewayInvokeURI: strings.TrimSpace(step.GatewayInvokeURI), - Action: step.Action, - DependsOn: cloneStringList(step.DependsOn), - CommitPolicy: step.CommitPolicy, - CommitAfter: cloneStringList(step.CommitAfter), - Amount: cloneMoney(step.Amount), - FromRole: shared.CloneAccountRole(step.FromRole), - ToRole: shared.CloneAccountRole(step.ToRole), - } - clone.Steps = append(clone.Steps, stepClone) - } - } - return clone -} - -func cloneStoredFXQuote(src *paymenttypes.FXQuote) *paymenttypes.FXQuote { - if src == nil { - return nil - } - result := &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 { - result.Pair = &paymenttypes.CurrencyPair{ - Base: strings.TrimSpace(src.Pair.Base), - Quote: strings.TrimSpace(src.Pair.Quote), - } - } - if src.Price != nil { - result.Price = &paymenttypes.Decimal{Value: strings.TrimSpace(src.Price.Value)} - } - return result -} - -func cloneStoredFeeLines(lines []*paymenttypes.FeeLine) []*paymenttypes.FeeLine { - if len(lines) == 0 { - return nil - } - result := make([]*paymenttypes.FeeLine, 0, len(lines)) - for _, line := range lines { - if line == nil { - result = append(result, nil) - continue - } - result = append(result, &paymenttypes.FeeLine{ - LedgerAccountRef: strings.TrimSpace(line.LedgerAccountRef), - Money: cloneMoney(line.Money), - LineType: line.LineType, - Side: line.Side, - Meta: cloneMetadata(line.Meta), - }) - } - return result -} diff --git a/api/payments/quotation/internal/service/quotation/plan_builder.go b/api/payments/quotation/internal/service/quotation/plan_builder.go deleted file mode 100644 index ab4f4671..00000000 --- a/api/payments/quotation/internal/service/quotation/plan_builder.go +++ /dev/null @@ -1,28 +0,0 @@ -package quotation - -import ( - "context" - - "github.com/tech/sendico/payments/storage/model" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" -) - -// RouteStore exposes routing definitions for plan construction. -type RouteStore interface { - List(ctx context.Context, filter *model.PaymentRouteFilter) (*model.PaymentRouteList, error) -} - -// PlanTemplateStore exposes orchestration plan templates for plan construction. -type PlanTemplateStore interface { - List(ctx context.Context, filter *model.PaymentPlanTemplateFilter) (*model.PaymentPlanTemplateList, error) -} - -// GatewayRegistry exposes gateway instances for capability-based selection. -type GatewayRegistry interface { - List(ctx context.Context) ([]*model.GatewayInstanceDescriptor, error) -} - -// PlanBuilder constructs ordered payment plans from intents, quotes, and routing policy. -type PlanBuilder interface { - Build(ctx context.Context, payment *model.Payment, quote *sharedv1.PaymentQuote, routes RouteStore, templates PlanTemplateStore, gateways GatewayRegistry) (*model.PaymentPlan, error) -} diff --git a/api/payments/quotation/internal/service/quotation/plan_builder_adapters.go b/api/payments/quotation/internal/service/quotation/plan_builder_adapters.go index 5a0bfc5e..30217ced 100644 --- a/api/payments/quotation/internal/service/quotation/plan_builder_adapters.go +++ b/api/payments/quotation/internal/service/quotation/plan_builder_adapters.go @@ -6,21 +6,8 @@ import ( "github.com/tech/sendico/payments/quotation/internal/service/plan" "github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/pkg/mlogger" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" ) -type defaultPlanBuilder struct { - inner plan.Builder -} - -func newDefaultPlanBuilder(logger mlogger.Logger) PlanBuilder { - return &defaultPlanBuilder{inner: plan.NewDefaultBuilder(logger)} -} - -func (b *defaultPlanBuilder) Build(ctx context.Context, payment *model.Payment, quote *sharedv1.PaymentQuote, routes RouteStore, templates PlanTemplateStore, gateways GatewayRegistry) (*model.PaymentPlan, error) { - return b.inner.Build(ctx, payment, quote, routes, templates, gateways) -} - func railFromEndpoint(endpoint model.PaymentEndpoint, attrs map[string]string, isSource bool) (model.Rail, string, error) { return plan.RailFromEndpoint(endpoint, attrs, isSource) } @@ -29,6 +16,13 @@ func resolveRouteNetwork(attrs map[string]string, sourceNetwork, destNetwork str return plan.ResolveRouteNetwork(attrs, sourceNetwork, destNetwork) } -func selectPlanTemplate(ctx context.Context, logger mlogger.Logger, templates PlanTemplateStore, sourceRail, destRail model.Rail, network string) (*model.PaymentPlanTemplate, error) { +func selectPlanTemplate( + ctx context.Context, + logger mlogger.Logger, + templates plan.PlanTemplateStore, + sourceRail model.Rail, + destRail model.Rail, + network string, +) (*model.PaymentPlanTemplate, error) { return plan.SelectTemplate(ctx, logger, templates, sourceRail, destRail, network) } diff --git a/api/payments/quotation/internal/service/quotation/plan_builder_compat.go b/api/payments/quotation/internal/service/quotation/plan_builder_compat.go index 9fc9d32a..aa8ac01e 100644 --- a/api/payments/quotation/internal/service/quotation/plan_builder_compat.go +++ b/api/payments/quotation/internal/service/quotation/plan_builder_compat.go @@ -13,7 +13,3 @@ func sendDirectionForRail(rail model.Rail) plan.SendDirection { func isGatewayEligible(gw *model.GatewayInstanceDescriptor, rail model.Rail, network, currency string, action model.RailOperation, dir plan.SendDirection, amount decimal.Decimal) error { return plan.IsGatewayEligible(gw, rail, network, currency, action, dir, amount) } - -func parseRailValue(value string) model.Rail { - return plan.ParseRailValue(value) -} diff --git a/api/payments/quotation/internal/service/quotation/provider_settlement_gateway.go b/api/payments/quotation/internal/service/quotation/provider_settlement_gateway.go deleted file mode 100644 index 43f00cca..00000000 --- a/api/payments/quotation/internal/service/quotation/provider_settlement_gateway.go +++ /dev/null @@ -1,180 +0,0 @@ -package quotation - -import ( - "context" - "strings" - - chainclient "github.com/tech/sendico/gateway/chain/client" - "github.com/tech/sendico/pkg/merrors" - "github.com/tech/sendico/pkg/payments/rail" - moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" - chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" -) - -type providerSettlementGateway struct { - client chainclient.Client - rail string - network string - capabilities rail.RailCapabilities -} - -func NewProviderSettlementGateway(client chainclient.Client, cfg chainclient.RailGatewayConfig) rail.RailGateway { - railName := strings.ToUpper(strings.TrimSpace(cfg.Rail)) - if railName == "" { - railName = "PROVIDER_SETTLEMENT" - } - return &providerSettlementGateway{ - client: client, - rail: railName, - network: strings.ToUpper(strings.TrimSpace(cfg.Network)), - capabilities: cfg.Capabilities, - } -} - -func (g *providerSettlementGateway) Rail() string { - return g.rail -} - -func (g *providerSettlementGateway) Network() string { - return g.network -} - -func (g *providerSettlementGateway) Capabilities() rail.RailCapabilities { - return g.capabilities -} - -func (g *providerSettlementGateway) Send(ctx context.Context, req rail.TransferRequest) (rail.RailResult, error) { - if g.client == nil { - return rail.RailResult{}, merrors.Internal("provider settlement gateway: client is required") - } - idempotencyKey := strings.TrimSpace(req.IdempotencyKey) - if idempotencyKey == "" { - return rail.RailResult{}, merrors.InvalidArgument("provider settlement gateway: idempotency_key is required") - } - currency := strings.TrimSpace(req.Currency) - amount := strings.TrimSpace(req.Amount) - if currency == "" || amount == "" { - return rail.RailResult{}, merrors.InvalidArgument("provider settlement gateway: amount is required") - } - metadata := cloneMetadata(req.Metadata) - if metadata == nil { - metadata = map[string]string{} - } - if strings.TrimSpace(metadata[providerSettlementMetaPaymentIntentID]) == "" { - if ref := strings.TrimSpace(req.PaymentRef); ref != "" { - metadata[providerSettlementMetaPaymentIntentID] = ref - } - } - if strings.TrimSpace(metadata[providerSettlementMetaPaymentIntentID]) == "" { - return rail.RailResult{}, merrors.InvalidArgument("provider settlement gateway: payment_intent_id is required") - } - if strings.TrimSpace(metadata[providerSettlementMetaOutgoingLeg]) == "" && g.rail != "" { - metadata[providerSettlementMetaOutgoingLeg] = strings.ToLower(strings.TrimSpace(g.rail)) - } - submitReq := &chainv1.SubmitTransferRequest{ - IdempotencyKey: idempotencyKey, - OrganizationRef: strings.TrimSpace(req.OrganizationRef), - SourceWalletRef: strings.TrimSpace(req.FromAccountID), - Amount: &moneyv1.Money{ - Currency: currency, - Amount: amount, - }, - Metadata: metadata, - PaymentRef: strings.TrimSpace(req.PaymentRef), - IntentRef: req.IntentRef, - OperationRef: req.OperationRef, - } - if dest := buildProviderSettlementDestination(req); dest != nil { - submitReq.Destination = dest - } - resp, err := g.client.SubmitTransfer(ctx, submitReq) - if err != nil { - return rail.RailResult{}, err - } - if resp == nil || resp.GetTransfer() == nil { - return rail.RailResult{}, merrors.Internal("provider settlement gateway: missing transfer response") - } - transfer := resp.GetTransfer() - return rail.RailResult{ - ReferenceID: strings.TrimSpace(transfer.GetTransferRef()), - Status: providerSettlementStatusFromTransfer(transfer.GetStatus()), - FinalAmount: railMoneyFromProto(transfer.GetNetAmount()), - }, nil -} - -func (g *providerSettlementGateway) Observe(ctx context.Context, referenceID string) (rail.ObserveResult, error) { - if g.client == nil { - return rail.ObserveResult{}, merrors.Internal("provider settlement gateway: client is required") - } - ref := strings.TrimSpace(referenceID) - if ref == "" { - return rail.ObserveResult{}, merrors.InvalidArgument("provider settlement gateway: reference_id is required") - } - resp, err := g.client.GetTransfer(ctx, &chainv1.GetTransferRequest{TransferRef: ref}) - if err != nil { - return rail.ObserveResult{}, err - } - if resp == nil || resp.GetTransfer() == nil { - return rail.ObserveResult{}, merrors.Internal("provider settlement gateway: missing transfer response") - } - transfer := resp.GetTransfer() - return rail.ObserveResult{ - ReferenceID: ref, - Status: providerSettlementStatusFromTransfer(transfer.GetStatus()), - FinalAmount: railMoneyFromProto(transfer.GetNetAmount()), - }, nil -} - -func (g *providerSettlementGateway) Block(ctx context.Context, req rail.BlockRequest) (rail.RailResult, error) { - return rail.RailResult{}, merrors.NotImplemented("provider settlement gateway: block not supported") -} - -func (g *providerSettlementGateway) Release(ctx context.Context, req rail.ReleaseRequest) (rail.RailResult, error) { - return rail.RailResult{}, merrors.NotImplemented("provider settlement gateway: release not supported") -} - -func buildProviderSettlementDestination(req rail.TransferRequest) *chainv1.TransferDestination { - destRef := strings.TrimSpace(req.ToAccountID) - memo := strings.TrimSpace(req.DestinationMemo) - if destRef == "" && memo == "" { - return nil - } - return &chainv1.TransferDestination{ - Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: destRef}, - Memo: memo, - } -} - -func providerSettlementStatusFromTransfer(status chainv1.TransferStatus) rail.TransferStatus { - switch status { - - case chainv1.TransferStatus_TRANSFER_SUCCESS: - return rail.TransferStatusSuccess - - case chainv1.TransferStatus_TRANSFER_FAILED: - return rail.TransferStatusFailed - - case chainv1.TransferStatus_TRANSFER_CANCELLED: - // our cancellation, not from provider - return rail.TransferStatusFailed - - default: - // CREATED, PROCESSING, WAITING - return rail.TransferStatusWaiting - } -} - -func railMoneyFromProto(src *moneyv1.Money) *rail.Money { - if src == nil { - return nil - } - currency := strings.TrimSpace(src.GetCurrency()) - amount := strings.TrimSpace(src.GetAmount()) - if currency == "" || amount == "" { - return nil - } - return &rail.Money{ - Amount: amount, - Currency: currency, - } -} diff --git a/api/payments/quotation/internal/service/quotation/quotation_app.go b/api/payments/quotation/internal/service/quotation/quotation_app.go index d2e8d08c..9320f00b 100644 --- a/api/payments/quotation/internal/service/quotation/quotation_app.go +++ b/api/payments/quotation/internal/service/quotation/quotation_app.go @@ -1,35 +1,36 @@ package quotation import ( + "github.com/tech/sendico/payments/quotation/internal/service/quotation/quotation_service_v2" "github.com/tech/sendico/payments/storage" "github.com/tech/sendico/pkg/api/routers" "github.com/tech/sendico/pkg/mlogger" - quotationv1 "github.com/tech/sendico/pkg/proto/payments/quotation/v1" + quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" "google.golang.org/grpc" ) -// QuotationService exposes only quotation RPCs as a standalone gRPC service. +// QuotationService exposes quotation-v2 RPCs as a standalone gRPC service. type QuotationService struct { - core *Service - quote *quotationService + core *Service + v2 *quotation_service_v2.QuotationServiceV2 } // NewQuotationService constructs a standalone quotation service. func NewQuotationService(logger mlogger.Logger, repo storage.Repository, opts ...Option) *QuotationService { core := NewService(logger, repo, opts...) return &QuotationService{ - core: core, - quote: newQuotationService(core), + core: core, + v2: newQuotationServiceV2(core), } } // Register attaches only the quotation service to the supplied gRPC router. func (s *QuotationService) Register(router routers.GRPC) error { - if s == nil || s.quote == nil { + if s == nil || s.v2 == nil { return nil } return router.Register(func(reg grpc.ServiceRegistrar) { - quotationv1.RegisterQuotationServiceServer(reg, s.quote) + quotationv2.RegisterQuotationServiceServer(reg, s.v2) }) } diff --git a/api/payments/quotation/internal/service/quotation/quotation_app_test.go b/api/payments/quotation/internal/service/quotation/quotation_app_test.go new file mode 100644 index 00000000..b08f69cb --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/quotation_app_test.go @@ -0,0 +1,56 @@ +package quotation + +import ( + "context" + "net" + "testing" + + "github.com/tech/sendico/pkg/api/routers" + quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" + "go.uber.org/zap" + "google.golang.org/grpc" +) + +func TestQuotationService_RegisterV2Only(t *testing.T) { + svc := NewQuotationService(zap.NewNop(), nil) + router := newGRPCCaptureRouter() + + if err := svc.Register(router); err != nil { + t.Fatalf("Register returned error: %v", err) + } + + services := router.server.GetServiceInfo() + if _, ok := services[quotationv2.QuotationService_ServiceDesc.ServiceName]; !ok { + t.Fatalf("expected %q service to be registered", quotationv2.QuotationService_ServiceDesc.ServiceName) + } + if len(services) != 1 { + t.Fatalf("expected exactly one registered service, got %d", len(services)) + } +} + +type grpcCaptureRouter struct { + server *grpc.Server + done chan error +} + +func newGRPCCaptureRouter() *grpcCaptureRouter { + return &grpcCaptureRouter{ + server: grpc.NewServer(), + done: make(chan error), + } +} + +func (r *grpcCaptureRouter) Register(registration routers.GRPCServiceRegistration) error { + registration(r.server) + return nil +} + +func (r *grpcCaptureRouter) Start(context.Context) error { return nil } + +func (r *grpcCaptureRouter) Finish(context.Context) error { return nil } + +func (r *grpcCaptureRouter) Addr() net.Addr { return nil } + +func (r *grpcCaptureRouter) Done() <-chan error { return r.done } + +var _ routers.GRPC = (*grpcCaptureRouter)(nil) diff --git a/api/payments/quotation/internal/service/quotation/quotation_service.go b/api/payments/quotation/internal/service/quotation/quotation_service.go deleted file mode 100644 index 95f9b55f..00000000 --- a/api/payments/quotation/internal/service/quotation/quotation_service.go +++ /dev/null @@ -1,24 +0,0 @@ -package quotation - -import ( - "context" - - quotationv1 "github.com/tech/sendico/pkg/proto/payments/quotation/v1" -) - -type quotationService struct { - svc *Service - quotationv1.UnimplementedQuotationServiceServer -} - -func newQuotationService(svc *Service) *quotationService { - return "ationService{svc: svc} -} - -func (s *quotationService) QuotePayment(ctx context.Context, req *quotationv1.QuotePaymentRequest) (*quotationv1.QuotePaymentResponse, error) { - return s.svc.QuotePayment(ctx, req) -} - -func (s *quotationService) QuotePayments(ctx context.Context, req *quotationv1.QuotePaymentsRequest) (*quotationv1.QuotePaymentsResponse, error) { - return s.svc.QuotePayments(ctx, req) -} diff --git a/api/payments/quotation/internal/service/quotation/quotation_service_v2/logging.go b/api/payments/quotation/internal/service/quotation/quotation_service_v2/logging.go new file mode 100644 index 00000000..df90ceda --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/quotation_service_v2/logging.go @@ -0,0 +1,143 @@ +package quotation_service_v2 + +import ( + "fmt" + "strings" + + "github.com/tech/sendico/pkg/mlogger" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/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" + "go.uber.org/zap" +) + +type endpointLogSummary struct { + ResolutionType string `json:"resolutionType"` + MethodRef string `json:"methodRef,omitempty"` + MethodType string `json:"methodType,omitempty"` + RecipientRef string `json:"recipientRef,omitempty"` + PayeeRef string `json:"payeeRef,omitempty"` + DataBytes int `json:"dataBytes,omitempty"` +} + +type quoteIntentLogSummary struct { + Source endpointLogSummary `json:"source"` + Destination endpointLogSummary `json:"destination"` + Amount string `json:"amount,omitempty"` + SettlementMode string `json:"settlementMode,omitempty"` + FeeTreatment string `json:"feeTreatment,omitempty"` + SettlementCurrency string `json:"settlementCurrency,omitempty"` + HasComment bool `json:"hasComment"` +} + +func (s *QuotationServiceV2) quotePaymentLogger(req *quotationv2.QuotePaymentRequest) mlogger.Logger { + return s.logger.With( + zap.String("flow_ref", bson.NewObjectID().Hex()), + zap.String("rpc_method", "QuotePayment"), + zap.String("organization_ref", strings.TrimSpace(req.GetMeta().GetOrganizationRef())), + zap.String("idempotency_key", strings.TrimSpace(req.GetIdempotencyKey())), + zap.Bool("preview_only", req.GetPreviewOnly()), + zap.String("initiator_ref", strings.TrimSpace(req.GetInitiatorRef())), + ) +} + +func (s *QuotationServiceV2) quotePaymentsLogger(req *quotationv2.QuotePaymentsRequest) mlogger.Logger { + return s.logger.With( + zap.String("flow_ref", bson.NewObjectID().Hex()), + zap.String("rpc_method", "QuotePayments"), + zap.String("organization_ref", strings.TrimSpace(req.GetMeta().GetOrganizationRef())), + zap.String("idempotency_key", strings.TrimSpace(req.GetIdempotencyKey())), + zap.Bool("preview_only", req.GetPreviewOnly()), + zap.String("initiator_ref", strings.TrimSpace(req.GetInitiatorRef())), + zap.Int("intent_count", len(req.GetIntents())), + ) +} + +func summarizeQuoteIntent(intent *quotationv2.QuoteIntent) *quoteIntentLogSummary { + if intent == nil { + return nil + } + return "eIntentLogSummary{ + Source: summarizeEndpoint(intent.GetSource()), + Destination: summarizeEndpoint(intent.GetDestination()), + Amount: moneyLogValue(intent.GetAmount()), + SettlementMode: enumLogValue(intent.GetSettlementMode().String()), + FeeTreatment: enumLogValue(intent.GetFeeTreatment().String()), + SettlementCurrency: strings.ToUpper(strings.TrimSpace(intent.GetSettlementCurrency())), + HasComment: strings.TrimSpace(intent.GetComment()) != "", + } +} + +func summarizeQuoteIntentList(intents []*quotationv2.QuoteIntent, limit int) ([]quoteIntentLogSummary, int, int) { + total := len(intents) + if total == 0 || limit <= 0 { + return nil, total, 0 + } + if limit > total { + limit = total + } + + items := make([]quoteIntentLogSummary, 0, limit) + for i := 0; i < limit; i++ { + if summary := summarizeQuoteIntent(intents[i]); summary != nil { + items = append(items, *summary) + continue + } + items = append(items, quoteIntentLogSummary{}) + } + return items, total, total - limit +} + +func summarizeEndpoint(endpoint *endpointv1.PaymentEndpoint) endpointLogSummary { + if endpoint == nil { + return endpointLogSummary{ResolutionType: "unspecified"} + } + switch source := endpoint.GetSource().(type) { + case *endpointv1.PaymentEndpoint_PaymentMethodRef: + return endpointLogSummary{ + ResolutionType: "payment_method_ref", + MethodRef: strings.TrimSpace(source.PaymentMethodRef), + } + case *endpointv1.PaymentEndpoint_PaymentMethod: + method := source.PaymentMethod + if method == nil { + return endpointLogSummary{ResolutionType: "payment_method"} + } + return endpointLogSummary{ + ResolutionType: "payment_method", + MethodType: enumLogValue(method.GetType().String()), + RecipientRef: strings.TrimSpace(method.GetRecipientRef()), + DataBytes: len(method.GetData()), + } + case *endpointv1.PaymentEndpoint_PayeeRef: + return endpointLogSummary{ + ResolutionType: "payee_ref", + PayeeRef: strings.TrimSpace(source.PayeeRef), + } + default: + return endpointLogSummary{ResolutionType: "unspecified"} + } +} + +func moneyLogValue(m *moneyv1.Money) string { + if m == nil { + return "" + } + amount := strings.TrimSpace(m.GetAmount()) + currency := strings.ToUpper(strings.TrimSpace(m.GetCurrency())) + switch { + case amount == "" && currency == "": + return "" + case amount == "": + return currency + case currency == "": + return amount + default: + return fmt.Sprintf("%s %s", amount, currency) + } +} + +func enumLogValue(value string) string { + return strings.ToLower(strings.TrimSpace(value)) +} diff --git a/api/payments/quotation/internal/service/quotation/quotation_service_v2/process_batch.go b/api/payments/quotation/internal/service/quotation/quotation_service_v2/process_batch.go index 24e4d80f..7663a37a 100644 --- a/api/payments/quotation/internal/service/quotation/quotation_service_v2/process_batch.go +++ b/api/payments/quotation/internal/service/quotation/quotation_service_v2/process_batch.go @@ -13,24 +13,45 @@ import ( "github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/pkg/merrors" quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" + "go.uber.org/zap" ) func (s *QuotationServiceV2) ProcessQuotePayments( ctx context.Context, req *quotationv2.QuotePaymentsRequest, ) (*QuotePaymentsResult, error) { + logger := s.quotePaymentsLogger(req).Named("processor").With(zap.String("mode", "batch")) + startedAt := time.Now() + logger.Info("QuotePayments request received") + summaries, totalIntents, truncatedIntents := summarizeQuoteIntentList(req.GetIntents(), 10) + logger.Debug("QuotePayments request payload", + zap.Int("intent_count", totalIntents), + zap.Int("intent_count_truncated", truncatedIntents), + zap.Any("intents", summaries), + ) + logger.Debug("ProcessQuotePayments started") + if err := s.validateDependencies(); err != nil { + logger.Warn("ProcessQuotePayments failed on dependency validation", zap.Error(err)) return nil, err } + logger.Debug("ProcessQuotePayments dependencies validated") requestCtx, err := s.deps.Validator.ValidateQuotePayments(req) if err != nil { + logger.Debug("ProcessQuotePayments failed on request validation", zap.Error(err)) return nil, err } + logger.Debug("ProcessQuotePayments request validated", + zap.String("organization_ref", requestCtx.OrganizationRef), + zap.Bool("preview_only", requestCtx.PreviewOnly), + zap.Int("intent_count", requestCtx.IntentCount), + ) fingerprint := "" if !requestCtx.PreviewOnly { fingerprint = s.deps.Idempotency.FingerprintQuotePayments(req) + logger.Debug("ProcessQuotePayments checking idempotency reuse") reusedRecord, reused, reuseErr := s.deps.Idempotency.TryReuse(ctx, s.deps.QuotesStore, quote_idempotency_service.ReuseInput{ OrganizationID: requestCtx.OrganizationID, IdempotencyKey: requestCtx.IdempotencyKey, @@ -38,11 +59,29 @@ func (s *QuotationServiceV2) ProcessQuotePayments( Shape: quote_idempotency_service.QuoteShapeBatch, }) if reuseErr != nil { + logger.Warn("ProcessQuotePayments idempotency reuse check failed", zap.Error(reuseErr)) return nil, reuseErr } if reused { - return s.batchResultFromRecord(reusedRecord) + logger.Info("ProcessQuotePayments served from idempotency reuse", + zap.String("quote_ref", strings.TrimSpace(reusedRecord.QuoteRef)), + zap.Duration("elapsed", time.Since(startedAt)), + ) + reusedResult, mapErr := s.batchResultFromRecord(reusedRecord) + if mapErr != nil { + logger.Warn("ProcessQuotePayments failed to map reused record", zap.Error(mapErr)) + return nil, mapErr + } + reusedResponse := reusedResult.Response + logger.Info("QuotePayments response ready", + zap.Duration("elapsed", time.Since(startedAt)), + zap.String("quote_ref", strings.TrimSpace(reusedResponse.GetQuoteRef())), + zap.String("idempotency_key", strings.TrimSpace(reusedResponse.GetIdempotencyKey())), + zap.Int("quotes_count", len(reusedResponse.GetQuotes())), + ) + return reusedResult, nil } + logger.Debug("ProcessQuotePayments idempotency reuse miss") } hydrated, err := s.deps.Hydrator.HydrateMany(ctx, transfer_intent_hydrator.HydrateManyInput{ @@ -51,13 +90,16 @@ func (s *QuotationServiceV2) ProcessQuotePayments( Intents: req.GetIntents(), }) if err != nil { + logger.Debug("ProcessQuotePayments failed during intent hydration", zap.Error(err)) return nil, err } + logger.Debug("ProcessQuotePayments intents hydrated", zap.Int("intent_count", len(hydrated))) quoteRef := "" if !requestCtx.PreviewOnly { quoteRef = normalizeQuoteRef(s.deps.NewRef()) if quoteRef == "" { + logger.Warn("ProcessQuotePayments generated empty quote_ref") return nil, merrors.InvalidArgument("quote_ref is required") } } @@ -70,6 +112,7 @@ func (s *QuotationServiceV2) ProcessQuotePayments( quoteRef, s.deps.Now().UTC(), collector, + logger, ) batch := batch_quote_processor_v2.New(single) @@ -78,22 +121,27 @@ func (s *QuotationServiceV2) ProcessQuotePayments( Intents: hydrated, }) if err != nil { + logger.Warn("ProcessQuotePayments failed during computation pipeline", zap.Error(err)) return nil, err } if batchOut == nil || len(batchOut.Items) != len(hydrated) { + logger.Warn("ProcessQuotePayments returned invalid batch output") return nil, merrors.InvalidArgument("batch quote output is invalid") } quotes := make([]*quotationv2.PaymentQuote, 0, len(batchOut.Items)) for _, item := range batchOut.Items { if item == nil || item.Quote == nil { + logger.Warn("ProcessQuotePayments contains empty quote item") return nil, merrors.InvalidArgument("batch item quote is required") } quotes = append(quotes, item.Quote) } + logger.Debug("ProcessQuotePayments computation completed", zap.Int("quotes_count", len(quotes))) details := collector.Ordered(len(batchOut.Items)) if len(details) != len(batchOut.Items) { + logger.Warn("ProcessQuotePayments missing item details", zap.Int("details_count", len(details)), zap.Int("items_count", len(batchOut.Items))) return nil, merrors.InvalidArgument("batch processing details are incomplete") } @@ -106,6 +154,17 @@ func (s *QuotationServiceV2) ProcessQuotePayments( Response: response, } if requestCtx.PreviewOnly { + logger.Info("ProcessQuotePayments preview completed", + zap.Duration("elapsed", time.Since(startedAt)), + zap.String("quote_ref", strings.TrimSpace(response.GetQuoteRef())), + zap.Int("quotes_count", len(response.GetQuotes())), + ) + logger.Info("QuotePayments response ready", + zap.Duration("elapsed", time.Since(startedAt)), + zap.String("quote_ref", strings.TrimSpace(response.GetQuoteRef())), + zap.String("idempotency_key", strings.TrimSpace(response.GetIdempotencyKey())), + zap.Int("quotes_count", len(response.GetQuotes())), + ) return result, nil } @@ -115,6 +174,7 @@ func (s *QuotationServiceV2) ProcessQuotePayments( statuses := make([]*quote_persistence_service.StatusInput, 0, len(details)) for _, detail := range details { if detail == nil || detail.Intent.Amount == nil || detail.Quote == nil { + logger.Warn("ProcessQuotePayments contains incomplete detail") return nil, merrors.InvalidArgument("batch processing detail is incomplete") } expires = append(expires, detail.ExpiresAt) @@ -125,6 +185,7 @@ func (s *QuotationServiceV2) ProcessQuotePayments( expiresAt, ok := minExpiry(expires) if !ok { + logger.Warn("ProcessQuotePayments produced empty expires_at") return nil, merrors.InvalidArgument("expires_at is required") } @@ -139,6 +200,7 @@ func (s *QuotationServiceV2) ProcessQuotePayments( Statuses: statuses, }) if err != nil { + logger.Warn("ProcessQuotePayments failed to build persistence record", zap.Error(err)) return nil, err } @@ -152,6 +214,7 @@ func (s *QuotationServiceV2) ProcessQuotePayments( }, }) if err != nil { + logger.Warn("ProcessQuotePayments failed while storing idempotent record", zap.Error(err)) if errors.Is(err, quote_idempotency_service.ErrIdempotencyParamMismatch) || errors.Is(err, quote_idempotency_service.ErrIdempotencyShapeMismatch) { return nil, merrors.InvalidArgument(err.Error()) @@ -159,9 +222,37 @@ func (s *QuotationServiceV2) ProcessQuotePayments( return nil, err } if reused { - return s.batchResultFromRecord(stored) + logger.Info("ProcessQuotePayments reused concurrent idempotent record", + zap.String("quote_ref", strings.TrimSpace(stored.QuoteRef)), + zap.Duration("elapsed", time.Since(startedAt)), + ) + reusedResult, mapErr := s.batchResultFromRecord(stored) + if mapErr != nil { + logger.Warn("ProcessQuotePayments failed to map reused concurrent record", zap.Error(mapErr)) + return nil, mapErr + } + reusedResponse := reusedResult.Response + logger.Info("QuotePayments response ready", + zap.Duration("elapsed", time.Since(startedAt)), + zap.String("quote_ref", strings.TrimSpace(reusedResponse.GetQuoteRef())), + zap.String("idempotency_key", strings.TrimSpace(reusedResponse.GetIdempotencyKey())), + zap.Int("quotes_count", len(reusedResponse.GetQuotes())), + ) + return reusedResult, nil } result.Record = stored + logger.Info("ProcessQuotePayments persisted quote batch", + zap.String("quote_ref", strings.TrimSpace(stored.QuoteRef)), + zap.Int("quotes_count", len(stored.Quotes)), + zap.Time("expires_at", stored.ExpiresAt), + zap.Duration("elapsed", time.Since(startedAt)), + ) + logger.Info("QuotePayments response ready", + zap.Duration("elapsed", time.Since(startedAt)), + zap.String("quote_ref", strings.TrimSpace(response.GetQuoteRef())), + zap.String("idempotency_key", strings.TrimSpace(response.GetIdempotencyKey())), + zap.Int("quotes_count", len(response.GetQuotes())), + ) return result, nil } diff --git a/api/payments/quotation/internal/service/quotation/quotation_service_v2/process_single.go b/api/payments/quotation/internal/service/quotation/quotation_service_v2/process_single.go index a3a41c12..29fa44d0 100644 --- a/api/payments/quotation/internal/service/quotation/quotation_service_v2/process_single.go +++ b/api/payments/quotation/internal/service/quotation/quotation_service_v2/process_single.go @@ -4,6 +4,7 @@ import ( "context" "errors" "strings" + "time" "github.com/tech/sendico/payments/quotation/internal/service/quotation/batch_quote_processor_v2" "github.com/tech/sendico/payments/quotation/internal/service/quotation/quote_idempotency_service" @@ -12,24 +13,40 @@ import ( "github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/pkg/merrors" quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" + "go.uber.org/zap" ) func (s *QuotationServiceV2) ProcessQuotePayment( ctx context.Context, req *quotationv2.QuotePaymentRequest, ) (*QuotePaymentResult, error) { + logger := s.quotePaymentLogger(req).Named("processor").With(zap.String("mode", "single")) + startedAt := time.Now() + logger.Info("QuotePayment request received") + logger.Debug("QuotePayment request payload", zap.Any("intent", summarizeQuoteIntent(req.GetIntent()))) + logger.Debug("ProcessQuotePayment started") + if err := s.validateDependencies(); err != nil { + logger.Warn("ProcessQuotePayment failed on dependency validation", zap.Error(err)) return nil, err } + logger.Debug("ProcessQuotePayment dependencies validated") requestCtx, err := s.deps.Validator.ValidateQuotePayment(req) if err != nil { + logger.Debug("ProcessQuotePayment failed on request validation", zap.Error(err)) return nil, err } + logger.Debug("ProcessQuotePayment request validated", + zap.String("organization_ref", requestCtx.OrganizationRef), + zap.Bool("preview_only", requestCtx.PreviewOnly), + zap.Int("intent_count", requestCtx.IntentCount), + ) fingerprint := "" if !requestCtx.PreviewOnly { fingerprint = s.deps.Idempotency.FingerprintQuotePayment(req) + logger.Debug("ProcessQuotePayment checking idempotency reuse") reusedRecord, reused, reuseErr := s.deps.Idempotency.TryReuse(ctx, s.deps.QuotesStore, quote_idempotency_service.ReuseInput{ OrganizationID: requestCtx.OrganizationID, IdempotencyKey: requestCtx.IdempotencyKey, @@ -37,11 +54,32 @@ func (s *QuotationServiceV2) ProcessQuotePayment( Shape: quote_idempotency_service.QuoteShapeSingle, }) if reuseErr != nil { + logger.Warn("ProcessQuotePayment idempotency reuse check failed", zap.Error(reuseErr)) return nil, reuseErr } if reused { - return s.singleResultFromRecord(reusedRecord) + logger.Info("ProcessQuotePayment served from idempotency reuse", + zap.String("quote_ref", strings.TrimSpace(reusedRecord.QuoteRef)), + zap.Duration("elapsed", time.Since(startedAt)), + ) + reusedResult, mapErr := s.singleResultFromRecord(reusedRecord) + if mapErr != nil { + logger.Warn("ProcessQuotePayment failed to map reused record", zap.Error(mapErr)) + return nil, mapErr + } + reusedResponse := reusedResult.Response + reusedQuote := reusedResponse.GetQuote() + logger.Info("QuotePayment response ready", + zap.Duration("elapsed", time.Since(startedAt)), + zap.String("quote_ref", strings.TrimSpace(reusedQuote.GetQuoteRef())), + zap.String("intent_ref", strings.TrimSpace(reusedQuote.GetIntentRef())), + zap.String("quote_state", strings.TrimSpace(reusedQuote.GetState().String())), + zap.String("block_reason", strings.TrimSpace(reusedQuote.GetBlockReason().String())), + zap.String("idempotency_key", strings.TrimSpace(reusedResponse.GetIdempotencyKey())), + ) + return reusedResult, nil } + logger.Debug("ProcessQuotePayment idempotency reuse miss") } hydrated, err := s.deps.Hydrator.HydrateOne(ctx, transfer_intent_hydrator.HydrateOneInput{ @@ -50,13 +88,19 @@ func (s *QuotationServiceV2) ProcessQuotePayment( Intent: req.GetIntent(), }) if err != nil { + logger.Debug("ProcessQuotePayment failed during intent hydration", zap.Error(err)) return nil, err } + logger.Debug("ProcessQuotePayment intent hydrated", + zap.String("intent_ref", strings.TrimSpace(hydrated.Ref)), + zap.String("kind", strings.TrimSpace(string(hydrated.Kind))), + ) quoteRef := "" if !requestCtx.PreviewOnly { quoteRef = normalizeQuoteRef(s.deps.NewRef()) if quoteRef == "" { + logger.Warn("ProcessQuotePayment generated empty quote_ref") return nil, merrors.InvalidArgument("quote_ref is required") } } @@ -69,6 +113,7 @@ func (s *QuotationServiceV2) ProcessQuotePayment( quoteRef, s.deps.Now().UTC(), collector, + logger, ) batch := batch_quote_processor_v2.New(single) @@ -77,11 +122,20 @@ func (s *QuotationServiceV2) ProcessQuotePayment( Intents: []*transfer_intent_hydrator.QuoteIntent{hydrated}, }) if err != nil { + logger.Warn("ProcessQuotePayment failed during computation pipeline", zap.Error(err)) return nil, err } if batchOut == nil || len(batchOut.Items) != 1 || batchOut.Items[0] == nil || batchOut.Items[0].Quote == nil { + logger.Warn("ProcessQuotePayment returned invalid single output") return nil, merrors.InvalidArgument("single quote output is invalid") } + computedQuote := batchOut.Items[0].Quote + logger.Debug("ProcessQuotePayment computation completed", + zap.String("quote_ref", strings.TrimSpace(computedQuote.GetQuoteRef())), + zap.String("intent_ref", strings.TrimSpace(computedQuote.GetIntentRef())), + zap.String("quote_state", strings.TrimSpace(computedQuote.GetState().String())), + zap.String("block_reason", strings.TrimSpace(computedQuote.GetBlockReason().String())), + ) response := "ationv2.QuotePaymentResponse{ Quote: batchOut.Items[0].Quote, @@ -97,11 +151,27 @@ func (s *QuotationServiceV2) ProcessQuotePayment( Response: response, } if requestCtx.PreviewOnly { + previewQuote := response.GetQuote() + logger.Info("ProcessQuotePayment preview completed", + zap.Duration("elapsed", time.Since(startedAt)), + zap.String("quote_ref", strings.TrimSpace(previewQuote.GetQuoteRef())), + zap.String("intent_ref", strings.TrimSpace(previewQuote.GetIntentRef())), + zap.String("quote_state", strings.TrimSpace(previewQuote.GetState().String())), + ) + logger.Info("QuotePayment response ready", + zap.Duration("elapsed", time.Since(startedAt)), + zap.String("quote_ref", strings.TrimSpace(previewQuote.GetQuoteRef())), + zap.String("intent_ref", strings.TrimSpace(previewQuote.GetIntentRef())), + zap.String("quote_state", strings.TrimSpace(previewQuote.GetState().String())), + zap.String("block_reason", strings.TrimSpace(previewQuote.GetBlockReason().String())), + zap.String("idempotency_key", strings.TrimSpace(response.GetIdempotencyKey())), + ) return result, nil } expiresAt := detail.ExpiresAt if expiresAt.IsZero() { + logger.Warn("ProcessQuotePayment produced empty expires_at") return nil, merrors.InvalidArgument("expires_at is required") } @@ -116,6 +186,7 @@ func (s *QuotationServiceV2) ProcessQuotePayment( Status: statusInputFromStatus(detail.Status), }) if err != nil { + logger.Warn("ProcessQuotePayment failed to build persistence record", zap.Error(err)) return nil, err } @@ -129,6 +200,7 @@ func (s *QuotationServiceV2) ProcessQuotePayment( }, }) if err != nil { + logger.Warn("ProcessQuotePayment failed while storing idempotent record", zap.Error(err)) if errors.Is(err, quote_idempotency_service.ErrIdempotencyParamMismatch) || errors.Is(err, quote_idempotency_service.ErrIdempotencyShapeMismatch) { return nil, merrors.InvalidArgument(err.Error()) @@ -137,9 +209,41 @@ func (s *QuotationServiceV2) ProcessQuotePayment( } if reused { - return s.singleResultFromRecord(stored) + logger.Info("ProcessQuotePayment reused concurrent idempotent record", + zap.String("quote_ref", strings.TrimSpace(stored.QuoteRef)), + zap.Duration("elapsed", time.Since(startedAt)), + ) + reusedResult, mapErr := s.singleResultFromRecord(stored) + if mapErr != nil { + logger.Warn("ProcessQuotePayment failed to map reused concurrent record", zap.Error(mapErr)) + return nil, mapErr + } + reusedResponse := reusedResult.Response + reusedQuote := reusedResponse.GetQuote() + logger.Info("QuotePayment response ready", + zap.Duration("elapsed", time.Since(startedAt)), + zap.String("quote_ref", strings.TrimSpace(reusedQuote.GetQuoteRef())), + zap.String("intent_ref", strings.TrimSpace(reusedQuote.GetIntentRef())), + zap.String("quote_state", strings.TrimSpace(reusedQuote.GetState().String())), + zap.String("block_reason", strings.TrimSpace(reusedQuote.GetBlockReason().String())), + zap.String("idempotency_key", strings.TrimSpace(reusedResponse.GetIdempotencyKey())), + ) + return reusedResult, nil } result.Record = stored + logger.Info("ProcessQuotePayment persisted quote", + zap.String("quote_ref", strings.TrimSpace(stored.QuoteRef)), + zap.Time("expires_at", stored.ExpiresAt), + zap.Duration("elapsed", time.Since(startedAt)), + ) + logger.Info("QuotePayment response ready", + zap.Duration("elapsed", time.Since(startedAt)), + zap.String("quote_ref", strings.TrimSpace(response.GetQuote().GetQuoteRef())), + zap.String("intent_ref", strings.TrimSpace(response.GetQuote().GetIntentRef())), + zap.String("quote_state", strings.TrimSpace(response.GetQuote().GetState().String())), + zap.String("block_reason", strings.TrimSpace(response.GetQuote().GetBlockReason().String())), + zap.String("idempotency_key", strings.TrimSpace(response.GetIdempotencyKey())), + ) return result, nil } diff --git a/api/payments/quotation/internal/service/quotation/quotation_service_v2/service.go b/api/payments/quotation/internal/service/quotation/quotation_service_v2/service.go index ab3554ff..5c9fff8b 100644 --- a/api/payments/quotation/internal/service/quotation/quotation_service_v2/service.go +++ b/api/payments/quotation/internal/service/quotation/quotation_service_v2/service.go @@ -15,11 +15,13 @@ import ( "github.com/tech/sendico/payments/quotation/internal/service/quotation/transfer_intent_hydrator" quotestorage "github.com/tech/sendico/payments/storage/quote" "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" "go.mongodb.org/mongo-driver/v2/bson" ) type Dependencies struct { + Logger mlogger.Logger QuotesStore quotestorage.QuotesStore Validator *quote_request_validator_v2.QuoteRequestValidatorV2 Hydrator *transfer_intent_hydrator.TransferIntentHydrator @@ -33,7 +35,8 @@ type Dependencies struct { } type QuotationServiceV2 struct { - deps Dependencies + deps Dependencies + logger mlogger.Logger quotationv2.UnimplementedQuotationServiceServer } @@ -59,7 +62,13 @@ func New(deps Dependencies) *QuotationServiceV2 { if deps.NewRef == nil { deps.NewRef = func() string { return bson.NewObjectID().Hex() } } - return &QuotationServiceV2{deps: deps} + if deps.Logger == nil { + panic("quotation_service_v2: logger is required") + } + return &QuotationServiceV2{ + deps: deps, + logger: deps.Logger.Named("v2"), + } } func (s *QuotationServiceV2) QuotePayment(ctx context.Context, req *quotationv2.QuotePaymentRequest) (*quotationv2.QuotePaymentResponse, error) { diff --git a/api/payments/quotation/internal/service/quotation/quotation_service_v2/service_e2e_test.go b/api/payments/quotation/internal/service/quotation/quotation_service_v2/service_e2e_test.go index 238a789e..b8e6a494 100644 --- a/api/payments/quotation/internal/service/quotation/quotation_service_v2/service_e2e_test.go +++ b/api/payments/quotation/internal/service/quotation/quotation_service_v2/service_e2e_test.go @@ -24,6 +24,7 @@ import ( quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap/zaptest" "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/timestamppb" @@ -36,6 +37,7 @@ func TestQuotePayment_USDTToRUB_EndToEnd(t *testing.T) { store := newInMemoryQuotesStore() core := &fakeQuoteCore{now: now} svc := New(Dependencies{ + Logger: zaptest.NewLogger(t), QuotesStore: store, Hydrator: transfer_intent_hydrator.New(nil, transfer_intent_hydrator.WithRefFactory(func() string { return "q-intent-single" @@ -88,7 +90,7 @@ func TestQuotePayment_USDTToRUB_EndToEnd(t *testing.T) { if got, want := quote.GetDestinationAmount().GetCurrency(), "RUB"; got != want { t.Fatalf("unexpected destination currency: got=%q want=%q", got, want) } - if got, want := quote.GetPayerTotalDebitAmount().GetAmount(), "101.8"; got != want { + if got, want := quote.GetPayerTotalDebitAmount().GetAmount(), "102.4"; got != want { t.Fatalf("unexpected payer_total_debit_amount: got=%q want=%q", got, want) } if got, want := quote.GetPayerTotalDebitAmount().GetCurrency(), "USDT"; got != want { @@ -197,6 +199,7 @@ func TestQuotePayment_ClampsQuoteExpiryToFXQuoteExpiry(t *testing.T) { fxTTL: 5 * time.Minute, } svc := New(Dependencies{ + Logger: zaptest.NewLogger(t), QuotesStore: store, Hydrator: transfer_intent_hydrator.New(nil, transfer_intent_hydrator.WithRefFactory(func() string { return "q-intent-single" @@ -254,6 +257,7 @@ func TestQuotePayments_USDTToRUB_ThreeItems_EndToEnd(t *testing.T) { store := newInMemoryQuotesStore() core := &fakeQuoteCore{now: now} svc := New(Dependencies{ + Logger: zaptest.NewLogger(t), QuotesStore: store, Hydrator: transfer_intent_hydrator.New(nil, transfer_intent_hydrator.WithRefFactory(func() string { return fmt.Sprintf("q-intent-%d", time.Now().UnixNano()) @@ -380,6 +384,7 @@ func TestQuotePayment_SelectsEligibleGatewaysAndIgnoresIrrelevant(t *testing.T) store := newInMemoryQuotesStore() core := &fakeQuoteCore{now: now} svc := New(Dependencies{ + Logger: zaptest.NewLogger(t), QuotesStore: store, Hydrator: transfer_intent_hydrator.New(nil, transfer_intent_hydrator.WithRefFactory(func() string { return "q-intent-topology" diff --git a/api/payments/quotation/internal/service/quotation/quotation_service_v2/single_processor.go b/api/payments/quotation/internal/service/quotation/quotation_service_v2/single_processor.go index 80eea7b0..3e6cfa99 100644 --- a/api/payments/quotation/internal/service/quotation/quotation_service_v2/single_processor.go +++ b/api/payments/quotation/internal/service/quotation/quotation_service_v2/single_processor.go @@ -13,6 +13,8 @@ import ( "github.com/tech/sendico/payments/quotation/internal/service/quotation/transfer_intent_hydrator" "github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" + "go.uber.org/zap" ) type itemProcessDetail struct { @@ -70,6 +72,7 @@ type singleIntentProcessorV2 struct { quoteRef string pricedAt time.Time collector *itemCollector + logger mlogger.Logger } func newSingleIntentProcessorV2( @@ -79,6 +82,7 @@ func newSingleIntentProcessorV2( quoteRef string, pricedAt time.Time, collector *itemCollector, + logger mlogger.Logger, ) *singleIntentProcessorV2 { return &singleIntentProcessorV2{ computation: computation, @@ -87,6 +91,7 @@ func newSingleIntentProcessorV2( quoteRef: quoteRef, pricedAt: pricedAt, collector: collector, + logger: logger.Named("single"), } } @@ -94,17 +99,29 @@ func (p *singleIntentProcessorV2) Process( ctx context.Context, in batch_quote_processor_v2.SingleProcessInput, ) (*batch_quote_processor_v2.SingleProcessOutput, error) { + if p == nil { + return nil, merrors.InvalidArgument("single processor is required") + } + logger := p.logger.With( + zap.Int("intent_index", in.Item.Index), + zap.Int("intent_count", in.Item.Count), + ) + logger.Debug("Single intent processing started") - if p == nil || p.computation == nil { + if p.computation == nil { + logger.Warn("Single intent processing failed: missing computation service") return nil, merrors.InvalidArgument("quote computation service is required") } if p.classifier == nil { + logger.Warn("Single intent processing failed: missing classifier") return nil, merrors.InvalidArgument("quote executability classifier is required") } if p.mapper == nil { + logger.Warn("Single intent processing failed: missing mapper") return nil, merrors.InvalidArgument("quote response mapper is required") } if in.Item.Intent == nil { + logger.Debug("Single intent processing failed: empty intent") return nil, merrors.InvalidArgument("intent is required") } @@ -116,17 +133,24 @@ func (p *singleIntentProcessorV2) Process( Intents: []*transfer_intent_hydrator.QuoteIntent{in.Item.Intent}, }) if err != nil { + logger.Warn("Single intent computation failed", zap.Error(err)) return nil, err } if computed == nil || computed.Plan == nil || len(computed.Results) != 1 || len(computed.Plan.Items) != 1 { + logger.Warn("Single intent computation returned invalid shape") return nil, merrors.InvalidArgument("invalid computation output for single item") } result := computed.Results[0] planItem := computed.Plan.Items[0] if result == nil || planItem == nil || result.Quote == nil || planItem.Intent.Amount == nil { + logger.Warn("Single intent computation returned incomplete payload") return nil, merrors.InvalidArgument("incomplete computation output") } + logger.Debug("Single intent computation completed", + zap.Int("steps_count", len(planItem.Steps)), + zap.String("intent_ref", strings.TrimSpace(planItem.Intent.Ref)), + ) state := p.classifier.BuildState(in.Context.PreviewOnly, result.BlockReason) status := quote_response_mapper_v2.QuoteStatus{ @@ -161,9 +185,11 @@ func (p *singleIntentProcessorV2) Process( Status: status, }) if mapErr != nil { + logger.Warn("Single intent response mapping failed", zap.Error(mapErr)) return nil, mapErr } if mapped == nil || mapped.Quote == nil { + logger.Warn("Single intent response mapping returned empty quote") return nil, merrors.InvalidArgument("mapped quote is required") } mapped.Quote.IntentRef = firstNonEmpty( @@ -182,6 +208,13 @@ func (p *singleIntentProcessorV2) Process( ExpiresAt: expiresAt, Status: status, }) + mappedQuote := mapped.Quote + logger.Debug("Single intent processing completed", + zap.String("quote_ref", strings.TrimSpace(mappedQuote.GetQuoteRef())), + zap.String("intent_ref", strings.TrimSpace(mappedQuote.GetIntentRef())), + zap.String("quote_state", strings.TrimSpace(mappedQuote.GetState().String())), + zap.String("block_reason", strings.TrimSpace(mappedQuote.GetBlockReason().String())), + ) return &batch_quote_processor_v2.SingleProcessOutput{ Quote: mapped.Quote, diff --git a/api/payments/quotation/internal/service/quotation/quotation_v2_wiring.go b/api/payments/quotation/internal/service/quotation/quotation_v2_wiring.go new file mode 100644 index 00000000..1eb8981b --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/quotation_v2_wiring.go @@ -0,0 +1,186 @@ +package quotation + +import ( + "context" + "strings" + "time" + + "github.com/tech/sendico/payments/quotation/internal/service/quotation/gateway_funding_profile" + "github.com/tech/sendico/payments/quotation/internal/service/quotation/quotation_service_v2" + "github.com/tech/sendico/payments/quotation/internal/service/quotation/quote_computation_service" + "github.com/tech/sendico/payments/quotation/internal/service/quotation/transfer_intent_hydrator" + "github.com/tech/sendico/payments/storage/model" + quotestorage "github.com/tech/sendico/payments/storage/quote" + 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" + sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" + "google.golang.org/protobuf/proto" +) + +func newQuotationServiceV2(core *Service) *quotation_service_v2.QuotationServiceV2 { + if core == nil { + return nil + } + + return quotation_service_v2.New(quotation_service_v2.Dependencies{ + Logger: core.logger, + QuotesStore: quoteStore(core), + Hydrator: transfer_intent_hydrator.New(nil), + Computation: newQuoteComputationService(core), + }) +} + +func quoteStore(core *Service) quotestorage.QuotesStore { + if core == nil || core.storage == nil { + return nil + } + return core.storage.Quotes() +} + +func newQuoteComputationService(core *Service) *quote_computation_service.QuoteComputationService { + opts := make([]quote_computation_service.Option, 0, 3) + if core != nil && core.storage != nil { + if routes := core.storage.Routes(); routes != nil { + opts = append( + opts, + quote_computation_service.WithRouteStore(routes), + quote_computation_service.WithLogger(core.logger), + ) + } + } + if core != nil && core.deps.gatewayRegistry != nil { + opts = append(opts, quote_computation_service.WithGatewayRegistry(core.deps.gatewayRegistry)) + } + if resolver := fundingProfileResolver(core); resolver != nil { + opts = append(opts, quote_computation_service.WithFundingProfileResolver(resolver)) + } + return quote_computation_service.New(legacyQuoteComputationCore{core: core}, opts...) +} + +func fundingProfileResolver(core *Service) gateway_funding_profile.FundingProfileResolver { + if core == nil { + return nil + } + + cardRoutes := make(map[string]gateway_funding_profile.CardGatewayFundingRoute, len(core.deps.cardRoutes)) + for key, route := range core.deps.cardRoutes { + routeKey := strings.TrimSpace(key) + if routeKey == "" { + continue + } + cardRoutes[routeKey] = gateway_funding_profile.CardGatewayFundingRoute{ + FundingAddress: strings.TrimSpace(route.FundingAddress), + FeeAddress: strings.TrimSpace(route.FeeAddress), + FeeWalletRef: strings.TrimSpace(route.FeeWalletRef), + } + } + + feeLedgerAccounts := make(map[string]string, len(core.deps.feeLedgerAccounts)) + for key, accountRef := range core.deps.feeLedgerAccounts { + accountKey := strings.TrimSpace(key) + account := strings.TrimSpace(accountRef) + if accountKey == "" || account == "" { + continue + } + feeLedgerAccounts[accountKey] = account + } + + if len(cardRoutes) == 0 && len(feeLedgerAccounts) == 0 { + return nil + } + return gateway_funding_profile.NewStaticFundingProfileResolver(gateway_funding_profile.StaticFundingProfileResolverInput{ + DefaultCardGateway: defaultCardGateway, + DefaultMode: model.FundingModeNone, + CardRoutes: cardRoutes, + FeeLedgerAccounts: feeLedgerAccounts, + }) +} + +type legacyQuoteComputationCore struct { + core *Service +} + +func (c legacyQuoteComputationCore) BuildQuote(ctx context.Context, in quote_computation_service.BuildQuoteInput) (*quote_computation_service.ComputedQuote, time.Time, error) { + if c.core == nil { + return nil, time.Time{}, errStorageUnavailable + } + + request := "eRequest{ + Meta: &sharedv1.RequestMeta{ + OrganizationRef: strings.TrimSpace(in.OrganizationRef), + }, + IdempotencyKey: strings.TrimSpace(in.IdempotencyKey), + Intent: protoIntentFromModel(in.Intent), + } + + legacyQuote, expiresAt, err := c.core.buildPaymentQuote(ctx, strings.TrimSpace(in.OrganizationRef), request) + if err != nil { + return nil, time.Time{}, err + } + return mapLegacyQuote(in, legacyQuote), expiresAt, nil +} + +func mapLegacyQuote(in quote_computation_service.BuildQuoteInput, src *sharedv1.PaymentQuote) *quote_computation_service.ComputedQuote { + if src == nil { + return "e_computation_service.ComputedQuote{} + } + resolvedSettlementMode := settlementModeToProto(in.Intent.SettlementMode) + if resolvedSettlementMode == paymentv1.SettlementMode_SETTLEMENT_UNSPECIFIED { + resolvedSettlementMode = paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE + } + return "e_computation_service.ComputedQuote{ + DebitAmount: cloneProtoMoney(src.GetDebitSettlementAmount()), + CreditAmount: cloneProtoMoney(src.GetExpectedSettlementAmount()), + TotalCost: cloneProtoMoney(src.GetDebitAmount()), + FeeLines: cloneFeeLines(src.GetFeeLines()), + FeeRules: cloneFeeRules(src.GetFeeRules()), + FXQuote: cloneFXQuote(src.GetFxQuote()), + Route: cloneRouteSpecification(in.Route), + ExecutionConditions: cloneExecutionConditions(in.ExecutionConditions), + ResolvedSettlementMode: resolvedSettlementMode, + ResolvedFeeTreatment: feeTreatmentForSettlementMode(resolvedSettlementMode), + } +} + +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 cloneRouteSpecification(src *quotationv2.RouteSpecification) *quotationv2.RouteSpecification { + if src == nil { + return nil + } + cloned, ok := proto.Clone(src).(*quotationv2.RouteSpecification) + if !ok { + return nil + } + return cloned +} + +func cloneExecutionConditions(src *quotationv2.ExecutionConditions) *quotationv2.ExecutionConditions { + if src == nil { + return nil + } + cloned, ok := proto.Clone(src).(*quotationv2.ExecutionConditions) + if !ok { + return nil + } + return cloned +} + +func cloneFXQuote(src *oraclev1.Quote) *oraclev1.Quote { + if src == nil { + return nil + } + cloned, ok := proto.Clone(src).(*oraclev1.Quote) + if !ok { + return nil + } + return cloned +} diff --git a/api/payments/quotation/internal/service/quotation/quote_batch.go b/api/payments/quotation/internal/service/quotation/quote_batch.go deleted file mode 100644 index e7b2b546..00000000 --- a/api/payments/quotation/internal/service/quotation/quote_batch.go +++ /dev/null @@ -1,145 +0,0 @@ -package quotation - -import ( - "fmt" - "sort" - "strings" - "time" - - "github.com/shopspring/decimal" - "github.com/tech/sendico/payments/storage/model" - moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" -) - -func perIntentIdempotencyKey(base string, index int, total int) string { - base = strings.TrimSpace(base) - if base == "" { - return "" - } - if total <= 1 { - return base - } - return fmt.Sprintf("%s:%d", base, index+1) -} - -func minQuoteExpiry(expires []time.Time) (time.Time, bool) { - var min time.Time - for _, exp := range expires { - if exp.IsZero() { - continue - } - if min.IsZero() || exp.Before(min) { - min = exp - } - } - if min.IsZero() { - return time.Time{}, false - } - return min, true -} - -func aggregatePaymentQuotes(quotes []*sharedv1.PaymentQuote) (*sharedv1.PaymentQuoteAggregate, error) { - if len(quotes) == 0 { - return nil, nil - } - debitTotals := map[string]decimal.Decimal{} - settlementTotals := map[string]decimal.Decimal{} - feeTotals := map[string]decimal.Decimal{} - networkTotals := map[string]decimal.Decimal{} - - for _, quote := range quotes { - if quote == nil { - continue - } - if err := accumulateMoney(debitTotals, quote.GetDebitAmount()); err != nil { - return nil, err - } - if err := accumulateMoney(settlementTotals, quote.GetExpectedSettlementAmount()); err != nil { - return nil, err - } - if err := accumulateMoney(feeTotals, quote.GetExpectedFeeTotal()); err != nil { - return nil, err - } - if nf := quote.GetNetworkFee(); nf != nil { - if err := accumulateMoney(networkTotals, nf.GetNetworkFee()); err != nil { - return nil, err - } - } - } - - return &sharedv1.PaymentQuoteAggregate{ - DebitAmounts: totalsToMoney(debitTotals), - ExpectedSettlementAmounts: totalsToMoney(settlementTotals), - ExpectedFeeTotals: totalsToMoney(feeTotals), - NetworkFeeTotals: totalsToMoney(networkTotals), - }, nil -} - -func accumulateMoney(totals map[string]decimal.Decimal, money *moneyv1.Money) error { - if money == nil { - return nil - } - currency := strings.TrimSpace(money.GetCurrency()) - if currency == "" { - return nil - } - amount, err := decimal.NewFromString(money.GetAmount()) - if err != nil { - return err - } - if current, ok := totals[currency]; ok { - totals[currency] = current.Add(amount) - return nil - } - totals[currency] = amount - return nil -} - -func totalsToMoney(totals map[string]decimal.Decimal) []*moneyv1.Money { - if len(totals) == 0 { - return nil - } - currencies := make([]string, 0, len(totals)) - for currency := range totals { - currencies = append(currencies, currency) - } - sort.Strings(currencies) - - result := make([]*moneyv1.Money, 0, len(currencies)) - for _, currency := range currencies { - amount := totals[currency] - result = append(result, &moneyv1.Money{ - Amount: amount.String(), - Currency: currency, - }) - } - return result -} - -func intentsFromProto(intents []*sharedv1.PaymentIntent) []model.PaymentIntent { - if len(intents) == 0 { - return nil - } - result := make([]model.PaymentIntent, 0, len(intents)) - for _, intent := range intents { - result = append(result, intentFromProto(intent)) - } - return result -} - -func quoteSnapshotsFromProto(quotes []*sharedv1.PaymentQuote) []*model.PaymentQuoteSnapshot { - if len(quotes) == 0 { - return nil - } - result := make([]*model.PaymentQuoteSnapshot, 0, len(quotes)) - for _, quote := range quotes { - if quote == nil { - continue - } - if snapshot := quoteSnapshotToModel(quote); snapshot != nil { - result = append(result, snapshot) - } - } - return result -} diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/compute.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/compute.go index 92edafd8..50a9b012 100644 --- a/api/payments/quotation/internal/service/quotation/quote_computation_service/compute.go +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/compute.go @@ -2,9 +2,9 @@ package quote_computation_service import ( "context" - "fmt" "github.com/tech/sendico/pkg/merrors" + "go.uber.org/zap" ) func (s *QuoteComputationService) Compute(ctx context.Context, in ComputeInput) (*ComputeOutput, error) { @@ -12,8 +12,19 @@ func (s *QuoteComputationService) Compute(ctx context.Context, in ComputeInput) return nil, merrors.InvalidArgument("quote computation core is required") } + s.logger.Debug("Computing quotes", + zap.String("org_ref", in.OrganizationRef), + zap.Int("intent_count", len(in.Intents)), + zap.Bool("preview_only", in.PreviewOnly), + ) + planModel, err := s.BuildPlan(ctx, in) if err != nil { + s.logger.Warn("Quote plan build failed", + zap.String("org_ref", in.OrganizationRef), + zap.Error(err), + ) + return nil, err } @@ -24,11 +35,26 @@ func (s *QuoteComputationService) Compute(ctx context.Context, in ComputeInput) if item == nil { return nil, computeErr } - return nil, fmt.Errorf("Item %d: %w", item.Index, computeErr) + + s.logger.Warn("Quote item computation failed", + zap.String("org_ref", in.OrganizationRef), + zap.Int("item_index", item.Index), + zap.String("intent_ref", item.IntentRef), + zap.Error(computeErr), + ) + + return nil, wrapIndexedError(computeErr, "Item %d", item.Index) } + results = append(results, computed) } + s.logger.Debug("Quote computation completed", + zap.String("org_ref", in.OrganizationRef), + zap.String("plan_mode", string(planModel.Mode)), + zap.Int("item_count", len(results)), + ) + return &ComputeOutput{ Plan: planModel, Results: results, @@ -45,18 +71,38 @@ func (s *QuoteComputationService) computePlanItem( quote, expiresAt, err := s.core.BuildQuote(ctx, item.QuoteInput) if err != nil { + s.logger.Warn("Quote build failed", + zap.Int("item_index", item.Index), + zap.String("intent_ref", item.IntentRef), + zap.Error(err), + ) + return nil, err } + enrichedQuote := ensureComputedQuote(quote, item) if bindErr := validateQuoteRouteBinding(enrichedQuote, item.QuoteInput); bindErr != nil { + s.logger.Warn("Quote route binding validation failed", + zap.Int("item_index", item.Index), + zap.String("intent_ref", item.IntentRef), + zap.Error(bindErr), + ) + return nil, bindErr } - result := &QuoteComputationResult{ + s.logger.Debug("Quote item computed", + zap.Int("item_index", item.Index), + zap.String("intent_ref", item.IntentRef), + zap.String("quote_ref", enrichedQuote.QuoteRef), + zap.Time("expires_at", expiresAt), + zap.String("block_reason", item.BlockReason.String()), + ) + + return &QuoteComputationResult{ ItemIndex: item.Index, Quote: enrichedQuote, ExpiresAt: expiresAt, BlockReason: item.BlockReason, - } - return result, nil + }, nil } diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/economics.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/economics.go index c5a1ac9d..b7dc37c7 100644 --- a/api/payments/quotation/internal/service/quotation/quote_computation_service/economics.go +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/economics.go @@ -1,8 +1,6 @@ package quote_computation_service import ( - "strings" - "github.com/tech/sendico/payments/storage/model" paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1" quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" @@ -19,17 +17,6 @@ func resolvedSettlementModeFromModel(mode model.SettlementMode) paymentv1.Settle } } -func resolvedSettlementModeFromRouteModelValue(value string) paymentv1.SettlementMode { - switch strings.ToUpper(strings.TrimSpace(value)) { - case "FIX_RECEIVED", "SETTLEMENT_FIX_RECEIVED", "SETTLEMENT_MODE_FIX_RECEIVED": - return paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED - case "FIX_SOURCE", "SETTLEMENT_FIX_SOURCE", "SETTLEMENT_MODE_FIX_SOURCE": - return paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE - default: - return paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE - } -} - func resolvedFeeTreatmentForSettlementMode(mode paymentv1.SettlementMode) quotationv2.FeeTreatment { switch mode { case paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED: diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/error_wrap.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/error_wrap.go new file mode 100644 index 00000000..7dc39dc7 --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/error_wrap.go @@ -0,0 +1,16 @@ +package quote_computation_service + +import ( + "errors" + "fmt" + + "github.com/tech/sendico/pkg/merrors" +) + +func wrapIndexedError(err error, format string, args ...any) error { + msg := fmt.Sprintf(format, args...) + if errors.Is(err, merrors.ErrInvalidArg) { + return merrors.InvalidArgumentWrap(err, msg) + } + return merrors.InternalWrap(err, msg) +} diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/gateway_selector.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/gateway_selector.go index 0f1ee9d7..a36c505e 100644 --- a/api/payments/quotation/internal/service/quotation/quote_computation_service/gateway_selector.go +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/gateway_selector.go @@ -2,7 +2,6 @@ package quote_computation_service import ( "context" - "fmt" "sort" "strings" @@ -11,6 +10,7 @@ import ( "github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/pkg/merrors" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + "go.uber.org/zap" ) func (s *QuoteComputationService) resolveStepGateways( @@ -19,14 +19,28 @@ func (s *QuoteComputationService) resolveStepGateways( routeNetwork string, ) error { if s == nil || s.gatewayRegistry == nil { + s.logger.Debug("Step gateway resolution skipped: no gateway registry configured") + return nil } + s.logger.Debug("Loading gateway registry", + zap.Int("step_count", len(steps)), + zap.String("route_network", routeNetwork), + ) + gateways, err := s.gatewayRegistry.List(ctx) if err != nil { + s.logger.Warn("Step gateway resolution failed: gateway registry list error", + zap.Error(err), + ) + return err } + if len(gateways) == 0 { + s.logger.Warn("Step gateway resolution failed: gateway registry has no entries") + return merrors.InvalidArgument("gateway registry has no entries") } @@ -40,29 +54,54 @@ func (s *QuoteComputationService) resolveStepGateways( return model.LessGatewayDescriptor(sorted[i], sorted[j]) }) + s.logger.Debug("Gateway registry loaded", zap.Int("gateway_count", len(sorted))) + for idx, step := range steps { if step == nil { continue } + if step.Rail == model.RailLedger { step.GatewayID = "internal" step.GatewayInvokeURI = "" + + s.logger.Debug("Step gateway assigned: ledger rail uses internal gateway", + zap.String("step_id", strings.TrimSpace(step.StepID)), + zap.Int("step_index", idx), + ) + continue } - selected, selectErr := selectGatewayForStep(sorted, step, routeNetwork) + selected, selectErr := s.selectGatewayForStep(sorted, step, routeNetwork) if selectErr != nil { - return fmt.Errorf("Step[%d] %s: %w", idx, strings.TrimSpace(step.StepID), selectErr) + s.logger.Warn("Step gateway resolution failed: no eligible gateway for step", + zap.String("step_id", strings.TrimSpace(step.StepID)), + zap.Int("step_index", idx), + zap.String("rail", string(step.Rail)), + zap.String("route_network", routeNetwork), + zap.Error(selectErr), + ) + + return selectErr } + step.GatewayID = strings.TrimSpace(selected.ID) step.InstanceID = strings.TrimSpace(selected.InstanceID) step.GatewayInvokeURI = strings.TrimSpace(selected.InvokeURI) + + s.logger.Debug("Gateway selected for step", + zap.String("step_id", strings.TrimSpace(step.StepID)), + zap.String("rail", string(step.Rail)), + zap.String("gateway_id", step.GatewayID), + zap.String("instance_id", step.InstanceID), + ) } return nil } -func selectGatewayForStep( +func (s *QuoteComputationService) selectGatewayForStep( gateways []*model.GatewayInstanceDescriptor, step *QuoteComputationStep, routeNetwork string, @@ -82,39 +121,67 @@ func selectGatewayForStep( amount = parsed } } - action := gatewayEligibilityOperation(step.Operation) + action := step.Operation direction := plan.SendDirectionForRail(step.Rail) network := networkForGatewaySelection(step.Rail, routeNetwork) + s.logger.Debug("Selecting gateway for step", + zap.String("step_id", strings.TrimSpace(step.StepID)), + zap.String("rail", string(step.Rail)), + zap.String("network", network), + zap.String("currency", currency), + zap.String("action", string(action)), + zap.String("preferred_gateway", step.GatewayID), + ) + eligible := make([]*model.GatewayInstanceDescriptor, 0, len(gateways)) - var lastErr error - for _, gw := range gateways { + for i, gw := range gateways { if gw == nil { + s.logger.Warn("Nil gateway found", zap.Int("gateway_index", i)) continue } - if err := plan.IsGatewayEligible(gw, step.Rail, network, currency, action, direction, amount); err != nil { - lastErr = err + eligErr := plan.IsGatewayEligible(gw, step.Rail, network, currency, action, direction, amount) + s.logger.Debug("Gateway eligibility check", + zap.String("step_id", strings.TrimSpace(step.StepID)), + zap.String("gateway_id", strings.TrimSpace(gw.ID)), + zap.String("instance_id", strings.TrimSpace(gw.InstanceID)), + zap.Bool("eligible", eligErr == nil), + zap.Error(eligErr), + ) + if eligErr != nil { continue } eligible = append(eligible, gw) } - if selected, _ := model.SelectGatewayByPreference( - eligible, - step.GatewayID, - step.InstanceID, - step.GatewayInvokeURI, - ); selected != nil { + s.logger.Debug("Gateway eligibility evaluated", + zap.String("step_id", strings.TrimSpace(step.StepID)), + zap.Int("eligible_count", len(eligible)), + zap.Int("total_count", len(gateways)), + ) + + selected, _ := model.SelectGatewayByPreference(eligible, step.GatewayID, step.InstanceID, step.GatewayInvokeURI) + if selected == nil && len(eligible) > 0 { + selected = eligible[0] + } + if selected != nil { + s.logger.Debug("Gateway selected", + zap.String("step_id", strings.TrimSpace(step.StepID)), + zap.String("gateway_id", strings.TrimSpace(selected.ID)), + zap.String("instance_id", strings.TrimSpace(selected.InstanceID)), + ) + return selected, nil } - if len(eligible) > 0 { - return eligible[0], nil - } - if lastErr != nil { - return nil, merrors.InvalidArgument("no eligible gateway: " + lastErr.Error()) - } - return nil, merrors.InvalidArgument("no eligible gateway") + s.logger.Warn("No eligible gateway found for step", + zap.String("step_id", strings.TrimSpace(step.StepID)), + zap.String("rail", string(step.Rail)), + zap.String("network", network), + zap.String("currency", currency), + ) + + return nil, merrors.NoData(model.NoEligibleGatewayMessage(network, currency, action, toGatewayDirection(direction))) } func parseDecimalAmount(m *moneyv1.Money) (decimal.Decimal, error) { @@ -132,15 +199,6 @@ func parseDecimalAmount(m *moneyv1.Money) (decimal.Decimal, error) { return parsed, nil } -func gatewayEligibilityOperation(op model.RailOperation) model.RailOperation { - switch op { - case model.RailOperationExternalDebit, model.RailOperationExternalCredit: - return model.RailOperationSend - default: - return op - } -} - func networkForGatewaySelection(rail model.Rail, routeNetwork string) string { switch rail { case model.RailCrypto, model.RailProviderSettlement, model.RailFiatOnRamp: @@ -150,23 +208,15 @@ func networkForGatewaySelection(rail model.Rail, routeNetwork string) string { } } -func hasExplicitDestinationGateway(attrs map[string]string) bool { - return strings.TrimSpace(firstNonEmpty( - lookupAttr(attrs, "gateway", "gateway_id", "gatewayId"), - lookupAttr(attrs, "destination_gateway", "destinationGateway"), - )) != "" -} - -func clearImplicitDestinationGateway(steps []*QuoteComputationStep) { - if len(steps) == 0 { - return +func toGatewayDirection(dir plan.SendDirection) model.GatewayDirection { + switch dir { + case plan.SendDirectionOut: + return model.GatewayDirectionOut + case plan.SendDirectionIn: + return model.GatewayDirectionIn + default: + return model.GatewayDirectionAny } - last := steps[len(steps)-1] - if last == nil { - return - } - last.GatewayID = "" - last.GatewayInvokeURI = "" } func destinationGatewayFromSteps(steps []*QuoteComputationStep) string { diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/helpers.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/helpers.go index db3698b3..cd3293f6 100644 --- a/api/payments/quotation/internal/service/quotation/quote_computation_service/helpers.go +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/helpers.go @@ -8,8 +8,6 @@ import ( moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" ) -const defaultCardGateway = "monetix" - func cloneProtoMoney(src *moneyv1.Money) *moneyv1.Money { if src == nil { return nil diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/planner.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/planner.go index b5ff27d7..b49c45f4 100644 --- a/api/payments/quotation/internal/service/quotation/quote_computation_service/planner.go +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/planner.go @@ -13,6 +13,7 @@ import ( moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" ) func (s *QuoteComputationService) BuildPlan(ctx context.Context, in ComputeInput) (*QuoteComputationPlan, error) { @@ -33,6 +34,14 @@ func (s *QuoteComputationService) BuildPlan(ctx context.Context, in ComputeInput if len(in.Intents) > 1 { mode = PlanModeBatch } + + s.logger.Debug("Building computation plan", + zap.String("org_ref", in.OrganizationRef), + zap.String("plan_mode", string(mode)), + zap.Int("intent_count", len(in.Intents)), + zap.Bool("preview_only", in.PreviewOnly), + ) + planModel := &QuoteComputationPlan{ Mode: mode, OrganizationRef: strings.TrimSpace(in.OrganizationRef), @@ -45,11 +54,24 @@ func (s *QuoteComputationService) BuildPlan(ctx context.Context, in ComputeInput for i, intent := range in.Intents { item, err := s.buildPlanItem(ctx, in, i, intent) if err != nil { - return nil, fmt.Errorf("intents[%d]: %w", i, err) + s.logger.Warn("Computation plan item build failed", + zap.String("org_ref", in.OrganizationRef), + zap.Int("intent_index", i), + zap.Error(err), + ) + + return nil, wrapIndexedError(err, "intents[%d]", i) } + planModel.Items = append(planModel.Items, item) } + s.logger.Debug("Computation plan built", + zap.String("org_ref", in.OrganizationRef), + zap.String("plan_mode", string(planModel.Mode)), + zap.Int("item_count", len(planModel.Items)), + ) + return planModel, nil } @@ -60,56 +82,117 @@ func (s *QuoteComputationService) buildPlanItem( intent *transfer_intent_hydrator.QuoteIntent, ) (*QuoteComputationPlanItem, error) { if intent == nil { + s.logger.Warn("Plan item build failed: intent is nil", zap.Int("index", index)) + return nil, merrors.InvalidArgument("intent is required") } modelIntent := modelIntentFromQuoteIntent(intent) if modelIntent.Amount == nil { + s.logger.Warn("Plan item build failed: intent amount is nil", zap.Int("index", index)) + return nil, merrors.InvalidArgument("intent.amount is required") } + if modelIntent.Source.Type == model.EndpointTypeUnspecified { + s.logger.Warn("Plan item build failed: intent source is unspecified", zap.Int("index", index)) + return nil, merrors.InvalidArgument("intent.source is required") } + if modelIntent.Destination.Type == model.EndpointTypeUnspecified { + s.logger.Warn("Plan item build failed: intent destination is unspecified", zap.Int("index", index)) + return nil, merrors.InvalidArgument("intent.destination is required") } + s.logger.Debug("Plan item intent validated", + zap.Int("index", index), + zap.String("source_type", string(modelIntent.Source.Type)), + zap.String("dest_type", string(modelIntent.Destination.Type)), + zap.String("amount_currency", modelIntent.Amount.GetCurrency()), + ) + itemIdempotencyKey := deriveItemIdempotencyKey(strings.TrimSpace(in.BaseIdempotencyKey), len(in.Intents), index) source := clonePaymentEndpoint(modelIntent.Source) destination := clonePaymentEndpoint(modelIntent.Destination) + sourceRail, sourceNetwork, err := plan.RailFromEndpoint(source, modelIntent.Attributes, true) if err != nil { + s.logger.Warn("Plan item build failed: source rail resolution error", + zap.Int("index", index), + zap.Error(err), + ) + return nil, err } + destRail, destNetwork, err := plan.RailFromEndpoint(destination, modelIntent.Attributes, false) if err != nil { + s.logger.Warn("Plan item build failed: destination rail resolution error", + zap.Int("index", index), + zap.Error(err), + ) + return nil, err } + routeNetwork, err := plan.ResolveRouteNetwork(modelIntent.Attributes, sourceNetwork, destNetwork) if err != nil { + s.logger.Warn("Plan item build failed: route network resolution error", + zap.Int("index", index), + zap.String("source_network", sourceNetwork), + zap.String("dest_network", destNetwork), + zap.Error(err), + ) + return nil, err } + s.logger.Debug("Plan item rails resolved", + zap.Int("index", index), + zap.String("source_rail", string(sourceRail)), + zap.String("dest_rail", string(destRail)), + zap.String("route_network", firstNonEmpty(routeNetwork, destNetwork, sourceNetwork)), + ) + routeRails, err := s.resolveRouteRails(ctx, sourceRail, destRail, firstNonEmpty(routeNetwork, destNetwork, sourceNetwork)) if err != nil { + s.logger.Warn("Plan item build failed: route rails resolution error", + zap.Int("index", index), + zap.String("source_rail", string(sourceRail)), + zap.String("dest_rail", string(destRail)), + zap.Error(err), + ) + return nil, err } + s.logger.Debug("Plan item route rails resolved", + zap.Int("index", index), + zap.Int("route_rails_count", len(routeRails)), + ) + steps := buildComputationSteps(index, modelIntent, destination, routeRails) - if modelIntent.Destination.Type == model.EndpointTypeCard && - s.gatewayRegistry != nil && - !hasExplicitDestinationGateway(modelIntent.Attributes) { - // Avoid sticky default provider when registry-driven selection is available. - clearImplicitDestinationGateway(steps) - } + + s.logger.Debug("Plan item steps built", zap.Int("index", index), zap.Int("step_count", len(steps))) + if err := s.resolveStepGateways( ctx, steps, firstNonEmpty(routeNetwork, destNetwork, sourceNetwork), ); err != nil { + s.logger.Warn("Plan item build failed: step gateway resolution error", + zap.Int("index", index), + zap.Error(err), + ) + return nil, err } + + s.logger.Debug("Plan item step gateways resolved", zap.Int("index", index)) + provider := firstNonEmpty( destinationGatewayFromSteps(steps), gatewayKeyForFunding(modelIntent.Attributes, destination), @@ -117,6 +200,7 @@ func (s *QuoteComputationService) buildPlanItem( if provider == "" && destRail == model.RailLedger { provider = "internal" } + funding, err := s.resolveFundingGate(ctx, resolveFundingGateInput{ OrganizationRef: strings.TrimSpace(in.OrganizationRef), Rail: destRail, @@ -133,17 +217,36 @@ func (s *QuoteComputationService) buildPlanItem( InstanceID: instanceIDForFunding(modelIntent.Attributes), }) if err != nil { + s.logger.Warn("Plan item build failed: funding gate resolution error", + zap.Int("index", index), + zap.String("provider", provider), + zap.Error(err), + ) + return nil, err } + + s.logger.Debug("Plan item funding gate resolved", + zap.Int("index", index), + zap.String("provider", provider), + zap.Bool("has_funding", funding != nil), + ) + route := buildRouteSpecification( modelIntent, firstNonEmpty(routeNetwork, destNetwork, sourceNetwork), steps, ) conditions, blockReason := buildExecutionConditions(in.PreviewOnly, steps, funding) + if route == nil || len(route.GetHops()) == 0 { blockReason = quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_ROUTE_UNAVAILABLE + + s.logger.Debug("Plan item route unavailable, item will be blocked", + zap.Int("index", index), + ) } + quoteInput := BuildQuoteInput{ OrganizationRef: strings.TrimSpace(in.OrganizationRef), IdempotencyKey: itemIdempotencyKey, @@ -160,6 +263,15 @@ func (s *QuoteComputationService) buildPlanItem( intentRef = fmt.Sprintf("intent-%d", index) } + s.logger.Debug("Computation plan item built", + zap.Int("index", index), + zap.String("intent_ref", intentRef), + zap.Int("step_count", len(steps)), + zap.String("block_reason", blockReason.String()), + zap.Bool("has_funding", funding != nil), + zap.Bool("preview_only", quoteInput.PreviewOnly), + ) + return &QuoteComputationPlanItem{ Index: index, IdempotencyKey: itemIdempotencyKey, @@ -187,14 +299,11 @@ func deriveItemIdempotencyKey(base string, total, index int) string { return fmt.Sprintf("%s:%d", base, index+1) } -func gatewayKeyForFunding(attrs map[string]string, destination model.PaymentEndpoint) string { +func gatewayKeyForFunding(attrs map[string]string, _ model.PaymentEndpoint) string { key := firstNonEmpty( lookupAttr(attrs, "gateway", "gateway_id", "gatewayId"), lookupAttr(attrs, "destination_gateway", "destinationGateway"), ) - if key == "" && destination.Card != nil { - return defaultCardGateway - } return normalizeGatewayKey(key) } @@ -225,9 +334,23 @@ func (s *QuoteComputationService) resolveFundingGate( in resolveFundingGateInput, ) (*gateway_funding_profile.QuoteFundingGate, error) { if s == nil || s.fundingResolver == nil { + s.logger.Debug("Funding gate resolution skipped: no funding resolver configured", + zap.String("gateway_id", in.GatewayID), + zap.String("rail", string(in.Rail)), + ) + return nil, nil } + s.logger.Debug("Resolving funding gate", + zap.String("org_ref", in.OrganizationRef), + zap.String("gateway_id", in.GatewayID), + zap.String("instance_id", in.InstanceID), + zap.String("rail", string(in.Rail)), + zap.String("network", in.Network), + zap.String("currency", in.Currency), + ) + profile, err := s.fundingResolver.ResolveGatewayFundingProfile(ctx, gateway_funding_profile.FundingProfileRequest{ OrganizationRef: strings.TrimSpace(in.OrganizationRef), GatewayID: normalizeGatewayKey(in.GatewayID), @@ -241,10 +364,40 @@ func (s *QuoteComputationService) resolveFundingGate( Attributes: in.Attributes, }) if err != nil { + s.logger.Warn("Funding gate resolution failed", + zap.String("org_ref", in.OrganizationRef), + zap.String("gateway_id", in.GatewayID), + zap.String("rail", string(in.Rail)), + zap.Error(err), + ) + return nil, err } + if profile == nil { + s.logger.Debug("Funding gate resolution returned no profile", + zap.String("gateway_id", in.GatewayID), + zap.String("rail", string(in.Rail)), + ) + return nil, nil } - return gateway_funding_profile.BuildFundingGateFromProfile(profile, in.Amount) + + gate, err := gateway_funding_profile.BuildFundingGateFromProfile(profile, in.Amount) + if err != nil { + s.logger.Warn("Funding gate build from profile failed", + zap.String("gateway_id", in.GatewayID), + zap.Error(err), + ) + + return nil, err + } + + s.logger.Debug("Funding gate resolved", + zap.String("gateway_id", in.GatewayID), + zap.String("rail", string(in.Rail)), + zap.Bool("has_gate", gate != nil), + ) + + return gate, nil } diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/planner_path_finding.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/planner_path_finding.go index c2f27722..11797093 100644 --- a/api/payments/quotation/internal/service/quotation/quote_computation_service/planner_path_finding.go +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/planner_path_finding.go @@ -7,6 +7,7 @@ import ( "github.com/tech/sendico/payments/quotation/internal/service/quotation/graph_path_finder" "github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/pkg/merrors" + "go.uber.org/zap" ) func (s *QuoteComputationService) resolveRouteRails( @@ -15,26 +16,69 @@ func (s *QuoteComputationService) resolveRouteRails( destinationRail model.Rail, network string, ) ([]model.Rail, error) { + s.logger.Debug("Resolving route rails", + zap.String("source_rail", string(sourceRail)), + zap.String("dest_rail", string(destinationRail)), + zap.String("network", network), + ) + if sourceRail == model.RailUnspecified { + s.logger.Warn("Route rails resolution failed: source rail is unspecified") + return nil, merrors.InvalidArgument("source rail is required") } + if destinationRail == model.RailUnspecified { + s.logger.Warn("Route rails resolution failed: destination rail is unspecified") + return nil, merrors.InvalidArgument("destination rail is required") } + if sourceRail == destinationRail { + s.logger.Debug("Route rails resolved: same rail, no path finding needed", + zap.String("rail", string(sourceRail)), + ) + return []model.Rail{sourceRail}, nil } strictGraph := s != nil && s.routeStore != nil + + s.logger.Debug("Loading route graph edges", + zap.Bool("strict_graph", strictGraph), + ) + edges, err := s.routeGraphEdges(ctx) if err != nil { + s.logger.Warn("Route rails resolution failed: route graph edges load error", + zap.String("source_rail", string(sourceRail)), + zap.String("dest_rail", string(destinationRail)), + zap.Error(err), + ) + return nil, err } + s.logger.Debug("Route graph edges loaded", + zap.Int("edge_count", len(edges)), + zap.Bool("strict_graph", strictGraph), + ) + if len(edges) == 0 { if strictGraph { + s.logger.Warn("Route rails resolution failed: route graph has no edges", + zap.String("source_rail", string(sourceRail)), + zap.String("dest_rail", string(destinationRail)), + ) + return nil, merrors.InvalidArgument("route graph has no edges") } + + s.logger.Debug("Route graph has no edges, using fallback path", + zap.String("source_rail", string(sourceRail)), + zap.String("dest_rail", string(destinationRail)), + ) + return fallbackRouteRails(sourceRail, destinationRail), nil } @@ -43,6 +87,12 @@ func (s *QuoteComputationService) resolveRouteRails( pathFinder = graph_path_finder.New() } + s.logger.Debug("Finding route path", + zap.String("source_rail", string(sourceRail)), + zap.String("dest_rail", string(destinationRail)), + zap.String("network", network), + ) + path, findErr := pathFinder.Find(graph_path_finder.FindInput{ SourceRail: sourceRail, DestinationRail: destinationRail, @@ -51,31 +101,75 @@ func (s *QuoteComputationService) resolveRouteRails( }) if findErr != nil { if strictGraph { + s.logger.Warn("Route rails resolution failed: path finding error", + zap.String("source_rail", string(sourceRail)), + zap.String("dest_rail", string(destinationRail)), + zap.String("network", network), + zap.Error(findErr), + ) + return nil, findErr } + + s.logger.Debug("Route path finding failed, using fallback path", + zap.String("source_rail", string(sourceRail)), + zap.String("dest_rail", string(destinationRail)), + zap.String("network", network), + zap.Error(findErr), + ) + return fallbackRouteRails(sourceRail, destinationRail), nil } if path == nil || len(path.Rails) == 0 { if strictGraph { + s.logger.Warn("Route rails resolution failed: path is empty", + zap.String("source_rail", string(sourceRail)), + zap.String("dest_rail", string(destinationRail)), + zap.String("network", network), + ) + return nil, merrors.InvalidArgument("route path is empty") } + + s.logger.Debug("Route path is empty, using fallback path", + zap.String("source_rail", string(sourceRail)), + zap.String("dest_rail", string(destinationRail)), + zap.String("network", network), + ) + return fallbackRouteRails(sourceRail, destinationRail), nil } + + s.logger.Debug("Route rails resolved", + zap.String("source_rail", string(sourceRail)), + zap.String("dest_rail", string(destinationRail)), + zap.Int("rail_count", len(path.Rails)), + ) + return append([]model.Rail(nil), path.Rails...), nil } func (s *QuoteComputationService) routeGraphEdges(ctx context.Context) ([]graph_path_finder.Edge, error) { if s == nil || s.routeStore == nil { + s.logger.Debug("Route graph edges skipped: no route store configured") + return nil, nil } enabled := true routes, err := s.routeStore.List(ctx, &model.PaymentRouteFilter{IsEnabled: &enabled}) if err != nil { + s.logger.Warn("Route graph edges load failed", + zap.Error(err), + ) + return nil, err } + if routes == nil || len(routes.Items) == 0 { + s.logger.Debug("Route graph edges: no routes found") + return nil, nil } @@ -84,17 +178,26 @@ func (s *QuoteComputationService) routeGraphEdges(ctx context.Context) ([]graph_ if route == nil || !route.IsEnabled { continue } + from := model.Rail(strings.ToUpper(strings.TrimSpace(string(route.FromRail)))) to := model.Rail(strings.ToUpper(strings.TrimSpace(string(route.ToRail)))) + if from == model.RailUnspecified || to == model.RailUnspecified { continue } + edges = append(edges, graph_path_finder.Edge{ FromRail: from, ToRail: to, Network: strings.ToUpper(strings.TrimSpace(route.Network)), }) } + + s.logger.Debug("Route graph edges built", + zap.Int("route_count", len(routes.Items)), + zap.Int("edge_count", len(edges)), + ) + return edges, nil } @@ -102,8 +205,10 @@ func fallbackRouteRails(sourceRail, destinationRail model.Rail) []model.Rail { if sourceRail == destinationRail { return []model.Rail{sourceRail} } + if requiresTransitBridgeStep(sourceRail, destinationRail) { return []model.Rail{sourceRail, model.RailLedger, destinationRail} } + return []model.Rail{sourceRail, destinationRail} } diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/quote_binding_validation.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/quote_binding_validation.go index 8ae9b9fa..b6d80ffa 100644 --- a/api/payments/quotation/internal/service/quotation/quote_computation_service/quote_binding_validation.go +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/quote_binding_validation.go @@ -66,10 +66,6 @@ func normalizeProvider(value string) string { return strings.ToLower(strings.TrimSpace(value)) } -func normalizePayoutMethod(value string) string { - return strings.ToUpper(strings.TrimSpace(value)) -} - func normalizeAsset(value string) string { return strings.ToUpper(strings.TrimSpace(value)) } diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/service.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/service.go index beee457e..3f225399 100644 --- a/api/payments/quotation/internal/service/quotation/quote_computation_service/service.go +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/service.go @@ -7,6 +7,8 @@ import ( "github.com/tech/sendico/payments/quotation/internal/service/plan" "github.com/tech/sendico/payments/quotation/internal/service/quotation/gateway_funding_profile" "github.com/tech/sendico/payments/quotation/internal/service/quotation/graph_path_finder" + "github.com/tech/sendico/pkg/mlogger" + "go.uber.org/zap" ) type Core interface { @@ -21,12 +23,14 @@ type QuoteComputationService struct { gatewayRegistry plan.GatewayRegistry routeStore plan.RouteStore pathFinder *graph_path_finder.GraphPathFinder + logger mlogger.Logger } func New(core Core, opts ...Option) *QuoteComputationService { svc := &QuoteComputationService{ core: core, pathFinder: graph_path_finder.New(), + logger: zap.NewNop(), } for _, opt := range opts { if opt != nil { @@ -67,3 +71,11 @@ func WithPathFinder(pathFinder *graph_path_finder.GraphPathFinder) Option { } } } + +func WithLogger(logger mlogger.Logger) Option { + return func(svc *QuoteComputationService) { + if svc != nil && logger != nil { + svc.logger = logger.Named("computation") + } + } +} diff --git a/api/payments/quotation/internal/service/quotation/quote_engine.go b/api/payments/quotation/internal/service/quotation/quote_engine.go index b71f5c97..20d55a54 100644 --- a/api/payments/quotation/internal/service/quotation/quote_engine.go +++ b/api/payments/quotation/internal/service/quotation/quote_engine.go @@ -16,13 +16,39 @@ import ( moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" - quotationv1 "github.com/tech/sendico/pkg/proto/payments/quotation/v1" sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" "go.uber.org/zap" "google.golang.org/protobuf/types/known/timestamppb" ) -func (s *Service) buildPaymentQuote(ctx context.Context, orgRef string, req *quotationv1.QuotePaymentRequest) (*sharedv1.PaymentQuote, time.Time, error) { +type quoteRequest struct { + Meta *sharedv1.RequestMeta + IdempotencyKey string + Intent *sharedv1.PaymentIntent +} + +func (r *quoteRequest) GetMeta() *sharedv1.RequestMeta { + if r == nil { + return nil + } + return r.Meta +} + +func (r *quoteRequest) GetIdempotencyKey() string { + if r == nil { + return "" + } + return r.IdempotencyKey +} + +func (r *quoteRequest) GetIntent() *sharedv1.PaymentIntent { + if r == nil { + return nil + } + return r.Intent +} + +func (s *Service) buildPaymentQuote(ctx context.Context, orgRef string, req *quoteRequest) (*sharedv1.PaymentQuote, time.Time, error) { intent := req.GetIntent() amount := intent.GetAmount() fxSide := fxv1.Side_SIDE_UNSPECIFIED @@ -117,7 +143,7 @@ func (s *Service) buildPaymentQuote(ctx context.Context, orgRef string, req *quo return quote, expiresAt, nil } -func (s *Service) quoteFees(ctx context.Context, orgRef string, req *quotationv1.QuotePaymentRequest, baseAmount *moneyv1.Money) (*feesv1.PrecomputeFeesResponse, error) { +func (s *Service) quoteFees(ctx context.Context, orgRef string, req *quoteRequest, baseAmount *moneyv1.Money) (*feesv1.PrecomputeFeesResponse, error) { if !s.deps.fees.available() { return &feesv1.PrecomputeFeesResponse{}, nil } @@ -153,7 +179,7 @@ func (s *Service) quoteFees(ctx context.Context, orgRef string, req *quotationv1 return resp, nil } -func (s *Service) quoteConversionFees(ctx context.Context, orgRef string, req *quotationv1.QuotePaymentRequest, baseAmount *moneyv1.Money) (*feesv1.PrecomputeFeesResponse, error) { +func (s *Service) quoteConversionFees(ctx context.Context, orgRef string, req *quoteRequest, baseAmount *moneyv1.Money) (*feesv1.PrecomputeFeesResponse, error) { if !s.deps.fees.available() { return &feesv1.PrecomputeFeesResponse{}, nil } @@ -454,7 +480,7 @@ func (s *Service) estimateNetworkFee(ctx context.Context, intent *sharedv1.Payme return resp, nil } -func (s *Service) requestFXQuote(ctx context.Context, orgRef string, req *quotationv1.QuotePaymentRequest) (*oraclev1.Quote, error) { +func (s *Service) requestFXQuote(ctx context.Context, orgRef string, req *quoteRequest) (*oraclev1.Quote, error) { if !s.deps.oracle.available() { if req.GetIntent().GetRequiresFx() { return nil, merrors.Internal("fx_oracle_unavailable") diff --git a/api/payments/quotation/internal/service/quotation/service.go b/api/payments/quotation/internal/service/quotation/service.go index 90512bac..d0195418 100644 --- a/api/payments/quotation/internal/service/quotation/service.go +++ b/api/payments/quotation/internal/service/quotation/service.go @@ -1,17 +1,10 @@ package quotation import ( - "context" - "github.com/tech/sendico/payments/storage" - "github.com/tech/sendico/pkg/api/routers" clockpkg "github.com/tech/sendico/pkg/clock" - msg "github.com/tech/sendico/pkg/messaging" - mb "github.com/tech/sendico/pkg/messaging/broker" "github.com/tech/sendico/pkg/mlogger" - orchestrationv1 "github.com/tech/sendico/pkg/proto/payments/orchestration/v1" - quotationv1 "github.com/tech/sendico/pkg/proto/payments/quotation/v1" - "google.golang.org/grpc" + "go.uber.org/zap" ) type serviceError string @@ -29,49 +22,37 @@ var ( errStorageUnavailable = serviceError("payments.quotation: storage not initialised") ) -// Service handles payment quotation and read models. +// Service hosts quotation-v2 runtime dependencies. type Service struct { logger mlogger.Logger storage storage.Repository clock clockpkg.Clock deps serviceDependencies - h handlerSet - - gatewayBroker mb.Broker - gatewayConsumers []msg.Consumer - - orchestrationv1.UnimplementedPaymentExecutionServiceServer } type serviceDependencies struct { fees feesDependency - ledger ledgerDependency gateway gatewayDependency - railGateways railGatewayDependency - providerGateway providerGatewayDependency oracle oracleDependency gatewayRegistry GatewayRegistry gatewayInvokeResolver GatewayInvokeResolver cardRoutes map[string]CardGatewayRoute feeLedgerAccounts map[string]string - planBuilder PlanBuilder -} - -type handlerSet struct { - commands *paymentCommandFactory } // NewService constructs the quotation service core. func NewService(logger mlogger.Logger, repo storage.Repository, opts ...Option) *Service { + if logger == nil { + logger = zap.NewNop() + } + svc := &Service{ - logger: logger.Named("payments.quotation"), + logger: logger.Named("service"), storage: repo, clock: clockpkg.NewSystem(), } - initMetrics() - for _, opt := range opts { if opt != nil { opt(svc) @@ -81,34 +62,8 @@ func NewService(logger mlogger.Logger, repo storage.Repository, opts ...Option) if svc.clock == nil { svc.clock = clockpkg.NewSystem() } - - engine := defaultPaymentEngine{svc: svc} - svc.h.commands = newPaymentCommandFactory(engine, svc.logger) - return svc } -func (s *Service) ensureHandlers() { - if s.h.commands == nil { - s.h.commands = newPaymentCommandFactory(defaultPaymentEngine{svc: s}, s.logger) - } -} - -// Register attaches the service to the supplied gRPC router. -func (s *Service) Register(router routers.GRPC) error { - return router.Register(func(reg grpc.ServiceRegistrar) { - orchestrationv1.RegisterPaymentExecutionServiceServer(reg, s) - }) -} - -// QuotePayment aggregates downstream quotes. -func (s *Service) QuotePayment(ctx context.Context, req *quotationv1.QuotePaymentRequest) (*quotationv1.QuotePaymentResponse, error) { - s.ensureHandlers() - return executeUnary(ctx, s, "QuotePayment", s.h.commands.QuotePayment().Execute, req) -} - -// QuotePayments aggregates downstream quotes for multiple intents. -func (s *Service) QuotePayments(ctx context.Context, req *quotationv1.QuotePaymentsRequest) (*quotationv1.QuotePaymentsResponse, error) { - s.ensureHandlers() - return executeUnary(ctx, s, "QuotePayments", s.h.commands.QuotePayments().Execute, req) -} +// Shutdown releases runtime resources. Quotation v2 has no background workers. +func (s *Service) Shutdown() {} diff --git a/api/payments/quotation/internal/service/quotation/service_helpers.go b/api/payments/quotation/internal/service/quotation/service_helpers.go deleted file mode 100644 index 0a3c1667..00000000 --- a/api/payments/quotation/internal/service/quotation/service_helpers.go +++ /dev/null @@ -1,191 +0,0 @@ -package quotation - -import ( - "context" - "errors" - "strings" - - "github.com/tech/sendico/payments/storage" - "github.com/tech/sendico/payments/storage/model" - quotestorage "github.com/tech/sendico/payments/storage/quote" - "github.com/tech/sendico/pkg/merrors" - quotationv1 "github.com/tech/sendico/pkg/proto/payments/quotation/v1" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" - "go.mongodb.org/mongo-driver/v2/bson" - "google.golang.org/protobuf/proto" -) - -func validateMetaAndOrgRef(meta *sharedv1.RequestMeta) (string, bson.ObjectID, error) { - if meta == nil { - return "", bson.NilObjectID, merrors.InvalidArgument("meta is required") - } - orgRef := strings.TrimSpace(meta.GetOrganizationRef()) - if orgRef == "" { - return "", bson.NilObjectID, merrors.InvalidArgument("organization_ref is required") - } - orgID, err := bson.ObjectIDFromHex(orgRef) - if err != nil { - return "", bson.NilObjectID, merrors.InvalidArgument("organization_ref must be a valid objectID") - } - return orgRef, orgID, nil -} - -func requireNonNilIntent(intent *sharedv1.PaymentIntent) error { - if intent == nil { - return merrors.InvalidArgument("intent is required") - } - if intent.GetAmount() == nil { - return merrors.InvalidArgument("intent.amount is required") - } - if strings.TrimSpace(intent.GetSettlementCurrency()) == "" { - return merrors.InvalidArgument("intent.settlement_currency is required") - } - return nil -} - -func ensureQuotesStore(repo storage.Repository) (quotestorage.QuotesStore, error) { - if repo == nil { - return nil, errStorageUnavailable - } - store := repo.Quotes() - if store == nil { - return nil, errStorageUnavailable - } - return store, nil -} - -type quoteResolutionInput struct { - OrgRef string - OrgID bson.ObjectID - Meta *sharedv1.RequestMeta - Intent *sharedv1.PaymentIntent - QuoteRef string - IdempotencyKey string -} - -type quoteResolutionError struct { - code string - err error -} - -func (e quoteResolutionError) Error() string { return e.err.Error() } - -func (s *Service) resolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*sharedv1.PaymentQuote, *sharedv1.PaymentIntent, *model.PaymentPlan, error) { - if ref := strings.TrimSpace(in.QuoteRef); ref != "" { - quotesStore, err := ensureQuotesStore(s.storage) - if err != nil { - return nil, nil, nil, err - } - record, err := quotesStore.GetByRef(ctx, in.OrgID, ref) - if err != nil { - if errors.Is(err, quotestorage.ErrQuoteNotFound) { - return nil, nil, nil, quoteResolutionError{code: "quote_not_found", err: merrors.InvalidArgument("quote_ref not found or expired")} - } - return nil, nil, nil, err - } - if !record.ExpiresAt.IsZero() && s.clock.Now().After(record.ExpiresAt) { - return nil, nil, nil, quoteResolutionError{code: "quote_expired", err: merrors.InvalidArgument("quote_ref expired")} - } - if note := strings.TrimSpace(record.ExecutionNote); note != "" { - return nil, nil, nil, quoteResolutionError{code: "quote_not_executable", err: merrors.InvalidArgument(note)} - } - intent, err := recordIntentFromQuote(record) - if err != nil { - return nil, nil, nil, err - } - if in.Intent != nil && !proto.Equal(intent, in.Intent) { - return nil, nil, nil, quoteResolutionError{code: "quote_intent_mismatch", err: merrors.InvalidArgument("quote_ref does not match intent")} - } - quote, err := recordQuoteFromQuote(record) - if err != nil { - return nil, nil, nil, err - } - quote.QuoteRef = ref - plan, err := recordPlanFromQuote(record) - if err != nil { - return nil, nil, nil, err - } - return quote, intent, plan, nil - } - - if in.Intent == nil { - return nil, nil, nil, merrors.InvalidArgument("intent is required") - } - req := "ationv1.QuotePaymentRequest{ - Meta: in.Meta, - IdempotencyKey: in.IdempotencyKey, - Intent: in.Intent, - PreviewOnly: false, - } - quote, _, err := s.buildPaymentQuote(ctx, in.OrgRef, req) - if err != nil { - return nil, nil, nil, err - } - plan, err := s.buildPaymentPlan(ctx, in.OrgID, in.Intent, in.IdempotencyKey, quote) - if err != nil { - return nil, nil, nil, err - } - return quote, in.Intent, plan, nil -} - -func recordIntentFromQuote(record *model.PaymentQuoteRecord) (*sharedv1.PaymentIntent, error) { - if record == nil { - return nil, merrors.InvalidArgument("stored quote payload is incomplete") - } - if len(record.Intents) > 0 { - if len(record.Intents) != 1 { - return nil, merrors.InvalidArgument("stored quote payload is incomplete") - } - return protoIntentFromModel(record.Intents[0]), nil - } - if record.Intent.Amount == nil && (record.Intent.Kind == "" || record.Intent.Kind == model.PaymentKindUnspecified) { - return nil, merrors.InvalidArgument("stored quote payload is incomplete") - } - return protoIntentFromModel(record.Intent), nil -} - -func recordQuoteFromQuote(record *model.PaymentQuoteRecord) (*sharedv1.PaymentQuote, error) { - if record == nil { - return nil, merrors.InvalidArgument("stored quote is empty") - } - if record.Quote != nil { - return modelQuoteToProto(record.Quote), nil - } - if len(record.Quotes) > 0 { - if len(record.Quotes) != 1 { - return nil, merrors.InvalidArgument("stored quote payload is incomplete") - } - return modelQuoteToProto(record.Quotes[0]), nil - } - return nil, merrors.InvalidArgument("stored quote is empty") -} - -func recordPlanFromQuote(record *model.PaymentQuoteRecord) (*model.PaymentPlan, error) { - if record == nil { - return nil, merrors.InvalidArgument("stored quote payload is incomplete") - } - if len(record.Plans) > 0 { - if len(record.Plans) != 1 { - return nil, merrors.InvalidArgument("stored quote payload is incomplete") - } - return cloneStoredPaymentPlan(record.Plans[0]), nil - } - if record.Plan != nil { - return cloneStoredPaymentPlan(record.Plan), nil - } - return nil, nil -} - -func newPayment(orgID bson.ObjectID, intent *sharedv1.PaymentIntent, idempotencyKey string, metadata map[string]string, quote *sharedv1.PaymentQuote) *model.Payment { - entity := &model.Payment{} - entity.SetID(bson.NewObjectID()) - entity.SetOrganizationRef(orgID) - entity.PaymentRef = entity.GetID().Hex() - entity.IdempotencyKey = idempotencyKey - entity.State = model.PaymentStateAccepted - entity.Intent = intentFromProto(intent) - entity.Metadata = cloneMetadata(metadata) - entity.LastQuote = quoteSnapshotToModel(quote) - entity.Normalize() - return entity -} diff --git a/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/hydrator.go b/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/hydrator.go index ff787924..17e744b4 100644 --- a/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/hydrator.go +++ b/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/hydrator.go @@ -2,6 +2,7 @@ package transfer_intent_hydrator import ( "context" + "errors" "fmt" "strings" @@ -157,13 +158,21 @@ func (h *TransferIntentHydrator) HydrateMany(ctx context.Context, in HydrateMany Intent: intent, }) if err != nil { - return nil, fmt.Errorf("intents[%d]: %w", i, err) + return nil, wrapIndexedIntentError(i, err) } out = append(out, item) } return out, nil } +func wrapIndexedIntentError(index int, err error) error { + msg := fmt.Sprintf("intents[%d]", index) + if errors.Is(err, merrors.ErrInvalidArg) { + return merrors.InvalidArgumentWrap(err, msg) + } + return merrors.InternalWrap(err, msg) +} + func resolveEconomics( mode paymentv1.SettlementMode, feeTreatment quotationv2.FeeTreatment, diff --git a/api/payments/storage/go.mod b/api/payments/storage/go.mod index cf28916a..4b4b10bc 100644 --- a/api/payments/storage/go.mod +++ b/api/payments/storage/go.mod @@ -5,6 +5,7 @@ go 1.25.7 replace github.com/tech/sendico/pkg => ../../pkg require ( + github.com/shopspring/decimal v1.4.0 github.com/tech/sendico/pkg v0.1.0 go.mongodb.org/mongo-driver/v2 v2.5.0 go.uber.org/zap v1.27.1 diff --git a/api/payments/storage/go.sum b/api/payments/storage/go.sum index a719c09c..198d8f8b 100644 --- a/api/payments/storage/go.sum +++ b/api/payments/storage/go.sum @@ -91,6 +91,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/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= 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/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= diff --git a/api/payments/orchestrator/internal/service/orchestrator/gateway_eligibility.go b/api/payments/storage/model/gateway_eligibility.go similarity index 52% rename from api/payments/orchestrator/internal/service/orchestrator/gateway_eligibility.go rename to api/payments/storage/model/gateway_eligibility.go index 1c2eb824..4d8d3dba 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/gateway_eligibility.go +++ b/api/payments/storage/model/gateway_eligibility.go @@ -1,31 +1,49 @@ -package orchestrator +package model import ( "fmt" "strings" "github.com/shopspring/decimal" - "github.com/tech/sendico/payments/storage/model" ) -type sendDirection int +type GatewayDirection int const ( - sendDirectionAny sendDirection = iota - sendDirectionOut - sendDirectionIn + GatewayDirectionAny GatewayDirection = iota + GatewayDirectionOut + GatewayDirectionIn ) -func sendDirectionForRail(rail model.Rail) sendDirection { - switch rail { - case model.RailFiatOnRamp: - return sendDirectionIn +func (d GatewayDirection) String() string { + switch d { + case GatewayDirectionOut: + return "out" + case GatewayDirectionIn: + return "in" default: - return sendDirectionOut + return "any" } } -func isGatewayEligible(gw *model.GatewayInstanceDescriptor, rail model.Rail, network, currency string, action model.RailOperation, dir sendDirection, amount decimal.Decimal) error { +func NoEligibleGatewayMessage(network, currency string, action RailOperation, dir GatewayDirection) string { + return fmt.Sprintf( + "plan builder: no eligible gateway found for %s %s %s for direction %s", + strings.ToUpper(strings.TrimSpace(network)), + strings.ToUpper(strings.TrimSpace(currency)), + ParseRailOperation(string(action)), + dir.String(), + ) +} + +func IsGatewayEligible( + gw *GatewayInstanceDescriptor, + rail Rail, + network, currency string, + action RailOperation, + dir GatewayDirection, + amount decimal.Decimal, +) error { if gw == nil { return gatewayIneligible(gw, "gateway instance is required") } @@ -51,8 +69,8 @@ func isGatewayEligible(gw *model.GatewayInstanceDescriptor, rail model.Rail, net } } - if !capabilityAllowsAction(gw.Capabilities, action, dir) { - return gatewayIneligible(gw, fmt.Sprintf("capability does not allow action=%s dir=%s", action, sendDirectionLabel(dir))) + if !gatewayAllowsAction(gw.Operations, gw.Capabilities, action, dir) { + return gatewayIneligible(gw, fmt.Sprintf("gateway does not allow action=%s dir=%s", action, dir.String())) } if currency != "" { @@ -71,49 +89,89 @@ func (e gatewayIneligibleError) Error() string { return e.reason } -func gatewayIneligible(gw *model.GatewayInstanceDescriptor, reason string) error { +func gatewayIneligible(gw *GatewayInstanceDescriptor, reason string) error { if strings.TrimSpace(reason) == "" { reason = "gateway instance is not eligible" } - return gatewayIneligibleError{reason: fmt.Sprintf("gateway %s eligibility check error: %s", gw.InstanceID, reason)} -} - -func sendDirectionLabel(dir sendDirection) string { - switch dir { - case sendDirectionOut: - return "out" - case sendDirectionIn: - return "in" - default: - return "any" + instanceID := "" + if gw != nil { + instanceID = gw.InstanceID } + return gatewayIneligibleError{reason: fmt.Sprintf("gateway %s eligibility check error: %s", instanceID, reason)} } -func capabilityAllowsAction(cap model.RailCapabilities, action model.RailOperation, dir sendDirection) bool { +func gatewayAllowsAction(operations []RailOperation, cap RailCapabilities, action RailOperation, dir GatewayDirection) bool { + normalized := NormalizeRailOperations(operations) + if len(normalized) > 0 { + return operationsAllowAction(normalized, action, dir) + } + return capabilityAllowsAction(cap, action, dir) +} + +func capabilityAllowsAction(cap RailCapabilities, action RailOperation, dir GatewayDirection) bool { switch action { - case model.RailOperationSend: + case RailOperationSend: switch dir { - case sendDirectionOut: + case GatewayDirectionOut: return cap.CanPayOut - case sendDirectionIn: + case GatewayDirectionIn: return cap.CanPayIn default: return cap.CanPayIn || cap.CanPayOut } - case model.RailOperationFee: + case RailOperationExternalDebit, RailOperationExternalCredit: + switch dir { + case GatewayDirectionOut: + return cap.CanPayOut + case GatewayDirectionIn: + return cap.CanPayIn + default: + return cap.CanPayIn || cap.CanPayOut + } + case RailOperationFee: return cap.CanSendFee - case model.RailOperationObserveConfirm: + case RailOperationObserveConfirm: return cap.RequiresObserveConfirm - case model.RailOperationBlock: + case RailOperationBlock: return cap.CanBlock - case model.RailOperationRelease: + case RailOperationRelease: return cap.CanRelease default: return true } } -func amountWithinLimits(gw *model.GatewayInstanceDescriptor, limits model.Limits, currency string, amount decimal.Decimal, action model.RailOperation) error { +func operationsAllowAction(operations []RailOperation, action RailOperation, dir GatewayDirection) bool { + action = ParseRailOperation(string(action)) + if action == RailOperationUnspecified { + return false + } + + if HasRailOperation(operations, action) { + return true + } + + switch action { + case RailOperationSend: + switch dir { + case GatewayDirectionIn: + return HasRailOperation(operations, RailOperationExternalDebit) + case GatewayDirectionOut: + return HasRailOperation(operations, RailOperationExternalCredit) + default: + return HasRailOperation(operations, RailOperationExternalDebit) || + HasRailOperation(operations, RailOperationExternalCredit) + } + case RailOperationExternalDebit: + return HasRailOperation(operations, RailOperationSend) + case RailOperationExternalCredit: + return HasRailOperation(operations, RailOperationSend) + default: + return false + } +} + +func amountWithinLimits(gw *GatewayInstanceDescriptor, limits Limits, currency string, amount decimal.Decimal, action RailOperation) error { min := firstLimitValue(limits.MinAmount, "") max := firstLimitValue(limits.MaxAmount, "") perTxMin := firstLimitValue(limits.PerTxMinAmount, "") @@ -123,7 +181,7 @@ func amountWithinLimits(gw *model.GatewayInstanceDescriptor, limits model.Limits if override, ok := limits.CurrencyLimits[currency]; ok { min = firstLimitValue(override.MinAmount, min) max = firstLimitValue(override.MaxAmount, max) - if action == model.RailOperationFee { + if action == RailOperationFee { maxFee = firstLimitValue(override.MaxFee, maxFee) } } @@ -148,7 +206,7 @@ func amountWithinLimits(gw *model.GatewayInstanceDescriptor, limits model.Limits return gatewayIneligible(gw, fmt.Sprintf("amount %s %s exceeds per-tx max limit %s", amount.String(), currency, val.String())) } } - if action == model.RailOperationFee && maxFee != "" { + if action == RailOperationFee && maxFee != "" { if val, err := decimal.NewFromString(maxFee); err == nil && amount.GreaterThan(val) { return gatewayIneligible(gw, fmt.Sprintf("fee amount %s %s exceeds max fee limit %s", amount.String(), currency, val.String())) } @@ -164,21 +222,3 @@ func firstLimitValue(primary, fallback string) string { } return strings.TrimSpace(fallback) } - -func parseRailValue(value string) model.Rail { - val := strings.ToUpper(strings.TrimSpace(value)) - switch val { - case string(model.RailCrypto): - return model.RailCrypto - case string(model.RailProviderSettlement): - return model.RailProviderSettlement - case string(model.RailLedger): - return model.RailLedger - case string(model.RailCardPayout): - return model.RailCardPayout - case string(model.RailFiatOnRamp): - return model.RailFiatOnRamp - default: - return model.RailUnspecified - } -} diff --git a/api/payments/storage/model/gateway_eligibility_test.go b/api/payments/storage/model/gateway_eligibility_test.go new file mode 100644 index 00000000..1cfd432d --- /dev/null +++ b/api/payments/storage/model/gateway_eligibility_test.go @@ -0,0 +1,49 @@ +package model + +import ( + "testing" + + "github.com/shopspring/decimal" +) + +func TestIsGatewayEligible_AllowsMatchingGateway(t *testing.T) { + gw := &GatewayInstanceDescriptor{ + ID: "gw-1", + InstanceID: "inst-1", + Rail: RailCrypto, + Network: "TRON", + Currencies: []string{"USDT"}, + Operations: []RailOperation{RailOperationSend, RailOperationExternalCredit}, + IsEnabled: true, + } + + err := IsGatewayEligible(gw, RailCrypto, "TRON", "USDT", RailOperationSend, GatewayDirectionOut, decimal.RequireFromString("10")) + if err != nil { + t.Fatalf("expected gateway to be eligible, got err=%v", err) + } +} + +func TestIsGatewayEligible_RejectsNetworkMismatch(t *testing.T) { + gw := &GatewayInstanceDescriptor{ + ID: "gw-1", + InstanceID: "inst-1", + Rail: RailCrypto, + Network: "ETH", + Currencies: []string{"USDT"}, + Operations: []RailOperation{RailOperationSend}, + IsEnabled: true, + } + + err := IsGatewayEligible(gw, RailCrypto, "TRON", "USDT", RailOperationSend, GatewayDirectionOut, decimal.RequireFromString("10")) + if err == nil { + t.Fatalf("expected network mismatch error") + } +} + +func TestNoEligibleGatewayMessage(t *testing.T) { + got := NoEligibleGatewayMessage("tron", "usdt", RailOperationSend, GatewayDirectionOut) + want := "plan builder: no eligible gateway found for TRON USDT SEND for direction out" + if got != want { + t.Fatalf("unexpected message: got=%q want=%q", got, want) + } +} diff --git a/api/payments/storage/model/payment.go b/api/payments/storage/model/payment.go index b21b55cf..0837e488 100644 --- a/api/payments/storage/model/payment.go +++ b/api/payments/storage/model/payment.go @@ -139,6 +139,7 @@ type GatewayInstanceDescriptor struct { Network string `bson:"network,omitempty" json:"network,omitempty"` InvokeURI string `bson:"invokeUri,omitempty" json:"invokeUri,omitempty"` Currencies []string `bson:"currencies,omitempty" json:"currencies,omitempty"` + Operations []RailOperation `bson:"operations,omitempty" json:"operations,omitempty"` Capabilities RailCapabilities `bson:"capabilities,omitempty" json:"capabilities,omitempty"` Limits Limits `bson:"limits,omitempty" json:"limits,omitempty"` Version string `bson:"version,omitempty" json:"version,omitempty"` @@ -305,18 +306,18 @@ type PaymentPlan struct { // ExecutionStep describes a planned or executed payment step for reporting. type ExecutionStep struct { - Code string `bson:"code,omitempty" json:"code,omitempty"` - Description string `bson:"description,omitempty" json:"description,omitempty"` - Amount *paymenttypes.Money `bson:"amount,omitempty" json:"amount,omitempty"` - NetworkFee *paymenttypes.Money `bson:"networkFee,omitempty" json:"networkFee,omitempty"` - SourceWalletRef string `bson:"sourceWalletRef,omitempty" json:"sourceWalletRef,omitempty"` - DestinationRef string `bson:"destinationRef,omitempty" json:"destinationRef,omitempty"` - TransferRef string `bson:"transferRef,omitempty" json:"transferRef,omitempty"` - OperationRef string `bson:"operationRef,omitempty" json:"operationRef,omitempty"` - ReportVisibility ReportVisibility `bson:"reportVisibility,omitempty" json:"reportVisibility,omitempty"` - Error string `bson:"error,omitempty" json:"error,omitempty"` - State OperationState `bson:"state,omitempty" json:"state,omitempty"` - Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"` + Code string `bson:"code,omitempty" json:"code,omitempty"` + Description string `bson:"description,omitempty" json:"description,omitempty"` + Amount *paymenttypes.Money `bson:"amount,omitempty" json:"amount,omitempty"` + NetworkFee *paymenttypes.Money `bson:"networkFee,omitempty" json:"networkFee,omitempty"` + SourceWalletRef string `bson:"sourceWalletRef,omitempty" json:"sourceWalletRef,omitempty"` + DestinationRef string `bson:"destinationRef,omitempty" json:"destinationRef,omitempty"` + TransferRef string `bson:"transferRef,omitempty" json:"transferRef,omitempty"` + OperationRef string `bson:"operationRef,omitempty" json:"operationRef,omitempty"` + ReportVisibility ReportVisibility `bson:"reportVisibility,omitempty" json:"reportVisibility,omitempty"` + Error string `bson:"error,omitempty" json:"error,omitempty"` + State OperationState `bson:"state,omitempty" json:"state,omitempty"` + Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"` } func (s *ExecutionStep) IsTerminal() bool { diff --git a/api/payments/storage/model/rail_operations.go b/api/payments/storage/model/rail_operations.go new file mode 100644 index 00000000..8a156559 --- /dev/null +++ b/api/payments/storage/model/rail_operations.go @@ -0,0 +1,93 @@ +package model + +import "strings" + +var supportedRailOperations = map[RailOperation]struct{}{ + RailOperationDebit: {}, + RailOperationCredit: {}, + RailOperationExternalDebit: {}, + RailOperationExternalCredit: {}, + RailOperationMove: {}, + RailOperationSend: {}, + RailOperationFee: {}, + RailOperationObserveConfirm: {}, + RailOperationFXConvert: {}, + RailOperationBlock: {}, + RailOperationRelease: {}, +} + +// ParseRailOperation canonicalizes string values into a RailOperation token. +func ParseRailOperation(value string) RailOperation { + clean := strings.ToUpper(strings.TrimSpace(value)) + if clean == "" { + return RailOperationUnspecified + } + return RailOperation(clean) +} + +// IsSupportedRailOperation reports whether op is recognized by payment planning. +func IsSupportedRailOperation(op RailOperation) bool { + _, ok := supportedRailOperations[ParseRailOperation(string(op))] + return ok +} + +// NormalizeRailOperations trims, uppercases, deduplicates, and filters unknown values. +func NormalizeRailOperations(values []RailOperation) []RailOperation { + if len(values) == 0 { + return nil + } + result := make([]RailOperation, 0, len(values)) + seen := map[RailOperation]bool{} + for _, value := range values { + op := ParseRailOperation(string(value)) + if op == RailOperationUnspecified || !IsSupportedRailOperation(op) || seen[op] { + continue + } + seen[op] = true + result = append(result, op) + } + if len(result) == 0 { + return nil + } + return result +} + +// NormalizeRailOperationStrings normalizes string operation values. +func NormalizeRailOperationStrings(values []string) []RailOperation { + if len(values) == 0 { + return nil + } + ops := make([]RailOperation, 0, len(values)) + for _, value := range values { + ops = append(ops, ParseRailOperation(value)) + } + return NormalizeRailOperations(ops) +} + +// HasRailOperation checks whether ops includes action. +func HasRailOperation(ops []RailOperation, action RailOperation) bool { + want := ParseRailOperation(string(action)) + if want == RailOperationUnspecified { + return false + } + for _, op := range ops { + if ParseRailOperation(string(op)) == want { + return true + } + } + return false +} + +// RailCapabilitiesFromOperations derives legacy capability flags from explicit operations. +func RailCapabilitiesFromOperations(ops []RailOperation) RailCapabilities { + normalized := NormalizeRailOperations(ops) + return RailCapabilities{ + CanPayIn: HasRailOperation(normalized, RailOperationExternalDebit), + CanPayOut: HasRailOperation(normalized, RailOperationSend) || HasRailOperation(normalized, RailOperationExternalCredit), + CanReadBalance: false, + CanSendFee: HasRailOperation(normalized, RailOperationFee), + RequiresObserveConfirm: HasRailOperation(normalized, RailOperationObserveConfirm), + CanBlock: HasRailOperation(normalized, RailOperationBlock), + CanRelease: HasRailOperation(normalized, RailOperationRelease), + } +} diff --git a/api/payments/storage/model/rail_operations_test.go b/api/payments/storage/model/rail_operations_test.go new file mode 100644 index 00000000..aca5f732 --- /dev/null +++ b/api/payments/storage/model/rail_operations_test.go @@ -0,0 +1,65 @@ +package model + +import "testing" + +func TestNormalizeRailOperations(t *testing.T) { + ops := NormalizeRailOperations([]RailOperation{ + "send", + "SEND", + " external_credit ", + "unknown", + "", + }) + if len(ops) != 2 { + t.Fatalf("unexpected operations count: got=%d want=2", len(ops)) + } + if ops[0] != RailOperationSend { + t.Fatalf("unexpected first operation: got=%q want=%q", ops[0], RailOperationSend) + } + if ops[1] != RailOperationExternalCredit { + t.Fatalf("unexpected second operation: got=%q want=%q", ops[1], RailOperationExternalCredit) + } +} + +func TestHasRailOperation(t *testing.T) { + ops := []RailOperation{RailOperationSend, RailOperationExternalCredit} + if !HasRailOperation(ops, RailOperationSend) { + t.Fatalf("expected send operation to be present") + } + if !HasRailOperation(ops, " external_credit ") { + t.Fatalf("expected external credit operation to be present") + } + if HasRailOperation(ops, RailOperationObserveConfirm) { + t.Fatalf("did not expect observe confirm operation to be present") + } +} + +func TestRailCapabilitiesFromOperations(t *testing.T) { + cap := RailCapabilitiesFromOperations([]RailOperation{ + RailOperationExternalDebit, + RailOperationExternalCredit, + RailOperationFee, + RailOperationObserveConfirm, + RailOperationBlock, + RailOperationRelease, + }) + + if !cap.CanPayIn { + t.Fatalf("expected can pay in to be true") + } + if !cap.CanPayOut { + t.Fatalf("expected can pay out to be true") + } + if !cap.CanSendFee { + t.Fatalf("expected can send fee to be true") + } + if !cap.RequiresObserveConfirm { + t.Fatalf("expected requires observe confirm to be true") + } + if !cap.CanBlock { + t.Fatalf("expected can block to be true") + } + if !cap.CanRelease { + t.Fatalf("expected can release to be true") + } +} diff --git a/api/payments/storage/mongo/repository.go b/api/payments/storage/mongo/repository.go index ebc68cbd..d9c1cd74 100644 --- a/api/payments/storage/mongo/repository.go +++ b/api/payments/storage/mongo/repository.go @@ -16,6 +16,7 @@ import ( "github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/mservice" "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" ) // Store implements storage.Repository backed by MongoDB. @@ -23,6 +24,7 @@ type Store struct { logger mlogger.Logger ping func(context.Context) error + database *mongo.Database payments storage.PaymentsStore methods storage.PaymentMethodsStore quotes quotestorage.QuotesStore @@ -71,17 +73,18 @@ func New(logger mlogger.Logger, conn *db.MongoConnection, opts ...Option) (*Stor plansRepo := repository.CreateMongoRepository(conn.Database(), (&model.PaymentPlanTemplate{}).Collection()) methodsRepo := repository.CreateMongoRepository(conn.Database(), mservice.PaymentMethods) - return newWithRepository(logger, conn.Ping, paymentsRepo, methodsRepo, quotesRepo, routesRepo, plansRepo, opts...) + return newWithRepository(logger, conn.Ping, conn.Database(), paymentsRepo, methodsRepo, quotesRepo, routesRepo, plansRepo, opts...) } // NewWithRepository constructs a payments repository using the provided primitives. func NewWithRepository(logger mlogger.Logger, ping func(context.Context) error, paymentsRepo repository.Repository, quotesRepo repository.Repository, routesRepo repository.Repository, plansRepo repository.Repository, opts ...Option) (*Store, error) { - return newWithRepository(logger, ping, paymentsRepo, nil, quotesRepo, routesRepo, plansRepo, opts...) + return newWithRepository(logger, ping, nil, paymentsRepo, nil, quotesRepo, routesRepo, plansRepo, opts...) } func newWithRepository( logger mlogger.Logger, ping func(context.Context) error, + database *mongo.Database, paymentsRepo, methodsRepo, quotesRepo, routesRepo, plansRepo repository.Repository, opts ...Option, ) (*Store, error) { @@ -147,6 +150,7 @@ func newWithRepository( result := &Store{ logger: childLogger, ping: ping, + database: database, payments: paymentsStore, methods: methodsStore, quotes: quotesRepoStore.Quotes(), @@ -190,4 +194,12 @@ func (s *Store) PlanTemplates() storage.PlanTemplatesStore { return s.plans } +// MongoDatabase returns underlying Mongo database when available. +func (s *Store) MongoDatabase() *mongo.Database { + if s == nil { + return nil + } + return s.database +} + var _ storage.Repository = (*Store)(nil) diff --git a/api/pkg/discovery/rail_vocab.go b/api/pkg/discovery/rail_vocab.go new file mode 100644 index 00000000..61163cd7 --- /dev/null +++ b/api/pkg/discovery/rail_vocab.go @@ -0,0 +1,142 @@ +package discovery + +import "strings" + +const ( + RailCrypto = "CRYPTO" + RailProviderSettlement = "PROVIDER_SETTLEMENT" + RailLedger = "LEDGER" + RailCardPayout = "CARD_PAYOUT" + RailFiatOnRamp = "FIAT_ONRAMP" +) + +const ( + RailOperationDebit = "DEBIT" + RailOperationCredit = "CREDIT" + RailOperationExternalDebit = "EXTERNAL_DEBIT" + RailOperationExternalCredit = "EXTERNAL_CREDIT" + RailOperationMove = "MOVE" + RailOperationSend = "SEND" + RailOperationFee = "FEE" + RailOperationObserveConfirm = "OBSERVE_CONFIRM" + RailOperationFXConvert = "FX_CONVERT" + RailOperationBlock = "BLOCK" + RailOperationRelease = "RELEASE" +) + +var knownRails = map[string]struct{}{ + RailCrypto: {}, + RailProviderSettlement: {}, + RailLedger: {}, + RailCardPayout: {}, + RailFiatOnRamp: {}, +} + +var knownRailOperations = map[string]struct{}{ + RailOperationDebit: {}, + RailOperationCredit: {}, + RailOperationExternalDebit: {}, + RailOperationExternalCredit: {}, + RailOperationMove: {}, + RailOperationSend: {}, + RailOperationFee: {}, + RailOperationObserveConfirm: {}, + RailOperationFXConvert: {}, + RailOperationBlock: {}, + RailOperationRelease: {}, +} + +// NormalizeRail canonicalizes a rail token. +func NormalizeRail(value string) string { + return strings.ToUpper(strings.TrimSpace(value)) +} + +// IsKnownRail reports whether the value is a recognized payment rail. +func IsKnownRail(value string) bool { + _, ok := knownRails[NormalizeRail(value)] + return ok +} + +// NormalizeRailOperation canonicalizes a rail operation token. +func NormalizeRailOperation(value string) string { + clean := strings.ToUpper(strings.TrimSpace(value)) + if strings.HasPrefix(clean, "RAIL_OPERATION_") { + clean = strings.TrimPrefix(clean, "RAIL_OPERATION_") + } + return clean +} + +// IsKnownRailOperation reports whether the value is a recognized rail operation. +func IsKnownRailOperation(value string) bool { + _, ok := knownRailOperations[NormalizeRailOperation(value)] + return ok +} + +// ExpandRailOperation maps canonical and legacy names to normalized rail operations. +func ExpandRailOperation(value string) []string { + if op := NormalizeRailOperation(value); op != "" { + if IsKnownRailOperation(op) { + return []string{op} + } + } + + switch strings.ToLower(strings.TrimSpace(value)) { + case "payin", "payin.crypto", "payin.fiat", "payin.card": + return []string{RailOperationExternalDebit} + case "payout", "payout.crypto", "payout.fiat", "payout.card": + return []string{RailOperationExternalCredit, RailOperationSend} + case "fee.send", "fees.send": + return []string{RailOperationFee} + case "observe.confirm", "observe_confirm": + return []string{RailOperationObserveConfirm} + case "funds.block", "hold.balance", "block": + return []string{RailOperationBlock} + case "funds.release", "release", "unblock": + return []string{RailOperationRelease} + default: + return nil + } +} + +// NormalizeRailOperations canonicalizes and deduplicates rail operation values. +func NormalizeRailOperations(values []string) []string { + if len(values) == 0 { + return nil + } + + result := make([]string, 0, len(values)) + seen := map[string]bool{} + for _, value := range values { + for _, op := range ExpandRailOperation(value) { + if op == "" || seen[op] { + continue + } + seen[op] = true + result = append(result, op) + } + } + if len(result) == 0 { + return nil + } + return result +} + +// CryptoRailGatewayOperations returns canonical operations for crypto rail gateways. +func CryptoRailGatewayOperations() []string { + return []string{ + RailOperationSend, + RailOperationExternalDebit, + RailOperationExternalCredit, + RailOperationFee, + RailOperationObserveConfirm, + } +} + +// CardPayoutRailGatewayOperations returns canonical operations for card payout gateways. +func CardPayoutRailGatewayOperations() []string { + return []string{ + RailOperationSend, + RailOperationExternalCredit, + RailOperationObserveConfirm, + } +} diff --git a/api/pkg/discovery/rail_vocab_test.go b/api/pkg/discovery/rail_vocab_test.go new file mode 100644 index 00000000..8c5e7d26 --- /dev/null +++ b/api/pkg/discovery/rail_vocab_test.go @@ -0,0 +1,49 @@ +package discovery + +import "testing" + +func TestNormalizeRailOperations(t *testing.T) { + got := NormalizeRailOperations([]string{ + "send", + "payout.crypto", + "observe.confirm", + "unknown", + "EXTERNAL_CREDIT", + }) + + want := []string{ + RailOperationSend, + RailOperationExternalCredit, + RailOperationObserveConfirm, + } + if len(got) != len(want) { + t.Fatalf("unexpected operations count: got=%d want=%d", len(got), len(want)) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("unexpected operation[%d]: got=%q want=%q", i, got[i], want[i]) + } + } +} + +func TestExpandRailOperationLegacyAliases(t *testing.T) { + got := ExpandRailOperation("payout.fiat") + if len(got) != 2 { + t.Fatalf("unexpected operations count: got=%d want=2", len(got)) + } + if got[0] != RailOperationExternalCredit { + t.Fatalf("unexpected first operation: got=%q want=%q", got[0], RailOperationExternalCredit) + } + if got[1] != RailOperationSend { + t.Fatalf("unexpected second operation: got=%q want=%q", got[1], RailOperationSend) + } +} + +func TestIsKnownRail(t *testing.T) { + if !IsKnownRail("crypto") { + t.Fatalf("expected crypto rail to be known") + } + if IsKnownRail("telegram") { + t.Fatalf("did not expect telegram rail to be known") + } +} diff --git a/api/pkg/discovery/registry.go b/api/pkg/discovery/registry.go index e4e60281..921c7f2e 100644 --- a/api/pkg/discovery/registry.go +++ b/api/pkg/discovery/registry.go @@ -229,7 +229,7 @@ func normalizeEntry(entry RegistryEntry) RegistryEntry { entry.InstanceID = entry.ID } entry.Service = strings.TrimSpace(entry.Service) - entry.Rail = strings.ToUpper(strings.TrimSpace(entry.Rail)) + entry.Rail = NormalizeRail(entry.Rail) entry.Network = strings.ToUpper(strings.TrimSpace(entry.Network)) entry.Operations = normalizeStrings(entry.Operations, false) entry.CurrencyMeta = normalizeCurrencyAnnouncements(entry.CurrencyMeta) @@ -259,7 +259,7 @@ func normalizeAnnouncement(announce Announcement) Announcement { announce.InstanceID = announce.ID } announce.Service = strings.TrimSpace(announce.Service) - announce.Rail = strings.ToUpper(strings.TrimSpace(announce.Rail)) + announce.Rail = NormalizeRail(announce.Rail) announce.Operations = normalizeStrings(announce.Operations, false) announce.Currencies = normalizeCurrencyAnnouncements(announce.Currencies) announce.InvokeURI = strings.TrimSpace(announce.InvokeURI) diff --git a/api/pkg/go.mod b/api/pkg/go.mod index 547bd1a2..39a0374a 100644 --- a/api/pkg/go.mod +++ b/api/pkg/go.mod @@ -9,7 +9,7 @@ require ( github.com/google/uuid v1.6.0 github.com/mattn/go-colorable v0.1.14 github.com/mitchellh/mapstructure v1.5.0 - github.com/nats-io/nats.go v1.48.0 + github.com/nats-io/nats.go v1.49.0 github.com/prometheus/client_golang v1.23.2 github.com/stretchr/testify v1.11.1 github.com/testcontainers/testcontainers-go v0.33.0 @@ -92,6 +92,6 @@ require ( golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect golang.org/x/time v0.5.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 gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/api/pkg/go.sum b/api/pkg/go.sum index 9e50f44e..4e72b695 100644 --- a/api/pkg/go.sum +++ b/api/pkg/go.sum @@ -106,8 +106,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/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/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U= -github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= +github.com/nats-io/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE= +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/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs= github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= @@ -271,8 +271,8 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= -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-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +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-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/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/pkg/messaging/internal/natsb/broker.go b/api/pkg/messaging/internal/natsb/broker.go index 4ea7fef2..daa7a7d2 100644 --- a/api/pkg/messaging/internal/natsb/broker.go +++ b/api/pkg/messaging/internal/natsb/broker.go @@ -20,9 +20,9 @@ import ( type natsSubscriotions = map[string]*TopicSubscription type NatsBroker struct { + logger mlogger.Logger nc *nats.Conn js nats.JetStreamContext - logger *zap.Logger topicSubs natsSubscriotions mu sync.Mutex bufferSize int @@ -73,7 +73,7 @@ func sanitizeNATSURL(rawURL string) string { // loadEnv gathers and validates connection details from environment variables // listed in the Settings struct. Invalid or missing values surface as a typed // InvalidArgument error so callers can decide how to handle them. -func loadEnv(settings *nc.Settings, l *zap.Logger) (*envConfig, error) { +func loadEnv(settings *nc.Settings, l mlogger.Logger) (*envConfig, error) { get := func(key, label string) (string, error) { if v := os.Getenv(key); v != "" { return v, nil diff --git a/api/pkg/server/grpcapp/app.go b/api/pkg/server/grpcapp/app.go index d40bb606..bdd12d73 100644 --- a/api/pkg/server/grpcapp/app.go +++ b/api/pkg/server/grpcapp/app.go @@ -3,7 +3,6 @@ package grpcapp import ( "context" "errors" - "fmt" "net/http" "sync" "time" @@ -203,16 +202,16 @@ func (a *App[T]) Start() error { } if addr := a.grpc.Addr(); addr != nil { - a.logger.Info(fmt.Sprintf("%s gRPC server started", a.name), zap.String("network", addr.Network()), zap.String("address", addr.String()), zap.Bool("debug_mode", a.debug)) + a.logger.Info("Server started", zap.String("server_name", a.name), zap.String("network", addr.Network()), zap.String("address", addr.String()), zap.Bool("debug_mode", a.debug)) } else { - a.logger.Info(fmt.Sprintf("%s gRPC server started", a.name), zap.Bool("debug_mode", a.debug)) + a.logger.Info("Server started", zap.String("server_name", a.name), zap.Bool("debug_mode", a.debug)) } err = <-a.grpc.Done() if err != nil && !errors.Is(err, context.Canceled) { - a.logger.Error("GRPC server stopped with error", zap.Error(err)) + a.logger.Error("Server stopped with error", zap.Error(err)) } else { - a.logger.Info("GRPC server finished") + a.logger.Info("Server finished") } a.cleanup(context.Background()) diff --git a/api/proto/payments/orchestration/v1/orchestration.proto b/api/proto/payments/orchestration/v1/orchestration.proto deleted file mode 100644 index 38e94bdf..00000000 --- a/api/proto/payments/orchestration/v1/orchestration.proto +++ /dev/null @@ -1,155 +0,0 @@ -syntax = "proto3"; - -package payments.orchestration.v1; - -option go_package = "github.com/tech/sendico/pkg/proto/payments/orchestration/v1;orchestrationv1"; - -import "api/proto/common/pagination/v1/cursor.proto"; -import "api/proto/billing/fees/v1/fees.proto"; -import "api/proto/gateway/chain/v1/chain.proto"; -import "api/proto/gateway/mntx/v1/mntx.proto"; -import "api/proto/payments/shared/v1/shared.proto"; - -// InitiatePaymentsRequest triggers execution of all payment intents within -// a previously accepted quote. -message InitiatePaymentsRequest { - payments.shared.v1.RequestMeta meta = 1; - string idempotency_key = 2; - string quote_ref = 3; - map metadata = 4; -} - -// InitiatePaymentsResponse returns the created payments. -message InitiatePaymentsResponse { - repeated payments.shared.v1.Payment payments = 1; -} - -// InitiatePaymentRequest creates a single payment from a standalone intent. -message InitiatePaymentRequest { - payments.shared.v1.RequestMeta meta = 1; - string idempotency_key = 2; - payments.shared.v1.PaymentIntent intent = 3; - map metadata = 4; - string quote_ref = 5; -} - -// InitiatePaymentResponse returns the created payment. -message InitiatePaymentResponse { - payments.shared.v1.Payment payment = 1; -} - -// GetPaymentRequest fetches a payment by its reference. -message GetPaymentRequest { - payments.shared.v1.RequestMeta meta = 1; - string payment_ref = 2; -} - -// GetPaymentResponse returns the requested payment. -message GetPaymentResponse { - payments.shared.v1.Payment payment = 1; -} - -// ListPaymentsRequest queries payments with optional state and endpoint filters. -message ListPaymentsRequest { - payments.shared.v1.RequestMeta meta = 1; - repeated payments.shared.v1.PaymentState filter_states = 2; - string source_ref = 3; - string destination_ref = 4; - common.pagination.v1.CursorPageRequest page = 5; - string organization_ref = 6; -} - -// ListPaymentsResponse returns a page of matching payments. -message ListPaymentsResponse { - repeated payments.shared.v1.Payment payments = 1; - common.pagination.v1.CursorPageResponse page = 2; -} - -// CancelPaymentRequest requests cancellation of a payment that has not yet -// been settled. -message CancelPaymentRequest { - payments.shared.v1.RequestMeta meta = 1; - string payment_ref = 2; - string reason = 3; -} - -// CancelPaymentResponse returns the updated payment after cancellation. -message CancelPaymentResponse { - payments.shared.v1.Payment payment = 1; -} - -// ProcessTransferUpdateRequest handles a blockchain transfer status change -// event from the chain gateway. -message ProcessTransferUpdateRequest { - payments.shared.v1.RequestMeta meta = 1; - chain.gateway.v1.TransferStatusChangedEvent event = 2; -} - -// ProcessTransferUpdateResponse returns the payment after processing. -message ProcessTransferUpdateResponse { - payments.shared.v1.Payment payment = 1; -} - -// ProcessDepositObservedRequest handles a wallet deposit observation event -// from the chain gateway. -message ProcessDepositObservedRequest { - payments.shared.v1.RequestMeta meta = 1; - chain.gateway.v1.WalletDepositObservedEvent event = 2; -} - -// ProcessDepositObservedResponse returns the payment after processing. -message ProcessDepositObservedResponse { - payments.shared.v1.Payment payment = 1; -} - -// ProcessCardPayoutUpdateRequest handles a card payout status change event -// from the card gateway. -message ProcessCardPayoutUpdateRequest { - payments.shared.v1.RequestMeta meta = 1; - mntx.gateway.v1.CardPayoutStatusChangedEvent event = 2; -} - -// ProcessCardPayoutUpdateResponse returns the payment after processing. -message ProcessCardPayoutUpdateResponse { - payments.shared.v1.Payment payment = 1; -} - -// InitiateConversionRequest creates an FX conversion payment between two -// ledger endpoints. -message InitiateConversionRequest { - payments.shared.v1.RequestMeta meta = 1; - string idempotency_key = 2; - payments.shared.v1.PaymentEndpoint source = 3; - payments.shared.v1.PaymentEndpoint destination = 4; - payments.shared.v1.FXIntent fx = 5; - fees.v1.PolicyOverrides fee_policy = 6; - map metadata = 7; -} - -// InitiateConversionResponse returns the created conversion payment. -message InitiateConversionResponse { - payments.shared.v1.Payment conversion = 1; -} - -// PaymentExecutionService orchestrates payment lifecycle operations across -// ledger, blockchain, card, and FX rails. -service PaymentExecutionService { - // InitiatePayments executes all intents within a quote. - rpc InitiatePayments(InitiatePaymentsRequest) returns (InitiatePaymentsResponse); - // InitiatePayment creates and executes a single payment. - rpc InitiatePayment(InitiatePaymentRequest) returns (InitiatePaymentResponse); - // CancelPayment cancels a pending payment. - rpc CancelPayment(CancelPaymentRequest) returns (CancelPaymentResponse); - // GetPayment retrieves a payment by reference. - rpc GetPayment(GetPaymentRequest) returns (GetPaymentResponse); - // ListPayments queries payments with filters and pagination. - rpc ListPayments(ListPaymentsRequest) returns (ListPaymentsResponse); - // InitiateConversion creates an FX conversion payment. - rpc InitiateConversion(InitiateConversionRequest) returns (InitiateConversionResponse); - // ProcessTransferUpdate handles blockchain transfer status callbacks. - rpc ProcessTransferUpdate(ProcessTransferUpdateRequest) returns (ProcessTransferUpdateResponse); - // ProcessDepositObserved handles deposit observation callbacks. - rpc ProcessDepositObserved(ProcessDepositObservedRequest) returns (ProcessDepositObservedResponse); - // ProcessCardPayoutUpdate handles card payout status callbacks. - rpc ProcessCardPayoutUpdate(ProcessCardPayoutUpdateRequest) returns (ProcessCardPayoutUpdateResponse); -} diff --git a/api/proto/payments/quotation/v1/quotation.proto b/api/proto/payments/quotation/v1/quotation.proto deleted file mode 100644 index 20c8d9cb..00000000 --- a/api/proto/payments/quotation/v1/quotation.proto +++ /dev/null @@ -1,47 +0,0 @@ -syntax = "proto3"; - -package payments.quotation.v1; - -option go_package = "github.com/tech/sendico/pkg/proto/payments/quotation/v1;quotationv1"; - -import "api/proto/payments/shared/v1/shared.proto"; - -// QuotePaymentRequest is the request to quote a single payment. -message QuotePaymentRequest { - payments.shared.v1.RequestMeta meta = 1; - string idempotency_key = 2; - payments.shared.v1.PaymentIntent intent = 3; - bool preview_only = 4; -} - -// QuotePaymentResponse is the response for QuotePayment. -message QuotePaymentResponse { - payments.shared.v1.PaymentQuote quote = 1; - string idempotency_key = 2; - // Non-empty when quote is valid for pricing but cannot be executed. - string execution_note = 3; -} - -// QuotePaymentsRequest is the request to quote multiple payments in a batch. -message QuotePaymentsRequest { - payments.shared.v1.RequestMeta meta = 1; - string idempotency_key = 2; - repeated payments.shared.v1.PaymentIntent intents = 3; - bool preview_only = 4; -} - -// QuotePaymentsResponse is the response for QuotePayments. -message QuotePaymentsResponse { - string quote_ref = 1; - payments.shared.v1.PaymentQuoteAggregate aggregate = 2; - repeated payments.shared.v1.PaymentQuote quotes = 3; - string idempotency_key = 4; -} - -// QuotationService provides payment quoting capabilities. -service QuotationService { - // QuotePayment returns a quote for a single payment request. - rpc QuotePayment(QuotePaymentRequest) returns (QuotePaymentResponse); - // QuotePayments returns quotes for multiple payment requests. - rpc QuotePayments(QuotePaymentsRequest) returns (QuotePaymentsResponse); -} diff --git a/api/server/go.mod b/api/server/go.mod index 0eae9b59..eb24f4a1 100644 --- a/api/server/go.mod +++ b/api/server/go.mod @@ -15,10 +15,10 @@ replace github.com/tech/sendico/payments/storage => ../payments/storage replace github.com/tech/sendico/gateway/tron => ../gateway/tron require ( - github.com/aws/aws-sdk-go-v2 v1.41.1 - github.com/aws/aws-sdk-go-v2/config v1.32.9 - github.com/aws/aws-sdk-go-v2/credentials v1.19.9 - github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0 + github.com/aws/aws-sdk-go-v2 v1.41.2 + github.com/aws/aws-sdk-go-v2/config v1.32.10 + github.com/aws/aws-sdk-go-v2/credentials v1.19.10 + github.com/aws/aws-sdk-go-v2/service/s3 v1.96.1 github.com/go-chi/chi/v5 v5.2.5 github.com/go-chi/cors v1.2.2 github.com/go-chi/jwtauth/v5 v5.3.3 @@ -53,21 +53,21 @@ require ( dario.cat/mergo v1.0.1 // indirect github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect - github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 // 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.18 // 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.18 // 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/service/internal/accept-encoding v1.13.4 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 // 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/s3shared v1.19.17 // indirect - github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.30.10 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect - github.com/aws/smithy-go v1.24.0 // 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.5 // 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.18 // 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.6 // 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.15 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.41.7 // indirect + github.com/aws/smithy-go v1.24.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/casbin/mongodb-adapter/v4 v4.3.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect @@ -76,7 +76,7 @@ require ( github.com/containerd/platforms v0.2.1 // indirect github.com/cpuguy83/dockercfg v0.3.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect - github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/docker v27.3.1+incompatible // indirect github.com/docker/go-connections v0.5.0 // indirect @@ -107,7 +107,7 @@ require ( github.com/moby/term v0.5.0 // indirect github.com/morikuni/aec v1.0.0 // 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/nuid v1.0.1 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect @@ -144,5 +144,5 @@ require ( golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.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 ) diff --git a/api/server/go.sum b/api/server/go.sum index 75928d9a..af103f5f 100644 --- a/api/server/go.sum +++ b/api/server/go.sum @@ -6,44 +6,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/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= 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.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= -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.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4= -github.com/aws/aws-sdk-go-v2/config v1.32.9 h1:ktda/mtAydeObvJXlHzyGpK1xcsLaP16zfUPDGoW90A= -github.com/aws/aws-sdk-go-v2/config v1.32.9/go.mod h1:U+fCQ+9QKsLW786BCfEjYRj34VVTbPdsLP3CHSYXMOI= -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.9/go.mod h1:+J44MBhmfVY/lETFiKI+klz0Vym2aCmIjqgClMmW82w= -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.17/go.mod h1:tyw7BOl5bBe/oqvoIeECFJjMdzXoa/dfVz3QQ5lgHGA= -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.17/go.mod h1:5M5CI3D12dNOtH3/mk6minaRwI2/37ifCURZISxA/IQ= -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.17/go.mod h1:EhG22vHRrvF8oXSTYStZhJc1aUgKtnJe+aOiFEV90cM= +github.com/aws/aws-sdk-go-v2 v1.41.2 h1:LuT2rzqNQsauaGkPK/7813XxcZ3o3yePY0Iy891T2ls= +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.5 h1:zWFmPmgw4sveAYi1mRqG+E/g0461cJ5M4bJ8/nc6d3Q= +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.10 h1:9DMthfO6XWZYLfzZglAgW5Fyou2nRI5CuV44sTedKBI= +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.10 h1:EEhmEUFCE1Yhl7vDhNOI5OCL/iKMdkkYFTRpZXNw7m8= +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.18 h1:Ii4s+Sq3yDfaMLpjrJsqD6SmG/Wq/P5L/hw2qa78UAY= +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.18 h1:F43zk1vemYIqPAwhjTjYIz0irU2EY7sOb/F5eJ3HuyM= +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.18 h1:xCeWVjj0ki0l3nruoyP2slHsGArMxeiiaoPN5QZH6YQ= +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/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.17/go.mod h1:CO+WeGmIdj/MlPel2KwID9Gt7CNq4M65HUfBW97liM0= -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.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow= -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.8/go.mod h1:FsTpJtvC4U1fyDXk7c71XoDv3HlRm8V3NiYLeYLh5YE= -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.17/go.mod h1:F2xxQ9TZz5gDWsclCtPQscGpP0VUOc8RqgFM3vDENmU= -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.17/go.mod h1:dcW24lbU0CzHusTE8LLHhRLI42ejmINN8Lcr22bwh/g= -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.0/go.mod h1:5jggDlZ2CLQhwJBiZJb4vfk4f0GxWdEDruWKEJ1xOdo= -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.5/go.mod h1:k029+U8SY30/3/ras4G/Fnv/b88N4mAfliNn08Dem4M= -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.10/go.mod h1:yifAsgBxgJWn3ggx70A3urX2AN49Y5sJTD1UQFlfqBw= -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.14/go.mod h1:sTGThjphYE4Ohw8vJiRStAcu3rbjtXRsdNB0TvZ5wwo= -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.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ= -github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= -github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= +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.18/go.mod h1:CCXwUKAJdoWr6/NcxZ+zsiPr6oH/Q5aTooRGYieAyj4= +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.5/go.mod h1:AZLZf2fMaahW5s/wMRciu1sYbdsikT/UHwbUjOdEVTc= +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.9/go.mod h1:Kzm5e6OmNH8VMkgK9t+ry5jEih4Y8whqs+1hrkxim1I= +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.18/go.mod h1:XhwkgGG6bHSd00nO/mexWTcTjgd6PjuvWQMqSn2UaEk= +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.18/go.mod h1:hWe9b4f+djUQGmyiGEeOnZv69dtMSgpDRIvNMvuvzvY= +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.1/go.mod h1:071TH4M3botFLWDbzQLfBR7tXYi7Fs2RsXSiH7nlUlY= +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.6/go.mod h1:hXzcHLARD7GeWnifd8j9RWqtfIgxj4/cAtIVIK7hg8g= +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.11/go.mod h1:0DO9B5EUJQlIDif+XJRWCljZRKsAFKh3gpFz7UnDtOo= +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.15/go.mod h1:lyRQKED9xWfgkYC/wmmYfv7iVIM68Z5OQ88ZdcV1QbU= +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.7/go.mod h1:sks5UWBhEuWYDPdwlnRFn1w7xWdH29Jcpe+/PJQefEs= +github.com/aws/smithy-go v1.24.1 h1:VbyeNfmYkWoxMVpGUAbQumkODcYmfMRfZ8yQiH30SK0= +github.com/aws/smithy-go v1.24.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= @@ -73,8 +73,8 @@ github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= -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 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo= +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/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/docker/docker v27.3.1+incompatible h1:KttF0XoteNTicmUtBO0L2tP+J7FGRFTjaEF4k6WdhfI= @@ -175,8 +175,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/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/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U= -github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= +github.com/nats-io/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE= +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/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs= github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= @@ -363,8 +363,8 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d h1:EocjzKLywydp5uZ5tJ79iP6Q0UjDnyiHkGRWxuPBP8s= google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:48U2I+QQUYhsFrg2SY6r+nJzeOtjey7j//WBESw+qyQ= -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-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +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-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/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/server/interface/api/srequest/payment.go b/api/server/interface/api/srequest/payment.go index 9bf988f1..9b835318 100644 --- a/api/server/interface/api/srequest/payment.go +++ b/api/server/interface/api/srequest/payment.go @@ -1,6 +1,8 @@ package srequest import ( + "strings" + "github.com/tech/sendico/pkg/merrors" ) @@ -23,8 +25,7 @@ type QuotePayment struct { } func (r *QuotePayment) Validate() error { - // base checks - if err := r.PaymentBase.Validate(); err != nil { + if err := validateQuoteIdempotency(r.PreviewOnly, r.IdempotencyKey); err != nil { return err } @@ -43,7 +44,7 @@ type QuotePayments struct { } func (r *QuotePayments) Validate() error { - if err := r.PaymentBase.Validate(); err != nil { + if err := validateQuoteIdempotency(r.PreviewOnly, r.IdempotencyKey); err != nil { return err } if len(r.Intents) == 0 { @@ -57,6 +58,20 @@ func (r *QuotePayments) Validate() error { return nil } +func validateQuoteIdempotency(previewOnly bool, idempotencyKey string) error { + key := strings.TrimSpace(idempotencyKey) + if previewOnly { + if key != "" { + return merrors.InvalidArgument("previewOnly requests must not include idempotencyKey", "idempotencyKey") + } + return nil + } + if key == "" { + return merrors.InvalidArgument("idempotencyKey is required", "idempotencyKey") + } + return nil +} + type InitiatePayment struct { PaymentBase `json:",inline"` Intent *PaymentIntent `json:"intent,omitempty"` diff --git a/api/server/interface/api/srequest/payment_validate_test.go b/api/server/interface/api/srequest/payment_validate_test.go new file mode 100644 index 00000000..3a801cef --- /dev/null +++ b/api/server/interface/api/srequest/payment_validate_test.go @@ -0,0 +1,29 @@ +package srequest + +import "testing" + +func TestValidateQuoteIdempotency(t *testing.T) { + t.Run("non-preview requires idempotency key", func(t *testing.T) { + if err := validateQuoteIdempotency(false, ""); err == nil { + t.Fatalf("expected error for empty idempotency key") + } + }) + + t.Run("preview rejects idempotency key", func(t *testing.T) { + if err := validateQuoteIdempotency(true, "idem-1"); err == nil { + t.Fatalf("expected error when preview request has idempotency key") + } + }) + + t.Run("preview accepts empty idempotency key", func(t *testing.T) { + if err := validateQuoteIdempotency(true, ""); err != nil { + t.Fatalf("expected no error, got %v", err) + } + }) + + t.Run("non-preview accepts idempotency key", func(t *testing.T) { + if err := validateQuoteIdempotency(false, "idem-1"); err != nil { + t.Fatalf("expected no error, got %v", err) + } + }) +} diff --git a/api/server/interface/api/sresponse/payment.go b/api/server/interface/api/sresponse/payment.go index 27a3fdc4..4d79b82f 100644 --- a/api/server/interface/api/sresponse/payment.go +++ b/api/server/interface/api/sresponse/payment.go @@ -2,6 +2,7 @@ package sresponse import ( "net/http" + "strconv" "strings" "time" @@ -11,9 +12,9 @@ import ( feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1" oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" - orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestration/v1" - quotationv1 "github.com/tech/sendico/pkg/proto/payments/quotation/v1" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" + orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2" + quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" + "google.golang.org/protobuf/types/known/timestamppb" ) type FeeLine struct { @@ -96,7 +97,7 @@ type paymentResponse struct { } // PaymentQuote wraps a payment quote with refreshed access token. -func PaymentQuoteResponse(logger mlogger.Logger, idempotencyKey string, quote *sharedv1.PaymentQuote, token *TokenData) http.HandlerFunc { +func PaymentQuoteResponse(logger mlogger.Logger, idempotencyKey string, quote *quotationv2.PaymentQuote, token *TokenData) http.HandlerFunc { return response.Ok(logger, paymentQuoteResponse{ Quote: toPaymentQuote(quote), IdempotencyKey: idempotencyKey, @@ -105,7 +106,7 @@ func PaymentQuoteResponse(logger mlogger.Logger, idempotencyKey string, quote *s } // PaymentQuotes wraps batch quotes with refreshed access token. -func PaymentQuotesResponse(logger mlogger.Logger, resp *quotationv1.QuotePaymentsResponse, token *TokenData) http.HandlerFunc { +func PaymentQuotesResponse(logger mlogger.Logger, resp *quotationv2.QuotePaymentsResponse, token *TokenData) http.HandlerFunc { return response.Ok(logger, paymentQuotesResponse{ Quote: toPaymentQuotes(resp), authResponse: authResponse{AccessToken: *token}, @@ -113,7 +114,7 @@ func PaymentQuotesResponse(logger mlogger.Logger, resp *quotationv1.QuotePayment } // Payments wraps a list of payments with refreshed access token. -func PaymentsResponse(logger mlogger.Logger, payments []*sharedv1.Payment, token *TokenData) http.HandlerFunc { +func PaymentsResponse(logger mlogger.Logger, payments []*orchestrationv2.Payment, token *TokenData) http.HandlerFunc { return response.Ok(logger, paymentsResponse{ Payments: toPayments(payments), authResponse: authResponse{AccessToken: *token}, @@ -121,7 +122,7 @@ func PaymentsResponse(logger mlogger.Logger, payments []*sharedv1.Payment, token } // PaymentsList wraps a list of payments with refreshed access token and pagination data. -func PaymentsListResponse(logger mlogger.Logger, resp *orchestratorv1.ListPaymentsResponse, token *TokenData) http.HandlerFunc { +func PaymentsListResponse(logger mlogger.Logger, resp *orchestrationv2.ListPaymentsResponse, token *TokenData) http.HandlerFunc { return response.Ok(logger, paymentsResponse{ Payments: toPayments(resp.GetPayments()), Page: resp.GetPage(), @@ -130,7 +131,7 @@ func PaymentsListResponse(logger mlogger.Logger, resp *orchestratorv1.ListPaymen } // Payment wraps a payment with refreshed access token. -func PaymentResponse(logger mlogger.Logger, payment *sharedv1.Payment, token *TokenData) http.HandlerFunc { +func PaymentResponse(logger mlogger.Logger, payment *orchestrationv2.Payment, token *TokenData) http.HandlerFunc { return response.Ok(logger, paymentResponse{ Payment: toPayment(payment), authResponse: authResponse{AccessToken: *token}, @@ -191,33 +192,20 @@ func toFxQuote(q *oraclev1.Quote) *FxQuote { } } -func toPaymentQuote(q *sharedv1.PaymentQuote) *PaymentQuote { +func toPaymentQuote(q *quotationv2.PaymentQuote) *PaymentQuote { if q == nil { return nil } return &PaymentQuote{ QuoteRef: q.GetQuoteRef(), - DebitAmount: toMoney(q.GetDebitAmount()), - DebitSettlementAmount: toMoney(q.GetDebitSettlementAmount()), - ExpectedSettlementAmount: toMoney(q.GetExpectedSettlementAmount()), - ExpectedFeeTotal: toMoney(q.GetExpectedFeeTotal()), + DebitAmount: toMoney(q.GetPayerTotalDebitAmount()), + ExpectedSettlementAmount: toMoney(q.GetDestinationAmount()), FeeLines: toFeeLines(q.GetFeeLines()), FxQuote: toFxQuote(q.GetFxQuote()), } } -func toPaymentQuoteAggregate(q *sharedv1.PaymentQuoteAggregate) *PaymentQuoteAggregate { - if q == nil { - return nil - } - return &PaymentQuoteAggregate{ - DebitAmounts: toMoneyList(q.GetDebitAmounts()), - ExpectedSettlementAmounts: toMoneyList(q.GetExpectedSettlementAmounts()), - ExpectedFeeTotals: toMoneyList(q.GetExpectedFeeTotals()), - } -} - -func toPaymentQuotes(resp *quotationv1.QuotePaymentsResponse) *PaymentQuotes { +func toPaymentQuotes(resp *quotationv2.QuotePaymentsResponse) *PaymentQuotes { if resp == nil { return nil } @@ -233,12 +221,11 @@ func toPaymentQuotes(resp *quotationv1.QuotePaymentsResponse) *PaymentQuotes { return &PaymentQuotes{ IdempotencyKey: resp.GetIdempotencyKey(), QuoteRef: resp.GetQuoteRef(), - Aggregate: toPaymentQuoteAggregate(resp.GetAggregate()), Quotes: quotes, } } -func toPayments(items []*sharedv1.Payment) []Payment { +func toPayments(items []*orchestrationv2.Payment) []Payment { if len(items) == 0 { return nil } @@ -254,22 +241,65 @@ func toPayments(items []*sharedv1.Payment) []Payment { return result } -func toPayment(p *sharedv1.Payment) *Payment { +func toPayment(p *orchestrationv2.Payment) *Payment { if p == nil { return nil } + failureCode, failureReason := firstFailure(p.GetStepExecutions()) return &Payment{ PaymentRef: p.GetPaymentRef(), - IdempotencyKey: p.GetIdempotencyKey(), State: enumJSONName(p.GetState().String()), - FailureCode: enumJSONName(p.GetFailureCode().String()), - FailureReason: p.GetFailureReason(), - LastQuote: toPaymentQuote(p.GetLastQuote()), - CreatedAt: p.GetCreatedAt().AsTime(), - Meta: p.GetMetadata(), + FailureCode: failureCode, + FailureReason: failureReason, + LastQuote: toPaymentQuote(p.GetQuoteSnapshot()), + CreatedAt: timestampAsTime(p.GetCreatedAt()), + Meta: paymentMeta(p), + IdempotencyKey: "", } } +func firstFailure(steps []*orchestrationv2.StepExecution) (string, string) { + for _, step := range steps { + if step == nil || step.GetFailure() == nil { + continue + } + failure := step.GetFailure() + message := strings.TrimSpace(failure.GetMessage()) + if message == "" { + message = strings.TrimSpace(failure.GetCode()) + } + return enumJSONName(failure.GetCategory().String()), message + } + return "", "" +} + +func paymentMeta(p *orchestrationv2.Payment) map[string]string { + if p == nil { + return nil + } + meta := make(map[string]string) + if quotationRef := strings.TrimSpace(p.GetQuotationRef()); quotationRef != "" { + meta["quotationRef"] = quotationRef + } + if clientPaymentRef := strings.TrimSpace(p.GetClientPaymentRef()); clientPaymentRef != "" { + meta["clientPaymentRef"] = clientPaymentRef + } + if version := p.GetVersion(); version > 0 { + meta["version"] = strconv.FormatUint(version, 10) + } + if len(meta) == 0 { + return nil + } + return meta +} + +func timestampAsTime(ts *timestamppb.Timestamp) time.Time { + if ts == nil { + return time.Time{} + } + return ts.AsTime() +} + func enumJSONName(value string) string { return strings.ToLower(strings.TrimSpace(value)) } diff --git a/api/server/internal/server/paymentapiimp/list.go b/api/server/internal/server/paymentapiimp/list.go index d6a471cc..b1362823 100644 --- a/api/server/internal/server/paymentapiimp/list.go +++ b/api/server/internal/server/paymentapiimp/list.go @@ -4,18 +4,19 @@ import ( "net/http" "strconv" "strings" + "time" "github.com/tech/sendico/pkg/api/http/response" "github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/model" "github.com/tech/sendico/pkg/mutil/mzap" paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1" - orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestration/v1" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" + orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2" "github.com/tech/sendico/server/interface/api/sresponse" mutil "github.com/tech/sendico/server/internal/mutil/param" "go.mongodb.org/mongo-driver/v2/bson" "go.uber.org/zap" + "google.golang.org/protobuf/types/known/timestamppb" ) const maxInt32 = int64(1<<31 - 1) @@ -38,9 +39,7 @@ func (a *PaymentAPI) listPayments(r *http.Request, account *model.Account, token return response.AccessDenied(a.logger, a.Name(), "payments read permission denied") } - req := &orchestratorv1.ListPaymentsRequest{ - OrganizationRef: orgRef.Hex(), - } + req := &orchestrationv2.ListPaymentsRequest{Meta: requestMeta(orgRef.Hex(), "")} if page, err := listPaymentsPage(r); err != nil { return response.Auto(a.logger, a.Name(), err) @@ -49,17 +48,33 @@ func (a *PaymentAPI) listPayments(r *http.Request, account *model.Account, token } query := r.URL.Query() - if sourceRef := strings.TrimSpace(query.Get("source_ref")); sourceRef != "" { - req.SourceRef = sourceRef + if quotationRef := firstNonEmpty(query.Get("quotation_ref"), query.Get("quote_ref")); quotationRef != "" { + req.QuotationRef = quotationRef } - if destinationRef := strings.TrimSpace(query.Get("destination_ref")); destinationRef != "" { - req.DestinationRef = destinationRef + createdFrom, err := parseRFC3339Timestamp(firstNonEmpty(query.Get("created_from"), query.Get("createdFrom")), "created_from") + if err != nil { + return response.Auto(a.logger, a.Name(), err) + } + if createdFrom != nil { + req.CreatedFrom = createdFrom + } + createdTo, err := parseRFC3339Timestamp(firstNonEmpty(query.Get("created_to"), query.Get("createdTo")), "created_to") + if err != nil { + return response.Auto(a.logger, a.Name(), err) + } + if createdTo != nil { + req.CreatedTo = createdTo + } + if req.GetCreatedFrom() != nil && req.GetCreatedTo() != nil { + if !req.GetCreatedTo().AsTime().After(req.GetCreatedFrom().AsTime()) { + return response.Auto(a.logger, a.Name(), merrors.InvalidArgument("created_to must be after created_from", "created_to")) + } } if states, err := parsePaymentStateFilters(r); err != nil { return response.Auto(a.logger, a.Name(), err) } else if len(states) > 0 { - req.FilterStates = states + req.States = states } resp, err := a.execution.ListPayments(ctx, req) @@ -106,7 +121,7 @@ func listPaymentsPage(r *http.Request) (*paginationv1.CursorPageRequest, error) return page, nil } -func parsePaymentStateFilters(r *http.Request) ([]sharedv1.PaymentState, error) { +func parsePaymentStateFilters(r *http.Request) ([]orchestrationv2.OrchestrationState, error) { query := r.URL.Query() values := append([]string{}, query["state"]...) values = append(values, query["states"]...) @@ -115,14 +130,14 @@ func parsePaymentStateFilters(r *http.Request) ([]sharedv1.PaymentState, error) return nil, nil } - states := make([]sharedv1.PaymentState, 0, len(values)) + states := make([]orchestrationv2.OrchestrationState, 0, len(values)) for _, raw := range values { for _, part := range strings.Split(raw, ",") { trimmed := strings.TrimSpace(part) if trimmed == "" { continue } - state, ok := paymentStateFromString(trimmed) + state, ok := orchestrationStateFromString(trimmed) if !ok { return nil, merrors.InvalidArgument("unsupported payment state: "+trimmed, "state") } @@ -136,17 +151,49 @@ func parsePaymentStateFilters(r *http.Request) ([]sharedv1.PaymentState, error) return states, nil } -func paymentStateFromString(value string) (sharedv1.PaymentState, bool) { +func orchestrationStateFromString(value string) (orchestrationv2.OrchestrationState, bool) { upper := strings.ToUpper(strings.TrimSpace(value)) if upper == "" { return 0, false } - if !strings.HasPrefix(upper, "PAYMENT_STATE_") { - upper = "PAYMENT_STATE_" + upper + switch upper { + case "PAYMENT_STATE_ACCEPTED", "ACCEPTED": + return orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_CREATED, true + case "PAYMENT_STATE_FUNDS_RESERVED", "FUNDS_RESERVED", "PAYMENT_STATE_SUBMITTED", "SUBMITTED": + return orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_EXECUTING, true + case "PAYMENT_STATE_SETTLED", "SETTLED": + return orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_SETTLED, true + case "PAYMENT_STATE_FAILED", "FAILED", "PAYMENT_STATE_CANCELLED", "CANCELLED": + return orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_FAILED, true } - enumValue, ok := sharedv1.PaymentState_value[upper] + if !strings.HasPrefix(upper, "ORCHESTRATION_STATE_") { + upper = "ORCHESTRATION_STATE_" + upper + } + enumValue, ok := orchestrationv2.OrchestrationState_value[upper] if !ok { return 0, false } - return sharedv1.PaymentState(enumValue), true + return orchestrationv2.OrchestrationState(enumValue), true +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed != "" { + return trimmed + } + } + return "" +} + +func parseRFC3339Timestamp(raw string, field string) (*timestamppb.Timestamp, error) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return nil, nil + } + parsed, err := time.Parse(time.RFC3339, trimmed) + if err != nil { + return nil, merrors.InvalidArgument("invalid "+field+", expected RFC3339", field) + } + return timestamppb.New(parsed), nil } diff --git a/api/server/internal/server/paymentapiimp/mapper.go b/api/server/internal/server/paymentapiimp/mapper.go index 883a9799..a9be4fff 100644 --- a/api/server/internal/server/paymentapiimp/mapper.go +++ b/api/server/internal/server/paymentapiimp/mapper.go @@ -1,26 +1,26 @@ package paymentapiimp import ( + "strconv" "strings" - "github.com/google/uuid" "github.com/tech/sendico/pkg/merrors" + pkgmodel "github.com/tech/sendico/pkg/model" paymenttypes "github.com/tech/sendico/pkg/payments/types" - 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" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" + endpointv1 "github.com/tech/sendico/pkg/proto/payments/endpoint/v1" + quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" "github.com/tech/sendico/server/interface/api/srequest" + "go.mongodb.org/mongo-driver/v2/bson" ) -func mapPaymentIntent(intent *srequest.PaymentIntent) (*sharedv1.PaymentIntent, error) { +func mapQuoteIntent(intent *srequest.PaymentIntent) (*quotationv2.QuoteIntent, error) { if intent == nil { return nil, merrors.InvalidArgument("intent is required") } - - kind, err := mapPaymentKind(intent.Kind) - if err != nil { + if err := validatePaymentKind(intent.Kind); err != nil { return nil, err } @@ -33,33 +33,35 @@ func mapPaymentIntent(intent *srequest.PaymentIntent) (*sharedv1.PaymentIntent, settlementCurrency = resolveSettlementCurrency(intent) } - source, err := mapPaymentEndpoint(intent.Source, "source") + source, err := mapQuoteEndpoint(intent.Source, "intent.source") if err != nil { return nil, err } - destination, err := mapPaymentEndpoint(intent.Destination, "destination") + destination, err := mapQuoteEndpoint(intent.Destination, "intent.destination") if err != nil { return nil, err } - fx, err := mapFXIntent(intent.FX) - if err != nil { - return nil, err - } - - return &sharedv1.PaymentIntent{ - Ref: uuid.New().String(), - Kind: kind, + quoteIntent := "ationv2.QuoteIntent{ Source: source, Destination: destination, Amount: mapMoney(intent.Amount), - RequiresFx: fx != nil, - Fx: fx, SettlementMode: settlementMode, SettlementCurrency: settlementCurrency, - Attributes: copyStringMap(intent.Attributes), - Customer: mapCustomer(intent.Customer), - }, nil + } + if comment := strings.TrimSpace(intent.Attributes["comment"]); comment != "" { + quoteIntent.Comment = comment + } + return quoteIntent, nil +} + +func validatePaymentKind(kind srequest.PaymentKind) error { + switch strings.TrimSpace(string(kind)) { + case string(srequest.PaymentKindPayout), string(srequest.PaymentKindInternalTransfer), string(srequest.PaymentKindFxConversion): + return nil + default: + return merrors.InvalidArgument("unsupported payment kind: " + string(kind)) + } } func resolveSettlementCurrency(intent *srequest.PaymentIntent) string { @@ -81,150 +83,147 @@ func resolveSettlementCurrency(intent *srequest.PaymentIntent) string { return quote } } - if intent.Amount != nil { - amountCurrency := strings.TrimSpace(intent.Amount.Currency) - if amountCurrency != "" { - switch { - case strings.EqualFold(amountCurrency, base) && quote != "": - return quote - case strings.EqualFold(amountCurrency, quote) && base != "": - return base - default: - return amountCurrency - } - } - } - if quote != "" { - return quote - } - if base != "" { - return base - } } if intent.Amount != nil { return strings.TrimSpace(intent.Amount.Currency) } - return "" } -func mapPaymentEndpoint(endpoint *srequest.Endpoint, field string) (*sharedv1.PaymentEndpoint, error) { +func mapQuoteEndpoint(endpoint *srequest.Endpoint, field string) (*endpointv1.PaymentEndpoint, error) { if endpoint == nil { - return nil, nil + return nil, merrors.InvalidArgument(field + " is required") } - var result sharedv1.PaymentEndpoint switch endpoint.Type { case srequest.EndpointTypeLedger: payload, err := endpoint.DecodeLedger() if err != nil { - return nil, merrors.InvalidArgument(field + " endpoint: " + err.Error()) + return nil, merrors.InvalidArgument(field + ": " + err.Error()) } - result.Endpoint = &sharedv1.PaymentEndpoint_Ledger{ - Ledger: mapLedgerEndpoint(&payload), + method := &ledgerMethodData{ + LedgerAccountRef: strings.TrimSpace(payload.LedgerAccountRef), + ContraLedgerAccountRef: strings.TrimSpace(payload.ContraLedgerAccountRef), } + if method.LedgerAccountRef == "" { + return nil, merrors.InvalidArgument(field + ".ledger_account_ref is required") + } + return endpointFromMethod(endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_LEDGER, method) + case srequest.EndpointTypeManagedWallet: payload, err := endpoint.DecodeManagedWallet() if err != nil { - return nil, merrors.InvalidArgument(field + " endpoint: " + err.Error()) + return nil, merrors.InvalidArgument(field + ": " + err.Error()) } - mw, err := mapManagedWalletEndpoint(&payload) + method := &pkgmodel.WalletPaymentData{WalletID: strings.TrimSpace(payload.ManagedWalletRef)} + if method.WalletID == "" { + return nil, merrors.InvalidArgument(field + ".managed_wallet_ref is required") + } + return endpointFromMethod(endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_WALLET, method) + + case srequest.EndpointTypeWallet: + payload, err := endpoint.DecodeWallet() if err != nil { - return nil, merrors.InvalidArgument(field + " endpoint: " + err.Error()) + return nil, merrors.InvalidArgument(field + ": " + err.Error()) } - result.Endpoint = &sharedv1.PaymentEndpoint_ManagedWallet{ - ManagedWallet: mw, + method := &pkgmodel.WalletPaymentData{WalletID: strings.TrimSpace(payload.WalletID)} + if method.WalletID == "" { + return nil, merrors.InvalidArgument(field + ".walletId is required") } + return endpointFromMethod(endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_WALLET, method) + case srequest.EndpointTypeExternalChain: payload, err := endpoint.DecodeExternalChain() if err != nil { - return nil, merrors.InvalidArgument(field + " endpoint: " + err.Error()) + return nil, merrors.InvalidArgument(field + ": " + err.Error()) } - ext, err := mapExternalChainEndpoint(&payload) - if err != nil { - return nil, merrors.InvalidArgument(field + " endpoint: " + err.Error()) - } - result.Endpoint = &sharedv1.PaymentEndpoint_ExternalChain{ - ExternalChain: ext, + method, mapErr := mapExternalChainMethod(payload, field) + if mapErr != nil { + return nil, mapErr } + return endpointFromMethod(endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CRYPTO_ADDRESS, method) + case srequest.EndpointTypeCard: payload, err := endpoint.DecodeCard() if err != nil { - return nil, merrors.InvalidArgument(field + " endpoint: " + err.Error()) + return nil, merrors.InvalidArgument(field + ": " + err.Error()) } - result.Endpoint = &sharedv1.PaymentEndpoint_Card{ - Card: mapCardEndpoint(&payload), + method := &pkgmodel.CardPaymentData{ + Pan: strings.TrimSpace(payload.Pan), + FirstName: strings.TrimSpace(payload.FirstName), + LastName: strings.TrimSpace(payload.LastName), + ExpMonth: uint32ToString(payload.ExpMonth), + ExpYear: uint32ToString(payload.ExpYear), + Country: strings.TrimSpace(payload.Country), } + if method.Pan == "" { + return nil, merrors.InvalidArgument(field + ".pan is required") + } + return endpointFromMethod(endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CARD, method) + case srequest.EndpointTypeCardToken: payload, err := endpoint.DecodeCardToken() if err != nil { - return nil, merrors.InvalidArgument(field + " endpoint: " + err.Error()) + return nil, merrors.InvalidArgument(field + ": " + err.Error()) } - result.Endpoint = &sharedv1.PaymentEndpoint_Card{ - Card: mapCardTokenEndpoint(&payload), + method := &pkgmodel.TokenPaymentData{ + Token: strings.TrimSpace(payload.Token), + Last4: strings.TrimSpace(payload.MaskedPan), } + if method.Token == "" { + return nil, merrors.InvalidArgument(field + ".token is required") + } + return endpointFromMethod(endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CARD_TOKEN, method) + case "": return nil, merrors.InvalidArgument(field + " endpoint type is required") + default: - return nil, merrors.InvalidArgument(field + " endpoint has unsupported type: " + string(endpoint.Type)) - } - - result.Metadata = copyStringMap(endpoint.Metadata) - return &result, nil -} - -func mapLedgerEndpoint(endpoint *srequest.LedgerEndpoint) *sharedv1.LedgerEndpoint { - if endpoint == nil { - return nil - } - return &sharedv1.LedgerEndpoint{ - LedgerAccountRef: endpoint.LedgerAccountRef, - ContraLedgerAccountRef: endpoint.ContraLedgerAccountRef, + return nil, merrors.InvalidArgument(field + " endpoint type is unsupported in v2: " + string(endpoint.Type)) } } -func mapManagedWalletEndpoint(endpoint *srequest.ManagedWalletEndpoint) (*sharedv1.ManagedWalletEndpoint, error) { - if endpoint == nil { - return nil, nil +func mapExternalChainMethod(payload srequest.ExternalChainEndpoint, field string) (*pkgmodel.CryptoAddressPaymentData, error) { + address := strings.TrimSpace(payload.Address) + if address == "" { + return nil, merrors.InvalidArgument(field + ".address is required") } - asset, err := mapAsset(endpoint.Asset) + if payload.Asset == nil { + return nil, merrors.InvalidArgument(field + ".asset is required") + } + token := strings.ToUpper(strings.TrimSpace(payload.Asset.TokenSymbol)) + if token == "" { + return nil, merrors.InvalidArgument(field + ".asset.token_symbol is required") + } + if _, err := mapChainNetwork(payload.Asset.Chain); err != nil { + return nil, merrors.InvalidArgument(field + ".asset.chain: " + err.Error()) + } + + result := &pkgmodel.CryptoAddressPaymentData{ + Currency: pkgmodel.Currency(token), + Address: address, + Network: strings.ToUpper(strings.TrimSpace(string(payload.Asset.Chain))), + } + if memo := strings.TrimSpace(payload.Memo); memo != "" { + result.DestinationTag = &memo + } + return result, nil +} + +func endpointFromMethod(methodType endpointv1.PaymentMethodType, data any) (*endpointv1.PaymentEndpoint, error) { + raw, err := bson.Marshal(data) if err != nil { - return nil, err + return nil, merrors.InternalWrap(err, "failed to encode payment method data") } - return &sharedv1.ManagedWalletEndpoint{ - ManagedWalletRef: endpoint.ManagedWalletRef, - Asset: asset, - }, nil -} - -func mapExternalChainEndpoint(endpoint *srequest.ExternalChainEndpoint) (*sharedv1.ExternalChainEndpoint, error) { - if endpoint == nil { - return nil, nil + method := &endpointv1.PaymentMethod{ + Type: methodType, + Data: raw, } - asset, err := mapAsset(endpoint.Asset) - if err != nil { - return nil, err - } - return &sharedv1.ExternalChainEndpoint{ - Asset: asset, - Address: endpoint.Address, - Memo: endpoint.Memo, - }, nil -} - -func mapAsset(asset *srequest.Asset) (*chainv1.Asset, error) { - if asset == nil { - return nil, nil - } - chain, err := mapChainNetwork(asset.Chain) - if err != nil { - return nil, err - } - return &chainv1.Asset{ - Chain: chain, - TokenSymbol: asset.TokenSymbol, - ContractAddress: asset.ContractAddress, + return &endpointv1.PaymentEndpoint{ + Source: &endpointv1.PaymentEndpoint_PaymentMethod{ + PaymentMethod: method, + }, }, nil } @@ -238,94 +237,6 @@ func mapMoney(m *paymenttypes.Money) *moneyv1.Money { } } -func mapFXIntent(fx *srequest.FXIntent) (*sharedv1.FXIntent, error) { - if fx == nil { - return nil, nil - } - side, err := mapFXSide(fx.Side) - if err != nil { - return nil, err - } - return &sharedv1.FXIntent{ - Pair: mapCurrencyPair(fx.Pair), - Side: side, - Firm: fx.Firm, - TtlMs: fx.TTLms, - PreferredProvider: fx.PreferredProvider, - MaxAgeMs: fx.MaxAgeMs, - }, nil -} - -func mapCustomer(customer *srequest.Customer) *sharedv1.Customer { - if customer == nil { - return nil - } - return &sharedv1.Customer{ - Id: strings.TrimSpace(customer.ID), - FirstName: strings.TrimSpace(customer.FirstName), - MiddleName: strings.TrimSpace(customer.MiddleName), - LastName: strings.TrimSpace(customer.LastName), - Ip: strings.TrimSpace(customer.IP), - Zip: strings.TrimSpace(customer.Zip), - Country: strings.TrimSpace(customer.Country), - State: strings.TrimSpace(customer.State), - City: strings.TrimSpace(customer.City), - Address: strings.TrimSpace(customer.Address), - } -} - -func mapCurrencyPair(pair *srequest.CurrencyPair) *fxv1.CurrencyPair { - if pair == nil { - return nil - } - return &fxv1.CurrencyPair{ - Base: pair.Base, - Quote: pair.Quote, - } -} - -func mapCardEndpoint(card *srequest.CardEndpoint) *sharedv1.CardEndpoint { - if card == nil { - return nil - } - result := &sharedv1.CardEndpoint{ - CardholderName: strings.TrimSpace(card.FirstName), - CardholderSurname: strings.TrimSpace(card.LastName), - ExpMonth: card.ExpMonth, - ExpYear: card.ExpYear, - Country: strings.TrimSpace(card.Country), - } - if pan := strings.TrimSpace(card.Pan); pan != "" { - result.Card = &sharedv1.CardEndpoint_Pan{Pan: pan} - } - return result -} - -func mapCardTokenEndpoint(card *srequest.CardTokenEndpoint) *sharedv1.CardEndpoint { - if card == nil { - return nil - } - return &sharedv1.CardEndpoint{ - Card: &sharedv1.CardEndpoint_Token{Token: strings.TrimSpace(card.Token)}, - MaskedPan: strings.TrimSpace(card.MaskedPan), - } -} - -func mapPaymentKind(kind srequest.PaymentKind) (sharedv1.PaymentKind, error) { - switch strings.TrimSpace(string(kind)) { - case "", string(srequest.PaymentKindUnspecified): - return sharedv1.PaymentKind_PAYMENT_KIND_UNSPECIFIED, nil - case string(srequest.PaymentKindPayout): - return sharedv1.PaymentKind_PAYMENT_KIND_PAYOUT, nil - case string(srequest.PaymentKindInternalTransfer): - return sharedv1.PaymentKind_PAYMENT_KIND_INTERNAL_TRANSFER, nil - case string(srequest.PaymentKindFxConversion): - return sharedv1.PaymentKind_PAYMENT_KIND_FX_CONVERSION, nil - default: - return sharedv1.PaymentKind_PAYMENT_KIND_UNSPECIFIED, merrors.InvalidArgument("unsupported payment kind: " + string(kind)) - } -} - func mapSettlementMode(mode srequest.SettlementMode) (paymentv1.SettlementMode, error) { switch strings.TrimSpace(string(mode)) { case "", string(srequest.SettlementModeUnspecified): @@ -339,19 +250,6 @@ func mapSettlementMode(mode srequest.SettlementMode) (paymentv1.SettlementMode, } } -func mapFXSide(side srequest.FXSide) (fxv1.Side, error) { - switch strings.TrimSpace(string(side)) { - case "", string(srequest.FXSideUnspecified): - return fxv1.Side_SIDE_UNSPECIFIED, nil - case string(srequest.FXSideBuyBaseSellQuote): - return fxv1.Side_BUY_BASE_SELL_QUOTE, nil - case string(srequest.FXSideSellBaseBuyQuote): - return fxv1.Side_SELL_BASE_BUY_QUOTE, nil - default: - return fxv1.Side_SIDE_UNSPECIFIED, merrors.InvalidArgument("unsupported fx side: " + string(side)) - } -} - func mapChainNetwork(chain srequest.ChainNetwork) (chainv1.ChainNetwork, error) { switch strings.TrimSpace(string(chain)) { case "", string(srequest.ChainNetworkUnspecified): @@ -369,13 +267,14 @@ func mapChainNetwork(chain srequest.ChainNetwork) (chainv1.ChainNetwork, error) } } -func copyStringMap(src map[string]string) map[string]string { - if len(src) == 0 { - return nil +func uint32ToString(v uint32) string { + if v == 0 { + return "" } - dst := make(map[string]string, len(src)) - for k, v := range src { - dst[k] = v - } - return dst + return strconv.FormatUint(uint64(v), 10) +} + +type ledgerMethodData struct { + LedgerAccountRef string `bson:"ledgerAccountRef"` + ContraLedgerAccountRef string `bson:"contraLedgerAccountRef,omitempty"` } diff --git a/api/server/internal/server/paymentapiimp/pay.go b/api/server/internal/server/paymentapiimp/pay.go index 762be860..f5e680c5 100644 --- a/api/server/internal/server/paymentapiimp/pay.go +++ b/api/server/internal/server/paymentapiimp/pay.go @@ -9,7 +9,9 @@ import ( "github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/model" "github.com/tech/sendico/pkg/mutil/mzap" - orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestration/v1" + tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1" + orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2" + quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" "github.com/tech/sendico/server/interface/api/srequest" "github.com/tech/sendico/server/interface/api/sresponse" @@ -58,26 +60,41 @@ func (a *PaymentAPI) initiatePayment(r *http.Request, account *model.Account, to } } - var intent *sharedv1.PaymentIntent + quotationRef := strings.TrimSpace(payload.QuoteRef) + intentRef := metadataValue(payload.Metadata, "intent_ref") if payload.Intent != nil { applyCustomerIP(payload.Intent, r.RemoteAddr) - intent, err = mapPaymentIntent(payload.Intent) + intent, err := mapQuoteIntent(payload.Intent) if err != nil { return response.BadPayload(a.logger, a.Name(), err) } + quoteResp, qErr := a.quotation.QuotePayment(ctx, "ationv2.QuotePaymentRequest{ + Meta: requestMeta(orgRef.Hex(), payload.IdempotencyKey), + IdempotencyKey: strings.TrimSpace(payload.IdempotencyKey), + Intent: intent, + InitiatorRef: initiatorRef(account), + }) + if qErr != nil { + a.logger.Warn("Failed to quote payment before execution", zap.Error(qErr), mzap.ObjRef("organization_ref", orgRef)) + return response.Auto(a.logger, a.Name(), qErr) + } + quotationRef = strings.TrimSpace(quoteResp.GetQuote().GetQuoteRef()) + if quotationRef == "" { + return response.Auto(a.logger, a.Name(), merrors.DataConflict("quotation service returned empty quote_ref")) + } + if derived := strings.TrimSpace(quoteResp.GetQuote().GetIntentRef()); derived != "" { + intentRef = derived + } } - req := &orchestratorv1.InitiatePaymentRequest{ - Meta: &sharedv1.RequestMeta{ - OrganizationRef: orgRef.Hex(), - }, - IdempotencyKey: strings.TrimSpace(payload.IdempotencyKey), - Intent: intent, - QuoteRef: strings.TrimSpace(payload.QuoteRef), - Metadata: payload.Metadata, + req := &orchestrationv2.ExecutePaymentRequest{ + Meta: requestMeta(orgRef.Hex(), payload.IdempotencyKey), + QuotationRef: quotationRef, + IntentRef: intentRef, + ClientPaymentRef: metadataValue(payload.Metadata, "client_payment_ref"), } - resp, err := a.execution.InitiatePayment(ctx, req) + resp, err := a.execution.ExecutePayment(ctx, req) if err != nil { a.logger.Warn("Failed to initiate payment", zap.Error(err), mzap.ObjRef("organization_ref", orgRef)) return response.Auto(a.logger, a.Name(), err) @@ -101,3 +118,29 @@ func decodeInitiatePayload(r *http.Request) (*srequest.InitiatePayment, error) { } return payload, nil } + +func requestMeta(organizationRef string, idempotencyKey string) *sharedv1.RequestMeta { + return &sharedv1.RequestMeta{ + OrganizationRef: strings.TrimSpace(organizationRef), + Trace: &tracev1.TraceContext{ + IdempotencyKey: strings.TrimSpace(idempotencyKey), + }, + } +} + +func metadataValue(meta map[string]string, key string) string { + if len(meta) == 0 { + return "" + } + return strings.TrimSpace(meta[strings.TrimSpace(key)]) +} + +func initiatorRef(account *model.Account) string { + if account == nil { + return "" + } + if account.ID != bson.NilObjectID { + return account.ID.Hex() + } + return strings.TrimSpace(account.Login) +} diff --git a/api/server/internal/server/paymentapiimp/paybatch.go b/api/server/internal/server/paymentapiimp/paybatch.go index dcd8cf14..7b1fe533 100644 --- a/api/server/internal/server/paymentapiimp/paybatch.go +++ b/api/server/internal/server/paymentapiimp/paybatch.go @@ -8,8 +8,7 @@ import ( "github.com/tech/sendico/pkg/api/http/response" "github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/model" - orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestration/v1" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" + orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2" "github.com/tech/sendico/server/interface/api/srequest" "github.com/tech/sendico/server/interface/api/sresponse" mutil "github.com/tech/sendico/server/internal/mutil/param" @@ -40,22 +39,24 @@ func (a *PaymentAPI) initiatePaymentsByQuote(r *http.Request, account *model.Acc return response.BadPayload(a.logger, a.Name(), err) } - req := &orchestratorv1.InitiatePaymentsRequest{ - Meta: &sharedv1.RequestMeta{ - OrganizationRef: orgRef.Hex(), - }, - IdempotencyKey: strings.TrimSpace(payload.IdempotencyKey), - QuoteRef: strings.TrimSpace(payload.QuoteRef), - Metadata: payload.Metadata, + req := &orchestrationv2.ExecutePaymentRequest{ + Meta: requestMeta(orgRef.Hex(), payload.IdempotencyKey), + QuotationRef: strings.TrimSpace(payload.QuoteRef), + IntentRef: metadataValue(payload.Metadata, "intent_ref"), + ClientPaymentRef: metadataValue(payload.Metadata, "client_payment_ref"), } - resp, err := a.execution.InitiatePayments(ctx, req) + resp, err := a.execution.ExecutePayment(ctx, req) if err != nil { a.logger.Warn("Failed to initiate batch payments", zap.Error(err), zap.String("organization_ref", orgRef.Hex())) return response.Auto(a.logger, a.Name(), err) } - return sresponse.PaymentsResponse(a.logger, resp.GetPayments(), token) + payments := make([]*orchestrationv2.Payment, 0, 1) + if payment := resp.GetPayment(); payment != nil { + payments = append(payments, payment) + } + return sresponse.PaymentsResponse(a.logger, payments, token) } func decodeInitiatePaymentsPayload(r *http.Request) (*srequest.InitiatePayments, error) { diff --git a/api/server/internal/server/paymentapiimp/quote.go b/api/server/internal/server/paymentapiimp/quote.go index 80c59660..56cf1119 100644 --- a/api/server/internal/server/paymentapiimp/quote.go +++ b/api/server/internal/server/paymentapiimp/quote.go @@ -8,8 +8,7 @@ import ( "github.com/tech/sendico/pkg/api/http/response" "github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/model" - quotationv1 "github.com/tech/sendico/pkg/proto/payments/quotation/v1" - sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" + quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" "github.com/tech/sendico/server/interface/api/srequest" "github.com/tech/sendico/server/interface/api/sresponse" mutil "github.com/tech/sendico/server/internal/mutil/param" @@ -46,18 +45,18 @@ func (a *PaymentAPI) quotePayment(r *http.Request, account *model.Account, token } applyCustomerIP(&payload.Intent, r.RemoteAddr) - intent, err := mapPaymentIntent(&payload.Intent) + intent, err := mapQuoteIntent(&payload.Intent) if err != nil { a.logger.Debug("Failed to map payment intent", zap.Error(err), mutil.PLog(a.oph, r)) return response.BadPayload(a.logger, a.Name(), err) } - req := "ationv1.QuotePaymentRequest{ - Meta: &sharedv1.RequestMeta{ - OrganizationRef: orgRef.Hex(), - }, + req := "ationv2.QuotePaymentRequest{ + Meta: requestMeta(orgRef.Hex(), payload.IdempotencyKey), IdempotencyKey: payload.IdempotencyKey, Intent: intent, + PreviewOnly: payload.PreviewOnly, + InitiatorRef: initiatorRef(account), } resp, err := a.quotation.QuotePayment(ctx, req) @@ -97,10 +96,10 @@ func (a *PaymentAPI) quotePayments(r *http.Request, account *model.Account, toke return response.Auto(a.logger, a.Name(), err) } - intents := make([]*sharedv1.PaymentIntent, 0, len(payload.Intents)) + intents := make([]*quotationv2.QuoteIntent, 0, len(payload.Intents)) for i := range payload.Intents { applyCustomerIP(&payload.Intents[i], r.RemoteAddr) - intent, err := mapPaymentIntent(&payload.Intents[i]) + intent, err := mapQuoteIntent(&payload.Intents[i]) if err != nil { a.logger.Debug("Failed to map payment intent", zap.Error(err), mutil.PLog(a.oph, r)) return response.BadPayload(a.logger, a.Name(), err) @@ -108,13 +107,12 @@ func (a *PaymentAPI) quotePayments(r *http.Request, account *model.Account, toke intents = append(intents, intent) } - req := "ationv1.QuotePaymentsRequest{ - Meta: &sharedv1.RequestMeta{ - OrganizationRef: orgRef.Hex(), - }, + req := "ationv2.QuotePaymentsRequest{ + Meta: requestMeta(orgRef.Hex(), payload.IdempotencyKey), IdempotencyKey: payload.IdempotencyKey, Intents: intents, PreviewOnly: payload.PreviewOnly, + InitiatorRef: initiatorRef(account), } resp, err := a.quotation.QuotePayments(ctx, req) diff --git a/api/server/internal/server/paymentapiimp/service.go b/api/server/internal/server/paymentapiimp/service.go index 7edab491..1bdacbc5 100644 --- a/api/server/internal/server/paymentapiimp/service.go +++ b/api/server/internal/server/paymentapiimp/service.go @@ -18,8 +18,8 @@ import ( msgconsumer "github.com/tech/sendico/pkg/messaging/consumer" "github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/mservice" - orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestration/v1" - quotationv1 "github.com/tech/sendico/pkg/proto/payments/quotation/v1" + orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2" + quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" eapi "github.com/tech/sendico/server/interface/api" mutil "github.com/tech/sendico/server/internal/mutil/param" "go.mongodb.org/mongo-driver/v2/bson" @@ -30,15 +30,14 @@ import ( ) type executionClient interface { - InitiatePayments(ctx context.Context, req *orchestratorv1.InitiatePaymentsRequest) (*orchestratorv1.InitiatePaymentsResponse, error) - InitiatePayment(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error) - ListPayments(ctx context.Context, req *orchestratorv1.ListPaymentsRequest) (*orchestratorv1.ListPaymentsResponse, error) + ExecutePayment(ctx context.Context, req *orchestrationv2.ExecutePaymentRequest) (*orchestrationv2.ExecutePaymentResponse, error) + ListPayments(ctx context.Context, req *orchestrationv2.ListPaymentsRequest) (*orchestrationv2.ListPaymentsResponse, error) Close() error } type quotationClient interface { - QuotePayment(ctx context.Context, req *quotationv1.QuotePaymentRequest) (*quotationv1.QuotePaymentResponse, error) - QuotePayments(ctx context.Context, req *quotationv1.QuotePaymentsRequest) (*quotationv1.QuotePaymentsResponse, error) + QuotePayment(ctx context.Context, req *quotationv2.QuotePaymentRequest) (*quotationv2.QuotePaymentResponse, error) + QuotePayments(ctx context.Context, req *quotationv2.QuotePaymentsRequest) (*quotationv2.QuotePaymentsResponse, error) Close() error } @@ -203,7 +202,7 @@ func (c *quotationClientConfig) setDefaults() { type grpcQuotationClient struct { conn *grpc.ClientConn - client quotationv1.QuotationServiceClient + client quotationv2.QuotationServiceClient callTimeout time.Duration } @@ -230,7 +229,7 @@ func newQuotationClient(ctx context.Context, cfg quotationClientConfig, opts ... } return &grpcQuotationClient{ conn: conn, - client: quotationv1.NewQuotationServiceClient(conn), + client: quotationv2.NewQuotationServiceClient(conn), callTimeout: cfg.CallTimeout, }, nil } @@ -242,13 +241,13 @@ func (c *grpcQuotationClient) Close() error { return c.conn.Close() } -func (c *grpcQuotationClient) QuotePayment(ctx context.Context, req *quotationv1.QuotePaymentRequest) (*quotationv1.QuotePaymentResponse, error) { +func (c *grpcQuotationClient) QuotePayment(ctx context.Context, req *quotationv2.QuotePaymentRequest) (*quotationv2.QuotePaymentResponse, error) { callCtx, cancel := c.callContext(ctx) defer cancel() return c.client.QuotePayment(callCtx, req) } -func (c *grpcQuotationClient) QuotePayments(ctx context.Context, req *quotationv1.QuotePaymentsRequest) (*quotationv1.QuotePaymentsResponse, error) { +func (c *grpcQuotationClient) QuotePayments(ctx context.Context, req *quotationv2.QuotePaymentsRequest) (*quotationv2.QuotePaymentsResponse, error) { callCtx, cancel := c.callContext(ctx) defer cancel() return c.client.QuotePayments(callCtx, req) diff --git a/ci/scripts/proto/generate.sh b/ci/scripts/proto/generate.sh index b1520ffc..a0108475 100755 --- a/ci/scripts/proto/generate.sh +++ b/ci/scripts/proto/generate.sh @@ -143,10 +143,10 @@ if [ -f "${PROTO_DIR}/payments/shared/v1/shared.proto" ]; then generate_go_with_grpc "${PROTO_DIR}/payments/shared/v1/shared.proto" fi -if [ -f "${PROTO_DIR}/payments/orchestration/v1/orchestration.proto" ]; then +if [ -f "${PROTO_DIR}/payments/orchestration/v2/orchestration.proto" ]; then info "Compiling payments orchestration protos" clean_pb_files "./pkg/proto/payments/orchestration" - generate_go_with_grpc "${PROTO_DIR}/payments/orchestration/v1/orchestration.proto" + generate_go_with_grpc "${PROTO_DIR}/payments/orchestration/v2/orchestration.proto" fi if [ -f "${PROTO_DIR}/payments/transfer/v1/transfer.proto" ]; then @@ -155,14 +155,10 @@ if [ -f "${PROTO_DIR}/payments/transfer/v1/transfer.proto" ]; then generate_go "${PROTO_DIR}/payments/transfer/v1/transfer.proto" fi -if [ -f "${PROTO_DIR}/payments/quotation/v1/quotation.proto" ] || \ - [ -f "${PROTO_DIR}/payments/quotation/v2/interface.proto" ] || \ +if [ -f "${PROTO_DIR}/payments/quotation/v2/interface.proto" ] || \ [ -f "${PROTO_DIR}/payments/quotation/v2/quotation.proto" ]; then info "Compiling payments quotation protos" clean_pb_files "./pkg/proto/payments/quotation" - if [ -f "${PROTO_DIR}/payments/quotation/v1/quotation.proto" ]; then - generate_go_with_grpc "${PROTO_DIR}/payments/quotation/v1/quotation.proto" - fi if [ -f "${PROTO_DIR}/payments/quotation/v2/interface.proto" ]; then generate_go "api/proto/payments/quotation/v2/interface.proto" fi diff --git a/frontend/pshared/lib/data/mapper/payment/enums.dart b/frontend/pshared/lib/data/mapper/payment/enums.dart index cd3f236d..65581395 100644 --- a/frontend/pshared/lib/data/mapper/payment/enums.dart +++ b/frontend/pshared/lib/data/mapper/payment/enums.dart @@ -5,7 +5,6 @@ import 'package:pshared/models/payment/kind.dart'; import 'package:pshared/models/payment/type.dart'; import 'package:pshared/models/payment/settlement_mode.dart'; - PaymentKind paymentKindFromValue(String? value) { switch (value) { case 'payout': @@ -166,6 +165,7 @@ PaymentType endpointTypeFromValue(String? value) { case 'managedWallet': case 'managed_wallet': return PaymentType.managedWallet; + case 'cryptoAddress': case 'externalChain': case 'external_chain': return PaymentType.externalChain; @@ -193,15 +193,15 @@ String endpointTypeToValue(PaymentType type) { case PaymentType.ledger: return 'ledger'; case PaymentType.managedWallet: - return 'managed_wallet'; + return 'managedWallet'; case PaymentType.externalChain: - return 'external_chain'; + return 'cryptoAddress'; case PaymentType.card: return 'card'; case PaymentType.cardToken: - return 'card'; + return 'cardToken'; case PaymentType.bankAccount: - return 'bank_account'; + return 'bankAccount'; case PaymentType.iban: return 'iban'; case PaymentType.wallet: diff --git a/frontend/pshared/lib/data/mapper/payment/payment_response.dart b/frontend/pshared/lib/data/mapper/payment/payment_response.dart index 1c5a15fc..88b3eb02 100644 --- a/frontend/pshared/lib/data/mapper/payment/payment_response.dart +++ b/frontend/pshared/lib/data/mapper/payment/payment_response.dart @@ -1,30 +1,31 @@ import 'package:pshared/data/dto/payment/payment.dart'; import 'package:pshared/data/mapper/payment/payment_quote.dart'; import 'package:pshared/models/payment/payment.dart'; - +import 'package:pshared/models/payment/state.dart'; extension PaymentDTOMapper on PaymentDTO { Payment toDomain() => Payment( - paymentRef: paymentRef, - idempotencyKey: idempotencyKey, - state: state, - failureCode: failureCode, - failureReason: failureReason, - lastQuote: lastQuote?.toDomain(), - metadata: metadata, - createdAt: createdAt == null ? null : DateTime.tryParse(createdAt!), - ); + paymentRef: paymentRef, + idempotencyKey: idempotencyKey, + state: state, + orchestrationState: paymentOrchestrationStateFromValue(state), + failureCode: failureCode, + failureReason: failureReason, + lastQuote: lastQuote?.toDomain(), + metadata: metadata, + createdAt: createdAt == null ? null : DateTime.tryParse(createdAt!), + ); } extension PaymentMapper on Payment { PaymentDTO toDTO() => PaymentDTO( - paymentRef: paymentRef, - idempotencyKey: idempotencyKey, - state: state, - failureCode: failureCode, - failureReason: failureReason, - lastQuote: lastQuote?.toDTO(), - metadata: metadata, - createdAt: createdAt?.toUtc().toIso8601String(), - ); + paymentRef: paymentRef, + idempotencyKey: idempotencyKey, + state: state ?? paymentOrchestrationStateToValue(orchestrationState), + failureCode: failureCode, + failureReason: failureReason, + lastQuote: lastQuote?.toDTO(), + metadata: metadata, + createdAt: createdAt?.toUtc().toIso8601String(), + ); } diff --git a/frontend/pshared/lib/models/payment/payment.dart b/frontend/pshared/lib/models/payment/payment.dart index 0d1c9058..97e4d99e 100644 --- a/frontend/pshared/lib/models/payment/payment.dart +++ b/frontend/pshared/lib/models/payment/payment.dart @@ -1,10 +1,11 @@ import 'package:pshared/models/payment/quote/quote.dart'; - +import 'package:pshared/models/payment/state.dart'; class Payment { final String? paymentRef; final String? idempotencyKey; final String? state; + final PaymentOrchestrationState orchestrationState; final String? failureCode; final String? failureReason; final PaymentQuote? lastQuote; @@ -15,6 +16,7 @@ class Payment { required this.paymentRef, required this.idempotencyKey, required this.state, + required this.orchestrationState, required this.failureCode, required this.failureReason, required this.lastQuote, @@ -22,9 +24,12 @@ class Payment { required this.createdAt, }); + bool get isPending => orchestrationState.isPending; + + bool get isTerminal => orchestrationState.isTerminal; + bool get isFailure { if ((failureCode ?? '').trim().isNotEmpty) return true; - final normalized = (state ?? '').trim().toLowerCase(); - return normalized.contains('fail') || normalized.contains('cancel'); + return orchestrationState == PaymentOrchestrationState.failed; } } diff --git a/frontend/pshared/lib/models/payment/state.dart b/frontend/pshared/lib/models/payment/state.dart new file mode 100644 index 00000000..2ee711cd --- /dev/null +++ b/frontend/pshared/lib/models/payment/state.dart @@ -0,0 +1,81 @@ +enum PaymentOrchestrationState { + created, + executing, + needsAttention, + settled, + failed, + unspecified, +} + +PaymentOrchestrationState paymentOrchestrationStateFromValue(String? value) { + final normalized = _normalizePaymentState(value); + switch (normalized) { + case 'CREATED': + case 'ACCEPTED': + return PaymentOrchestrationState.created; + case 'EXECUTING': + case 'PROCESSING': + case 'FUNDS_RESERVED': + case 'SUBMITTED': + return PaymentOrchestrationState.executing; + case 'NEEDS_ATTENTION': + return PaymentOrchestrationState.needsAttention; + case 'SETTLED': + case 'SUCCESS': + return PaymentOrchestrationState.settled; + case 'FAILED': + case 'CANCELLED': + return PaymentOrchestrationState.failed; + default: + return PaymentOrchestrationState.unspecified; + } +} + +String paymentOrchestrationStateToValue(PaymentOrchestrationState state) { + switch (state) { + case PaymentOrchestrationState.created: + return 'orchestration_state_created'; + case PaymentOrchestrationState.executing: + return 'orchestration_state_executing'; + case PaymentOrchestrationState.needsAttention: + return 'orchestration_state_needs_attention'; + case PaymentOrchestrationState.settled: + return 'orchestration_state_settled'; + case PaymentOrchestrationState.failed: + return 'orchestration_state_failed'; + case PaymentOrchestrationState.unspecified: + return 'orchestration_state_unspecified'; + } +} + +extension PaymentOrchestrationStateX on PaymentOrchestrationState { + bool get isTerminal { + switch (this) { + case PaymentOrchestrationState.settled: + case PaymentOrchestrationState.failed: + return true; + case PaymentOrchestrationState.created: + case PaymentOrchestrationState.executing: + case PaymentOrchestrationState.needsAttention: + case PaymentOrchestrationState.unspecified: + return false; + } + } + + bool get isPending => !isTerminal; +} + +String _normalizePaymentState(String? value) { + final trimmed = (value ?? '').trim().toUpperCase(); + if (trimmed.isEmpty) { + return ''; + } + + if (trimmed.startsWith('ORCHESTRATION_STATE_')) { + return trimmed.substring('ORCHESTRATION_STATE_'.length); + } + if (trimmed.startsWith('PAYMENT_STATE_')) { + return trimmed.substring('PAYMENT_STATE_'.length); + } + return trimmed; +} diff --git a/frontend/pshared/lib/models/storable.dart b/frontend/pshared/lib/models/storable.dart index dfd59e3f..da8baf49 100644 --- a/frontend/pshared/lib/models/storable.dart +++ b/frontend/pshared/lib/models/storable.dart @@ -1,12 +1,11 @@ import 'package:flutter/foundation.dart'; -import 'package:pshared/config/web.dart'; - +import 'package:pshared/config/constants.dart'; abstract class Storable { String get id; DateTime get createdAt; - DateTime get updatedAt; + DateTime get updatedAt; } @immutable @@ -23,11 +22,11 @@ class _StorableImp implements Storable { required this.createdAt, required this.updatedAt, }); - } -Storable newStorable({String? id, DateTime? createdAt, DateTime? updatedAt}) => _StorableImp( - id: id ?? Constants.nilObjectRef, - createdAt: createdAt ?? DateTime.now().toUtc(), - updatedAt: updatedAt ?? DateTime.now().toUtc(), -); +Storable newStorable({String? id, DateTime? createdAt, DateTime? updatedAt}) => + _StorableImp( + id: id ?? Constants.nilObjectRef, + createdAt: createdAt ?? DateTime.now().toUtc(), + updatedAt: updatedAt ?? DateTime.now().toUtc(), + ); diff --git a/frontend/pshared/lib/provider/payment/payments.dart b/frontend/pshared/lib/provider/payment/payments.dart index a5a3ba60..7bcc44e9 100644 --- a/frontend/pshared/lib/provider/payment/payments.dart +++ b/frontend/pshared/lib/provider/payment/payments.dart @@ -8,7 +8,6 @@ import 'package:pshared/provider/resource.dart'; import 'package:pshared/service/payment/service.dart'; import 'package:pshared/utils/exception.dart'; - class PaymentsProvider with ChangeNotifier { static const Duration _pendingRefreshInterval = Duration(seconds: 10); @@ -20,8 +19,9 @@ class PaymentsProvider with ChangeNotifier { bool _isLoadingMore = false; String? _nextCursor; int? _limit; - String? _sourceRef; - String? _destinationRef; + String? _quotationRef; + DateTime? _createdFrom; + DateTime? _createdTo; List? _states; int _opSeq = 0; @@ -32,7 +32,8 @@ class PaymentsProvider with ChangeNotifier { List get payments => _resource.data ?? []; bool get isLoading => _resource.isLoading; Exception? get error => _resource.error; - bool get isReady => _isLoaded && !_resource.isLoading && _resource.error == null; + bool get isReady => + _isLoaded && !_resource.isLoading && _resource.error == null; bool get isLoadingMore => _isLoadingMore; String? get nextCursor => _nextCursor; @@ -54,14 +55,16 @@ class PaymentsProvider with ChangeNotifier { Future refresh({ int? limit, - String? sourceRef, - String? destinationRef, + String? quotationRef, + DateTime? createdFrom, + DateTime? createdTo, List? states, }) async { await _refresh( limit: limit, - sourceRef: sourceRef, - destinationRef: destinationRef, + quotationRef: quotationRef, + createdFrom: createdFrom, + createdTo: createdTo, states: states, showLoading: true, updateError: true, @@ -70,14 +73,16 @@ class PaymentsProvider with ChangeNotifier { Future refreshSilently({ int? limit, - String? sourceRef, - String? destinationRef, + String? quotationRef, + DateTime? createdFrom, + DateTime? createdTo, List? states, }) async { await _refresh( limit: limit, - sourceRef: sourceRef, - destinationRef: destinationRef, + quotationRef: quotationRef, + createdFrom: createdFrom, + createdTo: createdTo, states: states, showLoading: false, updateError: false, @@ -87,10 +92,7 @@ class PaymentsProvider with ChangeNotifier { void mergePayments(List incoming) { if (incoming.isEmpty) return; final existing = List.from(_resource.data ?? const []); - final combined = [ - ...incoming, - ...existing, - ]; + final combined = [...incoming, ...existing]; final seen = {}; final merged = []; @@ -110,8 +112,9 @@ class PaymentsProvider with ChangeNotifier { Future _refresh({ int? limit, - String? sourceRef, - String? destinationRef, + String? quotationRef, + DateTime? createdFrom, + DateTime? createdTo, List? states, required bool showLoading, required bool updateError, @@ -120,8 +123,9 @@ class PaymentsProvider with ChangeNotifier { if (org == null || !org.isOrganizationSet) return; _limit = limit; - _sourceRef = _normalize(sourceRef); - _destinationRef = _normalize(destinationRef); + _quotationRef = _normalize(quotationRef); + _createdFrom = createdFrom?.toUtc(); + _createdTo = createdTo?.toUtc(); _states = _normalizeStates(states); _nextCursor = null; _isLoadingMore = false; @@ -129,7 +133,10 @@ class PaymentsProvider with ChangeNotifier { final seq = ++_opSeq; if (showLoading) { - _applyResource(_resource.copyWith(isLoading: true, error: null), notify: true); + _applyResource( + _resource.copyWith(isLoading: true, error: null), + notify: true, + ); } try { @@ -137,8 +144,9 @@ class PaymentsProvider with ChangeNotifier { org.current.id, limit: _limit, cursor: null, - sourceRef: _sourceRef, - destinationRef: _destinationRef, + quotationRef: _quotationRef, + createdFrom: _createdFrom, + createdTo: _createdTo, states: _states, ); @@ -147,11 +155,7 @@ class PaymentsProvider with ChangeNotifier { _isLoaded = true; _nextCursor = _normalize(page.nextCursor); _applyResource( - Resource( - data: page.items, - isLoading: false, - error: null, - ), + Resource(data: page.items, isLoading: false, error: null), notify: true, ); } catch (e) { @@ -162,10 +166,7 @@ class PaymentsProvider with ChangeNotifier { notify: true, ); } else if (showLoading) { - _applyResource( - _resource.copyWith(isLoading: false), - notify: true, - ); + _applyResource(_resource.copyWith(isLoading: false), notify: true); } } } @@ -189,8 +190,9 @@ class PaymentsProvider with ChangeNotifier { org.current.id, limit: _limit, cursor: cursor, - sourceRef: _sourceRef, - destinationRef: _destinationRef, + quotationRef: _quotationRef, + createdFrom: _createdFrom, + createdTo: _createdTo, states: _states, ); @@ -206,10 +208,7 @@ class PaymentsProvider with ChangeNotifier { } catch (e) { if (seq != _opSeq) return; - _applyResource( - _resource.copyWith(error: toException(e)), - notify: false, - ); + _applyResource(_resource.copyWith(error: toException(e)), notify: false); } finally { if (seq == _opSeq) { _isLoadingMore = false; @@ -224,15 +223,19 @@ class PaymentsProvider with ChangeNotifier { _isLoadingMore = false; _nextCursor = null; _limit = null; - _sourceRef = null; - _destinationRef = null; + _quotationRef = null; + _createdFrom = null; + _createdTo = null; _states = null; _resource = Resource(data: []); _stopPendingRefreshTimer(); notifyListeners(); } - void _applyResource(Resource> newResource, {required bool notify}) { + void _applyResource( + Resource> newResource, { + required bool notify, + }) { _resource = newResource; _syncPendingRefresh(); if (notify) notifyListeners(); @@ -253,15 +256,15 @@ class PaymentsProvider with ChangeNotifier { List? _normalizeStates(List? states) { if (states == null || states.isEmpty) return null; final normalized = states - .map((state) => state.trim()) - .where((state) => state.isNotEmpty) - .toList(); + .map((state) => state.trim()) + .where((state) => state.isNotEmpty) + .toList(); if (normalized.isEmpty) return null; return normalized; } void _syncPendingRefresh() { - final hasPending = payments.any(_isPending); + final hasPending = payments.any((payment) => payment.isPending); if (!hasPending) { _stopPendingRefreshTimer(); return; @@ -286,8 +289,9 @@ class PaymentsProvider with ChangeNotifier { try { await refreshSilently( limit: _limit, - sourceRef: _sourceRef, - destinationRef: _destinationRef, + quotationRef: _quotationRef, + createdFrom: _createdFrom, + createdTo: _createdTo, states: _states, ); } finally { @@ -301,29 +305,9 @@ class PaymentsProvider with ChangeNotifier { _isPendingRefreshInFlight = false; } - bool _isPending(Payment payment) { - final raw = payment.state; - final trimmed = (raw ?? '').trim().toUpperCase(); - final normalized = trimmed.startsWith('PAYMENT_STATE_') - ? trimmed.substring('PAYMENT_STATE_'.length) - : trimmed; - - switch (normalized) { - case 'SUCCESS': - case 'FAILED': - case 'CANCELLED': - return false; - case 'PROCESSING': - return true; - default: - return true; - } - } - @override void dispose() { _stopPendingRefreshTimer(); super.dispose(); } - } diff --git a/frontend/pshared/lib/service/authorization/service.dart b/frontend/pshared/lib/service/authorization/service.dart index 817319bc..cf6bb346 100644 --- a/frontend/pshared/lib/service/authorization/service.dart +++ b/frontend/pshared/lib/service/authorization/service.dart @@ -6,7 +6,7 @@ import 'package:pshared/api/requests/login_data.dart'; import 'package:pshared/api/responses/account.dart'; import 'package:pshared/api/responses/login.dart'; import 'package:pshared/api/responses/login_pending.dart'; -import 'package:pshared/config/web.dart'; +import 'package:pshared/config/constants.dart'; import 'package:pshared/data/mapper/account/account.dart'; import 'package:pshared/models/account/account.dart'; import 'package:pshared/models/auth/login_outcome.dart'; @@ -31,21 +31,33 @@ class AuthorizationService { final deviceId = await DeviceIdManager.getDeviceId(); final response = await httpr.getPOSTResponse( service, - '/login', - LoginRequest(login: login, deviceId: deviceId, clientId: Constants.clientId).toJson(), + '/login', + LoginRequest( + login: login, + deviceId: deviceId, + clientId: Constants.clientId, + ).toJson(), ); if (response.containsKey('refreshToken')) { - return LoginOutcome.completed((await completeLogin(response)).account.toDomain()); + return LoginOutcome.completed( + (await completeLogin(response)).account.toDomain(), + ); } if (response.containsKey('pendingToken')) { final pending = PendingLogin.fromResponse( PendingLoginResponse.fromJson(response), - session: SessionIdentifier(clientId: Constants.clientId, deviceId: deviceId), + session: SessionIdentifier( + clientId: Constants.clientId, + deviceId: deviceId, + ), ); return LoginOutcome.pending(pending); } - throw AuthenticationFailedException('Unexpected login response', Exception(response.toString())); + throw AuthenticationFailedException( + 'Unexpected login response', + Exception(response.toString()), + ); } static Future _updateAccessToken(AccountResponse response) async { @@ -57,13 +69,16 @@ class AuthorizationService { return AuthorizationStorage.updateRefreshToken(response.refreshToken); } - static Future _completeLogin(Map response) async { + static Future _completeLogin( + Map response, + ) async { final LoginResponse lr = LoginResponse.fromJson(response); await _updateTokens(lr); return lr; } - static Future completeLogin(Map response) => _completeLogin(response); + static Future completeLogin(Map response) => + _completeLogin(response); static Future restore() async { return (await TokenService.refreshAccessToken()).account.toDomain(); @@ -74,82 +89,123 @@ class AuthorizationService { } // Original AuthorizationService methods - keeping the interface unchanged - static Future> getGETResponse(String service, String url) async { + static Future> getGETResponse( + String service, + String url, + ) async { final token = await TokenService.getAccessTokenSafe(); return httpr.getGETResponse(service, url, authToken: token); } - static Future getGETBinaryResponse(String service, String url) async { + static Future getGETBinaryResponse( + String service, + String url, + ) async { final token = await TokenService.getAccessTokenSafe(); return httpr.getBinaryGETResponse(service, url, authToken: token); } - static Future> getPOSTResponse(String service, String url, Map body) async { + static Future> getPOSTResponse( + String service, + String url, + Map body, + ) async { final token = await TokenService.getAccessTokenSafe(); return httpr.getPOSTResponse(service, url, body, authToken: token); } - static Future> getPUTResponse(String service, String url, Map body) async { + static Future> getPUTResponse( + String service, + String url, + Map body, + ) async { final token = await TokenService.getAccessTokenSafe(); return httpr.getPUTResponse(service, url, body, authToken: token); } - static Future> getPATCHResponse(String service, String url, Map body) async { + static Future> getPATCHResponse( + String service, + String url, + Map body, + ) async { final token = await TokenService.getAccessTokenSafe(); return httpr.getPATCHResponse(service, url, body, authToken: token); } - static Future> getDELETEResponse(String service, String url, Map body) async { + static Future> getDELETEResponse( + String service, + String url, + Map body, + ) async { final token = await TokenService.getAccessTokenSafe(); return httpr.getDELETEResponse(service, url, body, authToken: token); } - static Future getFileUploadResponseAuth(String service, String url, String fileName, String fileType, String mediaType, List bytes) async { + static Future getFileUploadResponseAuth( + String service, + String url, + String fileName, + String fileType, + String mediaType, + List bytes, + ) async { final token = await TokenService.getAccessTokenSafe(); - final res = await httpr.getFileUploadResponse(service, url, fileName, fileType, mediaType, bytes, authToken: token); + final res = await httpr.getFileUploadResponse( + service, + url, + fileName, + fileType, + mediaType, + bytes, + authToken: token, + ); if (res == null) { throw Exception('Upload failed'); } return res.url; } - static Future isAuthorizationStored() async => AuthorizationStorage.isAuthorizationStored(); + static Future isAuthorizationStored() async => + AuthorizationStorage.isAuthorizationStored(); /// Execute an operation with automatic token management and retry logic static Future executeWithAuth( Future Function() operation, String description, { int? maxRetries, - }) async => AuthCircuitBreaker.execute(() async => RetryHelper.withExponentialBackoff( - operation, - maxRetries: maxRetries ?? 3, - initialDelay: Duration(milliseconds: 100), - maxDelay: Duration(seconds: 5), - shouldRetry: (error) => RetryHelper.isRetryableError(error), - )); - + }) async => AuthCircuitBreaker.execute( + () async => RetryHelper.withExponentialBackoff( + operation, + maxRetries: maxRetries ?? 3, + initialDelay: Duration(milliseconds: 100), + maxDelay: Duration(seconds: 5), + shouldRetry: (error) => RetryHelper.isRetryableError(error), + ), + ); /// Handle 401 unauthorized errors with automatic token recovery static Future handleUnauthorized( Future Function() operation, String description, ) async { - _logger.warning('Handling unauthorized error with token recovery: $description'); - - return executeWithAuth( - () async { - try { - // Attempt token recovery first - await TokenService.handleUnauthorized(); - - // Retry the original operation - return await operation(); - } catch (e) { - _logger.severe('Token recovery failed', e); - throw AuthenticationFailedException('Token recovery failed', toException(e)); - } - }, - 'unauthorized recovery: $description', + _logger.warning( + 'Handling unauthorized error with token recovery: $description', ); + + return executeWithAuth(() async { + try { + // Attempt token recovery first + await TokenService.handleUnauthorized(); + + // Retry the original operation + return await operation(); + } catch (e) { + _logger.severe('Token recovery failed', e); + throw AuthenticationFailedException( + 'Token recovery failed', + toException(e), + ); + } + }, 'unauthorized recovery: $description'); } } diff --git a/frontend/pshared/lib/service/device_id.dart b/frontend/pshared/lib/service/device_id.dart index d6cf6351..eb3cb6b7 100644 --- a/frontend/pshared/lib/service/device_id.dart +++ b/frontend/pshared/lib/service/device_id.dart @@ -2,10 +2,9 @@ import 'package:uuid/uuid.dart'; import 'package:logging/logging.dart'; -import 'package:pshared/config/web.dart'; +import 'package:pshared/config/constants.dart'; import 'package:pshared/service/secure_storage.dart'; - class DeviceIdManager { static final _logger = Logger('service.device_id'); @@ -15,7 +14,7 @@ class DeviceIdManager { if (deviceId == null) { _logger.fine('Device id is not set, generating new'); - deviceId = (const Uuid()).v4(); + deviceId = (const Uuid()).v4(); await SecureStorageService.set(_key, deviceId); } diff --git a/frontend/pshared/lib/service/payment/service.dart b/frontend/pshared/lib/service/payment/service.dart index 0c5856ea..844fc95d 100644 --- a/frontend/pshared/lib/service/payment/service.dart +++ b/frontend/pshared/lib/service/payment/service.dart @@ -12,7 +12,6 @@ import 'package:pshared/service/authorization/service.dart'; import 'package:pshared/service/services.dart'; import 'package:pshared/utils/http/params.dart'; - class PaymentService { static final _logger = Logger('service.payment'); static const String _objectType = Services.payments; @@ -21,17 +20,21 @@ class PaymentService { String organizationRef, { int? limit, String? cursor, - String? sourceRef, - String? destinationRef, + String? quotationRef, + DateTime? createdFrom, + DateTime? createdTo, List? states, }) async { _logger.fine('Listing payments for organization $organizationRef'); final queryParams = {}; - if (sourceRef != null && sourceRef.isNotEmpty) { - queryParams['source_ref'] = sourceRef; + if (quotationRef != null && quotationRef.isNotEmpty) { + queryParams['quotation_ref'] = quotationRef; } - if (destinationRef != null && destinationRef.isNotEmpty) { - queryParams['destination_ref'] = destinationRef; + if (createdFrom != null) { + queryParams['created_from'] = createdFrom.toUtc().toIso8601String(); + } + if (createdTo != null) { + queryParams['created_to'] = createdTo.toUtc().toIso8601String(); } if (states != null && states.isNotEmpty) { queryParams['state'] = states.join(','); @@ -43,9 +46,14 @@ class PaymentService { cursor: cursor, queryParams: queryParams, ); - final response = await AuthorizationService.getGETResponse(_objectType, url); + final response = await AuthorizationService.getGETResponse( + _objectType, + url, + ); final parsed = PaymentsResponse.fromJson(response); - final payments = parsed.payments.map((payment) => payment.toDomain()).toList(); + final payments = parsed.payments + .map((payment) => payment.toDomain()) + .toList(); return PaymentPage(items: payments, nextCursor: parsed.nextCursor); } @@ -53,16 +61,18 @@ class PaymentService { String organizationRef, { int? limit, String? cursor, - String? sourceRef, - String? destinationRef, + String? quotationRef, + DateTime? createdFrom, + DateTime? createdTo, List? states, }) async { final page = await listPage( organizationRef, limit: limit, cursor: cursor, - sourceRef: sourceRef, - destinationRef: destinationRef, + quotationRef: quotationRef, + createdFrom: createdFrom, + createdTo: createdTo, states: states, ); return page.items; @@ -74,7 +84,9 @@ class PaymentService { String? idempotencyKey, Map? metadata, }) async { - _logger.fine('Executing payment for quotation $quotationRef in $organizationRef'); + _logger.fine( + 'Executing payment for quotation $quotationRef in $organizationRef', + ); final request = InitiatePaymentRequest( idempotencyKey: idempotencyKey ?? Uuid().v4(), quoteRef: quotationRef, @@ -87,5 +99,4 @@ class PaymentService { ); return PaymentResponse.fromJson(response).payment.toDomain(); } - } diff --git a/frontend/pshared/test/payment/payment_state_model_test.dart b/frontend/pshared/test/payment/payment_state_model_test.dart new file mode 100644 index 00000000..f461951a --- /dev/null +++ b/frontend/pshared/test/payment/payment_state_model_test.dart @@ -0,0 +1,122 @@ +import 'package:pshared/models/payment/payment.dart'; +import 'package:pshared/models/payment/state.dart'; +import 'package:test/test.dart'; + +void main() { + group('PaymentOrchestrationState parser', () { + test('maps v2 orchestration states', () { + expect( + paymentOrchestrationStateFromValue('orchestration_state_created'), + PaymentOrchestrationState.created, + ); + expect( + paymentOrchestrationStateFromValue('ORCHESTRATION_STATE_EXECUTING'), + PaymentOrchestrationState.executing, + ); + expect( + paymentOrchestrationStateFromValue( + 'orchestration_state_needs_attention', + ), + PaymentOrchestrationState.needsAttention, + ); + expect( + paymentOrchestrationStateFromValue('orchestration_state_settled'), + PaymentOrchestrationState.settled, + ); + expect( + paymentOrchestrationStateFromValue('orchestration_state_failed'), + PaymentOrchestrationState.failed, + ); + }); + + test('maps legacy payment states for compatibility', () { + expect( + paymentOrchestrationStateFromValue('payment_state_accepted'), + PaymentOrchestrationState.created, + ); + expect( + paymentOrchestrationStateFromValue('payment_state_submitted'), + PaymentOrchestrationState.executing, + ); + expect( + paymentOrchestrationStateFromValue('payment_state_settled'), + PaymentOrchestrationState.settled, + ); + expect( + paymentOrchestrationStateFromValue('payment_state_cancelled'), + PaymentOrchestrationState.failed, + ); + }); + + test('unknown state maps to unspecified', () { + expect( + paymentOrchestrationStateFromValue('something_else'), + PaymentOrchestrationState.unspecified, + ); + expect( + paymentOrchestrationStateFromValue(null), + PaymentOrchestrationState.unspecified, + ); + }); + }); + + group('Payment model state helpers', () { + test('isPending and isTerminal are derived from typed state', () { + const created = Payment( + paymentRef: 'p-1', + idempotencyKey: 'idem-1', + state: 'orchestration_state_created', + orchestrationState: PaymentOrchestrationState.created, + failureCode: null, + failureReason: null, + lastQuote: null, + metadata: null, + createdAt: null, + ); + const settled = Payment( + paymentRef: 'p-2', + idempotencyKey: 'idem-2', + state: 'orchestration_state_settled', + orchestrationState: PaymentOrchestrationState.settled, + failureCode: null, + failureReason: null, + lastQuote: null, + metadata: null, + createdAt: null, + ); + + expect(created.isPending, isTrue); + expect(created.isTerminal, isFalse); + expect(settled.isPending, isFalse); + expect(settled.isTerminal, isTrue); + }); + + test('isFailure handles both explicit code and failed state', () { + const withFailureCode = Payment( + paymentRef: 'p-3', + idempotencyKey: 'idem-3', + state: 'orchestration_state_executing', + orchestrationState: PaymentOrchestrationState.executing, + failureCode: 'failure_ledger', + failureReason: 'ledger failed', + lastQuote: null, + metadata: null, + createdAt: null, + ); + const failedState = Payment( + paymentRef: 'p-4', + idempotencyKey: 'idem-4', + state: 'orchestration_state_failed', + orchestrationState: PaymentOrchestrationState.failed, + failureCode: null, + failureReason: null, + lastQuote: null, + metadata: null, + createdAt: null, + ); + + expect(withFailureCode.isFailure, isTrue); + expect(failedState.isFailure, isTrue); + }); + }); +} diff --git a/frontend/pshared/test/payment/request_dto_format_test.dart b/frontend/pshared/test/payment/request_dto_format_test.dart new file mode 100644 index 00000000..8dae1812 --- /dev/null +++ b/frontend/pshared/test/payment/request_dto_format_test.dart @@ -0,0 +1,113 @@ +import 'dart:convert'; + +import 'package:test/test.dart'; + +import 'package:pshared/api/requests/payment/initiate.dart'; +import 'package:pshared/api/requests/payment/initiate_payments.dart'; +import 'package:pshared/api/requests/payment/quote.dart'; +import 'package:pshared/data/dto/money.dart'; +import 'package:pshared/data/dto/payment/endpoint.dart'; +import 'package:pshared/data/dto/payment/intent/payment.dart'; +import 'package:pshared/data/mapper/payment/payment.dart'; +import 'package:pshared/models/payment/asset.dart'; +import 'package:pshared/models/payment/chain_network.dart'; +import 'package:pshared/models/payment/methods/card_token.dart'; +import 'package:pshared/models/payment/methods/crypto_address.dart'; +import 'package:pshared/models/payment/methods/managed_wallet.dart'; + +void main() { + group('Payment request DTO contract', () { + test('serializes endpoint types to backend canonical values', () { + final managed = ManagedWalletPaymentMethod( + managedWalletRef: 'mw-1', + ).toDTO(); + final external = CryptoAddressPaymentMethod( + asset: const PaymentAsset( + chain: ChainNetwork.tronMainnet, + tokenSymbol: 'USDT', + ), + address: 'TXYZ', + ).toDTO(); + final cardToken = CardTokenPaymentMethod( + token: 'tok_1', + maskedPan: '4111', + ).toDTO(); + + expect(managed.type, equals('managedWallet')); + expect(external.type, equals('cryptoAddress')); + expect(cardToken.type, equals('cardToken')); + }); + + test('quote payment request uses expected backend field names', () { + final request = QuotePaymentRequest( + idempotencyKey: 'idem-1', + previewOnly: true, + intent: const PaymentIntentDTO( + kind: 'payout', + source: PaymentEndpointDTO( + type: 'ledger', + data: {'ledger_account_ref': 'ledger:src'}, + ), + destination: PaymentEndpointDTO( + type: 'cardToken', + data: {'token': 'tok_1', 'masked_pan': '4111'}, + ), + amount: MoneyDTO(amount: '10', currency: 'USD'), + settlementMode: 'fix_received', + settlementCurrency: 'USD', + ), + ); + + final json = + jsonDecode(jsonEncode(request.toJson())) as Map; + + expect(json['idempotencyKey'], equals('idem-1')); + expect(json['previewOnly'], isTrue); + expect(json['intent'], isA>()); + + final intent = json['intent'] as Map; + expect(intent['kind'], equals('payout')); + expect(intent['settlement_mode'], equals('fix_received')); + expect(intent['settlement_currency'], equals('USD')); + + final source = intent['source'] as Map; + final destination = intent['destination'] as Map; + expect(source['type'], equals('ledger')); + expect(destination['type'], equals('cardToken')); + }); + + test('initiate payment by quote keeps expected fields', () { + final request = InitiatePaymentRequest( + idempotencyKey: 'idem-2', + quoteRef: 'q-1', + metadata: const {'intent_ref': 'intent-1'}, + ); + + final json = request.toJson(); + expect(json['idempotencyKey'], equals('idem-2')); + expect(json['quoteRef'], equals('q-1')); + expect( + (json['metadata'] as Map)['intent_ref'], + equals('intent-1'), + ); + expect(json.containsKey('intent'), isTrue); + expect(json['intent'], isNull); + }); + + test('initiate multi payments request keeps expected fields', () { + final request = InitiatePaymentsRequest( + idempotencyKey: 'idem-3', + quoteRef: 'q-2', + metadata: const {'client_payment_ref': 'cp-1'}, + ); + + final json = request.toJson(); + expect(json['idempotencyKey'], equals('idem-3')); + expect(json['quoteRef'], equals('q-2')); + expect( + (json['metadata'] as Map)['client_payment_ref'], + equals('cp-1'), + ); + }); + }); +} diff --git a/frontend/pweb/test/widget_test.dart b/frontend/pweb/test/widget_test.dart index 045c8617..faff1a83 100644 --- a/frontend/pweb/test/widget_test.dart +++ b/frontend/pweb/test/widget_test.dart @@ -1,30 +1,33 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; import 'package:pweb/app/app.dart'; +import 'package:pweb/providers/account.dart'; +import 'package:pweb/providers/locale.dart'; +import 'package:pshared/provider/account.dart'; +import 'package:pshared/provider/locale.dart'; void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const PayApp()); + testWidgets('PayApp builds with required providers', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider( + create: (_) => PwebLocaleProvider(null), + ), + ChangeNotifierProxyProvider( + create: (_) => PwebAccountProvider(), + update: (context, localeProvider, provider) => + provider!..updateProvider(localeProvider), + ), + ], + child: const PayApp(), + ), + ); - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); - - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); - - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); + await tester.pumpAndSettle(); + expect(find.byType(PayApp), findsOneWidget); }); }