Compare commits
54 Commits
709df51512
...
SEND062
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75f3678b90 | ||
| 4fa641f971 | |||
|
|
eb8b7b3402 | ||
| 3a8935f5f0 | |||
|
|
d92be5eedc | ||
| 94406373e6 | |||
| eda5bf19ad | |||
|
|
5629f5fcb2 | ||
| 95f7698661 | |||
|
|
4e70873a94 | ||
|
|
de07b9a792 | ||
| 9b794a3065 | |||
|
|
56bf49aa03 | ||
|
|
8377b6b2af | ||
| f06208348b | |||
|
|
b4c09cfb3b | ||
| 00812fa2bd | |||
|
|
ce5f90939f | ||
| 1b40b173eb | |||
| cd8e8071a9 | |||
|
|
f7a1027de7 | ||
| c5b3dfbd7a | |||
|
|
41cb826d26 | ||
|
|
51c72a87ae | ||
|
|
3f578353da | ||
| 7cac494509 | |||
|
|
d8f0febc5e | ||
| 34a8a5d057 | |||
|
|
83745bcd10 | ||
|
|
f9acb47ad7 | ||
| b2cc3fe980 | |||
|
|
d28e8615a9 | ||
| 3d1157a5d3 | |||
|
|
bae4cd6e35 | ||
| bd79eb016a | |||
|
|
b10ec79fe0 | ||
| 4b57550c36 | |||
|
|
0f0529c445 | ||
| 01c4108157 | |||
|
|
3c6cffdf33 | ||
| 82bab11a8f | |||
|
|
2f77d9d972 | ||
| 7559d4d09b | |||
| a1e739ba52 | |||
|
|
2be76aa519 | ||
|
|
6bb3ab5063 | ||
| 17e08ff26f | |||
|
|
e5ba048c73 | ||
| ddd5e36275 | |||
|
|
ce23de94ce | ||
| 1005201bb7 | |||
|
|
38077c1ed8 | ||
| d0368f5a00 | |||
|
|
86eab3bb70 |
@@ -4,6 +4,7 @@ matrix:
|
||||
BFF_DOCKERFILE: ci/prod/compose/bff.dockerfile
|
||||
BFF_MONGO_SECRET_PATH: sendico/db
|
||||
BFF_API_SECRET_PATH: sendico/api/endpoint
|
||||
BFF_VAULT_SECRET_PATH: sendico/edge/bff/vault
|
||||
BFF_ENV: prod
|
||||
|
||||
when:
|
||||
|
||||
@@ -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.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.2
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.3
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.11
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.11
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.3
|
||||
github.com/jung-kurt/gofpdf v1.16.2
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/shopspring/decimal v1.4.0
|
||||
@@ -20,20 +20,20 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
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.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.10 // 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/aws-sdk-go-v2/aws/protocol/eventstream v1.7.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.19 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.11 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.19 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.7 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.12 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.8 // indirect
|
||||
github.com/aws/smithy-go v1.24.2 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect
|
||||
|
||||
@@ -4,42 +4,42 @@ 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.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.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.10 h1:fJvQ5mIBVfKtiyx0AHY6HeWcRX5LGANLpq8SVR+Uazs=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.10/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.2 h1:M1A9AjcFwlxTLuf0Faj88L8Iqw0n/AJHjpZTQzMMsSc=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.2/go.mod h1:KsdTV6Q9WKUZm2mNJnUFmIoXfZux91M3sr/a4REX8e0=
|
||||
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/aws-sdk-go-v2 v1.41.3 h1:4kQ/fa22KjDt13QCy1+bYADvdgcxpfH18f0zP542kZA=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.3/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.6 h1:N4lRUXZpZ1KVEUn6hxtco/1d2lgYhNn1fHkkl8WhlyQ=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.6/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.11 h1:ftxI5sgz8jZkckuUHXfC/wMUc8u3fG1vQS0plr2F2Zs=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.11/go.mod h1:twF11+6ps9aNRKEDimksp923o44w/Thk9+8YIlzWMmo=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.11 h1:NdV8cwCcAXrCWyxArt58BrvZJ9pZ9Fhf9w6Uh5W3Uyc=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.11/go.mod h1:30yY2zqkMPdrvxBqzI9xQCM+WrlrZKSOpSJEsylVU+8=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19 h1:INUvJxmhdEbVulJYHI061k4TVuS3jzzthNvjqvVvTKM=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19/go.mod h1:FpZN2QISLdEBWkayloda+sZjVJL+e9Gl0k1SyTgcswU=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19 h1:/sECfyq2JTifMI2JPyZ4bdRN77zJmr6SrS1eL3augIA=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19/go.mod h1:dMf8A5oAqr9/oxOfLkC/c2LU/uMcALP0Rgn2BD5LWn0=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19 h1:AWeJMk33GTBf6J20XJe6qZoRSJo0WfUhsMdUKhoODXE=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19/go.mod h1:+GWrYoaAsV7/4pNHpwh1kiNLXkKaSoppxQq9lbH8Ejw=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5 h1:clHU5fm//kWS1C2HgtgWxfQbFbx4b6rx+5jzhgX9HrI=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.19 h1:3Y4oma5TiV7tT9wa8zRcdoXwZkGz9Q/wxbEUK7cMuAM=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.19/go.mod h1:V1K+TeJVD5JOk3D9e5tsX2KUdL7BlB+FV6cBhdobN8c=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6 h1:XAq62tBTJP/85lFD5oqOOe7YYgWxY9LvWq8plyDvDVg=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.11 h1:BYf7XNsJMzl4mObARUBUib+j2tf0U//JAAtTnYqvqCw=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.11/go.mod h1:aEUS4WrNk/+FxkBZZa7tVgp4pGH+kFGW40Y8rCPqt5g=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19 h1:X1Tow7suZk9UCJHE1Iw9GMZJJl0dAnKXXP1NaSDHwmw=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19/go.mod h1:/rARO8psX+4sfjUQXp5LLifjUt8DuATZ31WptNJTyQA=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.19 h1:JnQeStZvPHFHeyky/7LbMlyQjUa+jIBj36OlWm0pzIk=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.19/go.mod h1:HGyasyHvYdFQeJhvDHfH7HXkHh57htcJGKDZ+7z+I24=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.3 h1:+d0SsTvxtIJt4tSJ6wr+jrxEMDa6XeupjRv8H7Qitkk=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.3/go.mod h1:ROUNFvFWPwBlOu687WJNQ9cPvd2ccpFrnCiA1YGz50o=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.7 h1:Y2cAXlClHsXkkOvWZFXATr34b0hxxloeQu/pAZz2row=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.7/go.mod h1:idzZ7gmDeqeNrSPkdbtMp9qWMgcBwykA7P7Rzh5DXVU=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.12 h1:iSsvB9EtQ09YrsmIc44Heqlx5ByGErqhPK1ZQLppias=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.12/go.mod h1:fEWYKTRGoZNl8tZ77i61/ccwOMJdGxwOhWCkp6TXAr0=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16 h1:EnUdUqRP1CNzt2DkV67tJx6XDN4xlfBFm+bzeNOQVb0=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16/go.mod h1:Jic/xv0Rq/pFNCh3WwpH4BEqdbSAl+IyHro8LbibHD8=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.8 h1:XQTQTF75vnug2TXS8m7CVJfC2nniYPZnO1D4Np761Oo=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.8/go.mod h1:Xgx+PR1NUOjNmQY+tRMnouRp83JRM8pRMw/vCaVhPkI=
|
||||
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
|
||||
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
|
||||
@@ -391,7 +391,7 @@ func (s *Service) startDiscoveryAnnouncer() {
|
||||
}
|
||||
|
||||
announce := discovery.Announcement{
|
||||
Service: "BILLING_DOCUMENTS",
|
||||
Service: mservice.BillingDocuments,
|
||||
Operations: []string{discovery.OperationDocumentsBatchResolve, discovery.OperationDocumentsGet},
|
||||
InvokeURI: s.invokeURI,
|
||||
Version: appversion.Create().Short(),
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
msg "github.com/tech/sendico/pkg/messaging"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
"github.com/tech/sendico/pkg/mutil/mzap"
|
||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
@@ -385,7 +386,7 @@ func (s *Service) computeQuoteWithTime(ctx context.Context, orgRef bson.ObjectID
|
||||
|
||||
logFields := []zap.Field{zap.Time("booked_at_used", bookedAt)}
|
||||
if !orgRef.IsZero() {
|
||||
logFields = append(logFields, zap.String("organization_ref", orgRef.Hex()))
|
||||
logFields = append(logFields, mzap.ObjRef("organization_ref", orgRef))
|
||||
}
|
||||
|
||||
logFields = append(logFields, logFieldsFromIntent(intent)...)
|
||||
@@ -563,7 +564,7 @@ func (s *Service) startDiscoveryAnnouncer() {
|
||||
}
|
||||
|
||||
announce := discovery.Announcement{
|
||||
Service: "BILLING_FEES",
|
||||
Service: mservice.BillingFees,
|
||||
Operations: []string{discovery.OperationFeeCalc},
|
||||
InvokeURI: s.invokeURI,
|
||||
Version: appversion.Create().Short(),
|
||||
|
||||
@@ -49,7 +49,7 @@ func (i *Imp) startDiscovery(cfg *config) error {
|
||||
i.registrySvc = svc
|
||||
|
||||
announce := discovery.Announcement{
|
||||
Service: "DISCOVERY",
|
||||
Service: mservice.Discovery,
|
||||
InstanceID: discovery.InstanceID(),
|
||||
Operations: []string{discovery.OperationDiscoveryLookup},
|
||||
Version: appversion.Create().Short(),
|
||||
|
||||
@@ -109,6 +109,19 @@ api:
|
||||
dial_timeout_seconds: 5
|
||||
call_timeout_seconds: 5
|
||||
insecure: true
|
||||
callbacks:
|
||||
default_event_types:
|
||||
- payment.status.updated
|
||||
default_status: active
|
||||
secret_path_prefix: sendico/callbacks
|
||||
secret_field: value
|
||||
secret_length_bytes: 32
|
||||
vault:
|
||||
address: "http://dev-vault:8200"
|
||||
token_env: VAULT_TOKEN
|
||||
token_file_env: VAULT_TOKEN_FILE
|
||||
namespace: ""
|
||||
mount_path: kv
|
||||
|
||||
app:
|
||||
|
||||
|
||||
@@ -111,6 +111,19 @@ api:
|
||||
dial_timeout_seconds: 5
|
||||
call_timeout_seconds: 5
|
||||
insecure: true
|
||||
callbacks:
|
||||
default_event_types:
|
||||
- payment.status.updated
|
||||
default_status: active
|
||||
secret_path_prefix: sendico/callbacks
|
||||
secret_field: value
|
||||
secret_length_bytes: 32
|
||||
vault:
|
||||
address: "https://vault.sendico.io"
|
||||
token_env: VAULT_TOKEN
|
||||
token_file_env: VAULT_TOKEN_FILE
|
||||
namespace: ""
|
||||
mount_path: kv
|
||||
|
||||
app:
|
||||
|
||||
|
||||
@@ -15,16 +15,17 @@ 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.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.2
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.3
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.11
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.11
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.3
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
github.com/go-chi/cors v1.2.2
|
||||
github.com/go-chi/jwtauth/v5 v5.4.0
|
||||
github.com/go-chi/metrics v0.1.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/mitchellh/mapstructure v1.5.0
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/shopspring/decimal v1.4.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/tech/sendico/gateway/tron v0.0.0-00010101000000-000000000000
|
||||
@@ -53,20 +54,20 @@ 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.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.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.10 // 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/aws-sdk-go-v2/aws/protocol/eventstream v1.7.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.19 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.11 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.19 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.7 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.12 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.8 // indirect
|
||||
github.com/aws/smithy-go v1.24.2 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/casbin/mongodb-adapter/v4 v4.3.0 // indirect
|
||||
@@ -83,11 +84,22 @@ require (
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/go-chi/chi v1.5.5 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
|
||||
github.com/hashicorp/go-rootcerts v1.0.2 // indirect
|
||||
github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 // indirect
|
||||
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect
|
||||
github.com/hashicorp/go-sockaddr v1.0.7 // indirect
|
||||
github.com/hashicorp/hcl v1.0.1-vault-7 // indirect
|
||||
github.com/hashicorp/vault/api v1.22.0 // indirect
|
||||
github.com/klauspost/compress v1.18.4 // indirect
|
||||
github.com/lestrrat-go/blackmagic v1.0.4 // indirect
|
||||
github.com/lestrrat-go/dsig v1.0.0 // indirect
|
||||
@@ -100,6 +112,7 @@ require (
|
||||
github.com/magiconair/properties v1.8.7 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/moby/patternmatcher v0.6.0 // indirect
|
||||
github.com/moby/sys/sequential v0.6.0 // indirect
|
||||
@@ -116,10 +129,10 @@ require (
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // 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.20.1 // indirect
|
||||
github.com/ryanuber/go-glob v1.0.0 // indirect
|
||||
github.com/segmentio/asm v1.2.1 // indirect
|
||||
github.com/shirou/gopsutil/v3 v3.24.5 // indirect
|
||||
github.com/shoenig/go-m1cpu v0.1.6 // indirect
|
||||
@@ -134,16 +147,17 @@ require (
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 // indirect
|
||||
go.opentelemetry.io/otel v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.41.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
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-20260226221140-a57be14db171 // indirect
|
||||
)
|
||||
|
||||
@@ -6,42 +6,42 @@ 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.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.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.10 h1:fJvQ5mIBVfKtiyx0AHY6HeWcRX5LGANLpq8SVR+Uazs=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.10/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.2 h1:M1A9AjcFwlxTLuf0Faj88L8Iqw0n/AJHjpZTQzMMsSc=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.2/go.mod h1:KsdTV6Q9WKUZm2mNJnUFmIoXfZux91M3sr/a4REX8e0=
|
||||
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/aws-sdk-go-v2 v1.41.3 h1:4kQ/fa22KjDt13QCy1+bYADvdgcxpfH18f0zP542kZA=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.3/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.6 h1:N4lRUXZpZ1KVEUn6hxtco/1d2lgYhNn1fHkkl8WhlyQ=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.6/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.11 h1:ftxI5sgz8jZkckuUHXfC/wMUc8u3fG1vQS0plr2F2Zs=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.11/go.mod h1:twF11+6ps9aNRKEDimksp923o44w/Thk9+8YIlzWMmo=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.11 h1:NdV8cwCcAXrCWyxArt58BrvZJ9pZ9Fhf9w6Uh5W3Uyc=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.11/go.mod h1:30yY2zqkMPdrvxBqzI9xQCM+WrlrZKSOpSJEsylVU+8=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19 h1:INUvJxmhdEbVulJYHI061k4TVuS3jzzthNvjqvVvTKM=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19/go.mod h1:FpZN2QISLdEBWkayloda+sZjVJL+e9Gl0k1SyTgcswU=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19 h1:/sECfyq2JTifMI2JPyZ4bdRN77zJmr6SrS1eL3augIA=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19/go.mod h1:dMf8A5oAqr9/oxOfLkC/c2LU/uMcALP0Rgn2BD5LWn0=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19 h1:AWeJMk33GTBf6J20XJe6qZoRSJo0WfUhsMdUKhoODXE=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19/go.mod h1:+GWrYoaAsV7/4pNHpwh1kiNLXkKaSoppxQq9lbH8Ejw=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5 h1:clHU5fm//kWS1C2HgtgWxfQbFbx4b6rx+5jzhgX9HrI=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.19 h1:3Y4oma5TiV7tT9wa8zRcdoXwZkGz9Q/wxbEUK7cMuAM=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.19/go.mod h1:V1K+TeJVD5JOk3D9e5tsX2KUdL7BlB+FV6cBhdobN8c=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6 h1:XAq62tBTJP/85lFD5oqOOe7YYgWxY9LvWq8plyDvDVg=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.11 h1:BYf7XNsJMzl4mObARUBUib+j2tf0U//JAAtTnYqvqCw=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.11/go.mod h1:aEUS4WrNk/+FxkBZZa7tVgp4pGH+kFGW40Y8rCPqt5g=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19 h1:X1Tow7suZk9UCJHE1Iw9GMZJJl0dAnKXXP1NaSDHwmw=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19/go.mod h1:/rARO8psX+4sfjUQXp5LLifjUt8DuATZ31WptNJTyQA=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.19 h1:JnQeStZvPHFHeyky/7LbMlyQjUa+jIBj36OlWm0pzIk=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.19/go.mod h1:HGyasyHvYdFQeJhvDHfH7HXkHh57htcJGKDZ+7z+I24=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.3 h1:+d0SsTvxtIJt4tSJ6wr+jrxEMDa6XeupjRv8H7Qitkk=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.3/go.mod h1:ROUNFvFWPwBlOu687WJNQ9cPvd2ccpFrnCiA1YGz50o=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.7 h1:Y2cAXlClHsXkkOvWZFXATr34b0hxxloeQu/pAZz2row=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.7/go.mod h1:idzZ7gmDeqeNrSPkdbtMp9qWMgcBwykA7P7Rzh5DXVU=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.12 h1:iSsvB9EtQ09YrsmIc44Heqlx5ByGErqhPK1ZQLppias=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.12/go.mod h1:fEWYKTRGoZNl8tZ77i61/ccwOMJdGxwOhWCkp6TXAr0=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16 h1:EnUdUqRP1CNzt2DkV67tJx6XDN4xlfBFm+bzeNOQVb0=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16/go.mod h1:Jic/xv0Rq/pFNCh3WwpH4BEqdbSAl+IyHro8LbibHD8=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.8 h1:XQTQTF75vnug2TXS8m7CVJfC2nniYPZnO1D4Np761Oo=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.8/go.mod h1:Xgx+PR1NUOjNmQY+tRMnouRp83JRM8pRMw/vCaVhPkI=
|
||||
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
|
||||
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
||||
@@ -85,6 +85,8 @@ github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj
|
||||
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/go-chi/chi v1.5.4/go.mod h1:uaf8YgoFazUOkPBG7fxPftUylNumIev9awIWOENIuEg=
|
||||
@@ -98,6 +100,8 @@ github.com/go-chi/jwtauth/v5 v5.4.0 h1:Ieh0xMJsFvqylqJ02/mQHKzbbKO9DYNBh4DPKCwTw
|
||||
github.com/go-chi/jwtauth/v5 v5.4.0/go.mod h1:w6yjqUUXz1b8+oiJel64Sz1KJwduQM6qUA5QNzO5+bQ=
|
||||
github.com/go-chi/metrics v0.1.1 h1:CXhbnkAVVjb0k73EBRQ6Z2YdWFnbXZgNtg1Mboguibk=
|
||||
github.com/go-chi/metrics v0.1.1/go.mod h1:mcGTM1pPalP7WCtb+akNYFO/lwNwBBLCuedepqjoPn4=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
@@ -106,6 +110,8 @@ github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
|
||||
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
@@ -123,6 +129,29 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZBeBuvMkmuI2V3Fak=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII=
|
||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
||||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
|
||||
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
|
||||
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
|
||||
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
||||
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
||||
github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48=
|
||||
github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw=
|
||||
github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc=
|
||||
github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8=
|
||||
github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRctKAPTloZdMU5ZJxaesJ/VM=
|
||||
github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0=
|
||||
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts=
|
||||
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4=
|
||||
github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw=
|
||||
github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw=
|
||||
github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I=
|
||||
github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM=
|
||||
github.com/hashicorp/vault/api v1.22.0 h1:+HYFquE35/B74fHoIeXlZIP2YADVboaPjaSicHEZiH0=
|
||||
github.com/hashicorp/vault/api v1.22.0/go.mod h1:IUZA2cDvr4Ok3+NtK2Oq/r+lJeXkeCrHRmqdyWfpmGM=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
|
||||
@@ -159,6 +188,8 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
|
||||
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
@@ -208,6 +239,8 @@ github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEy
|
||||
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk=
|
||||
github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc=
|
||||
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
|
||||
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
|
||||
@@ -263,20 +296,20 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 h1:UP6IpuHFkUgOQL9FFQFrZ+5LiwhhYRbi7VZSIx6Nj5s=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM=
|
||||
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
|
||||
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
|
||||
go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
|
||||
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 h1:wVZXIWjQSeSmMoxF74LzAnpVQOAFDo3pPji9Y4SOFKc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0/go.mod h1:khvBS2IggMFNwZK/6lEeHg/W57h/IX6J4URh57fuI40=
|
||||
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
|
||||
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
|
||||
go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ=
|
||||
go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
|
||||
go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
|
||||
go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
|
||||
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
|
||||
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
|
||||
go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
|
||||
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
|
||||
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
|
||||
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
|
||||
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/tech/sendico/pkg/vault/kv"
|
||||
mwa "github.com/tech/sendico/server/interface/middleware"
|
||||
fsc "github.com/tech/sendico/server/interface/services/fileservice/config"
|
||||
)
|
||||
@@ -13,6 +14,7 @@ type Config struct {
|
||||
PaymentOrchestrator *PaymentOrchestratorConfig `yaml:"payment_orchestrator"`
|
||||
PaymentQuotation *PaymentOrchestratorConfig `yaml:"payment_quotation"`
|
||||
PaymentMethods *PaymentOrchestratorConfig `yaml:"payment_methods"`
|
||||
Callbacks *CallbacksConfig `yaml:"callbacks"`
|
||||
}
|
||||
|
||||
type ChainGatewayConfig struct {
|
||||
@@ -45,3 +47,12 @@ type PaymentOrchestratorConfig struct {
|
||||
CallTimeoutSeconds int `yaml:"call_timeout_seconds"`
|
||||
Insecure bool `yaml:"insecure"`
|
||||
}
|
||||
|
||||
type CallbacksConfig struct {
|
||||
DefaultEventTypes []string `yaml:"default_event_types"`
|
||||
DefaultStatus string `yaml:"default_status"`
|
||||
SecretPathPrefix string `yaml:"secret_path_prefix"`
|
||||
SecretField string `yaml:"secret_field"`
|
||||
SecretLengthBytes int `yaml:"secret_length_bytes"`
|
||||
Vault kv.Config `yaml:"vault"`
|
||||
}
|
||||
|
||||
@@ -73,9 +73,10 @@ func validateQuoteIdempotency(previewOnly bool, idempotencyKey string) error {
|
||||
}
|
||||
|
||||
type InitiatePayment struct {
|
||||
PaymentBase `json:",inline"`
|
||||
Intent *PaymentIntent `json:"intent,omitempty"`
|
||||
QuoteRef string `json:"quoteRef,omitempty"`
|
||||
PaymentBase `json:",inline"`
|
||||
Intent *PaymentIntent `json:"intent,omitempty"`
|
||||
QuoteRef string `json:"quoteRef,omitempty"`
|
||||
ClientPaymentRef string `json:"clientPaymentRef,omitempty"`
|
||||
}
|
||||
|
||||
func (r InitiatePayment) Validate() error {
|
||||
@@ -106,8 +107,9 @@ func (r InitiatePayment) Validate() error {
|
||||
}
|
||||
|
||||
type InitiatePayments struct {
|
||||
PaymentBase `json:",inline"`
|
||||
QuoteRef string `json:"quoteRef,omitempty"`
|
||||
PaymentBase `json:",inline"`
|
||||
QuoteRef string `json:"quoteRef,omitempty"`
|
||||
ClientPaymentRef string `json:"clientPaymentRef,omitempty"`
|
||||
}
|
||||
|
||||
func (r *InitiatePayments) Validate() error {
|
||||
|
||||
33
api/edge/bff/interface/api/sresponse/callback.go
Normal file
33
api/edge/bff/interface/api/sresponse/callback.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package sresponse
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/tech/sendico/pkg/api/http/response"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
)
|
||||
|
||||
type callbackWriteResponse struct {
|
||||
AccessToken TokenData `json:"accessToken"`
|
||||
Callbacks []model.Callback `json:"callbacks"`
|
||||
GeneratedSigningSecret string `json:"generatedSigningSecret,omitempty"`
|
||||
}
|
||||
|
||||
func Callback(
|
||||
logger mlogger.Logger,
|
||||
callback *model.Callback,
|
||||
accessToken *TokenData,
|
||||
generatedSecret string,
|
||||
created bool,
|
||||
) http.HandlerFunc {
|
||||
resp := callbackWriteResponse{
|
||||
AccessToken: *accessToken,
|
||||
Callbacks: []model.Callback{*callback},
|
||||
GeneratedSigningSecret: generatedSecret,
|
||||
}
|
||||
if created {
|
||||
return response.Created(logger, resp)
|
||||
}
|
||||
return response.Ok(logger, resp)
|
||||
}
|
||||
11
api/edge/bff/interface/services/callbacks/callbacks.go
Normal file
11
api/edge/bff/interface/services/callbacks/callbacks.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package callbacks
|
||||
|
||||
import (
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
"github.com/tech/sendico/server/interface/api"
|
||||
"github.com/tech/sendico/server/internal/server/callbacksimp"
|
||||
)
|
||||
|
||||
func Create(a api.API) (mservice.MicroService, error) {
|
||||
return callbacksimp.CreateAPI(a)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
"github.com/tech/sendico/server/interface/api"
|
||||
"github.com/tech/sendico/server/interface/services/account"
|
||||
"github.com/tech/sendico/server/interface/services/callbacks"
|
||||
"github.com/tech/sendico/server/interface/services/invitation"
|
||||
"github.com/tech/sendico/server/interface/services/ledger"
|
||||
"github.com/tech/sendico/server/interface/services/logo"
|
||||
@@ -91,6 +92,7 @@ func (a *APIImp) installServices() error {
|
||||
srvf = append(srvf, wallet.Create)
|
||||
srvf = append(srvf, ledger.Create)
|
||||
srvf = append(srvf, recipient.Create)
|
||||
srvf = append(srvf, callbacks.Create)
|
||||
srvf = append(srvf, paymethod.Create)
|
||||
srvf = append(srvf, payment.Create)
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@ func hasGrantType(grants []string, target string) bool {
|
||||
|
||||
func (pr *PublicRouter) validateClientIPPolicy(r *http.Request, clientID string, client *model.Client) http.HandlerFunc {
|
||||
if client == nil {
|
||||
pr.logger.Info("Client not found, rejecting authorization", zap.String("client_id", clientID))
|
||||
return response.Unauthorized(pr.logger, pr.service, "client not found")
|
||||
}
|
||||
clientIP := ipguard.ClientIP(r)
|
||||
|
||||
@@ -42,9 +42,7 @@ func PrepareRefreshToken(
|
||||
}
|
||||
|
||||
token := &model.RefreshToken{
|
||||
AccountBoundBase: model.AccountBoundBase{
|
||||
AccountRef: account.GetID(),
|
||||
},
|
||||
AccountRef: account.GetID(),
|
||||
ClientRefreshToken: model.ClientRefreshToken{
|
||||
SessionIdentifier: *session,
|
||||
RefreshToken: refreshToken,
|
||||
|
||||
@@ -120,11 +120,11 @@ func (a *AccountAPI) resetPassword(r *http.Request) http.HandlerFunc {
|
||||
var user model.Account
|
||||
err = a.db.Get(ctx, accountRef, &user)
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
a.logger.Info("User not found for password reset", zap.String("account_ref", accountRef.Hex()))
|
||||
a.logger.Info("User not found for password reset", mzap.ObjRef("account_ref", accountRef))
|
||||
return response.NotFound(a.logger, a.Name(), "User not found")
|
||||
}
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to get user for password reset", zap.Error(err), zap.String("account_ref", accountRef.Hex()))
|
||||
a.logger.Warn("Failed to get user for password reset", zap.Error(err), mzap.ObjRef("account_ref", accountRef))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
@@ -140,7 +140,7 @@ func (a *AccountAPI) resetPassword(r *http.Request) http.HandlerFunc {
|
||||
}
|
||||
|
||||
if t.AccountRef != accountRef {
|
||||
a.logger.Warn("Token account reference does not match request account reference", zap.String("token_account_ref", t.AccountRef.Hex()), zap.String("request_account_ref", accountRef.Hex()))
|
||||
a.logger.Warn("Token account reference does not match request account reference", mzap.ObjRef("token_account_ref", t.AccountRef), mzap.ObjRef("request_account_ref", accountRef))
|
||||
return response.DataConflict(a.logger, a.Name(), "Token does not match account")
|
||||
}
|
||||
|
||||
|
||||
47
api/edge/bff/internal/server/callbacksimp/create.go
Normal file
47
api/edge/bff/internal/server/callbacksimp/create.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package callbacksimp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/tech/sendico/pkg/api/http/response"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/server/interface/api/sresponse"
|
||||
mutil "github.com/tech/sendico/server/internal/mutil/param"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (a *CallbacksAPI) create(r *http.Request, account *model.Account, accessToken *sresponse.TokenData) http.HandlerFunc {
|
||||
organizationRef, err := a.Oph.GetRef(r)
|
||||
if err != nil {
|
||||
a.Logger.Warn("Failed to parse organization reference", zap.Error(err), mutil.PLog(a.Oph, r))
|
||||
return response.BadReference(a.Logger, a.Name(), a.Oph.Name(), a.Oph.GetID(r), err)
|
||||
}
|
||||
|
||||
var callback model.Callback
|
||||
if err := json.NewDecoder(r.Body).Decode(&callback); err != nil {
|
||||
a.Logger.Warn("Failed to decode callback payload", zap.Error(err))
|
||||
return response.BadPayload(a.Logger, a.Name(), err)
|
||||
}
|
||||
|
||||
mutation, err := a.normalizeAndPrepare(r.Context(), &callback, organizationRef, "", true)
|
||||
if err != nil {
|
||||
return response.Auto(a.Logger, a.Name(), err)
|
||||
}
|
||||
|
||||
if _, err := a.tf.CreateTransaction().Execute(r.Context(), func(ctx context.Context) (any, error) {
|
||||
if err := a.DB.Create(ctx, *account.GetID(), organizationRef, &callback); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := a.applySigningSecretMutation(ctx, *account.GetID(), *callback.GetID(), mutation); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, nil
|
||||
}); err != nil {
|
||||
a.Logger.Warn("Failed to create callback transaction", zap.Error(err))
|
||||
return response.Auto(a.Logger, a.Name(), err)
|
||||
}
|
||||
|
||||
return a.callbackResponse(&callback, accessToken, mutation.Generated, true)
|
||||
}
|
||||
285
api/edge/bff/internal/server/callbacksimp/rotate.go
Normal file
285
api/edge/bff/internal/server/callbacksimp/rotate.go
Normal file
@@ -0,0 +1,285 @@
|
||||
package callbacksimp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/pkg/api/http/response"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"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"
|
||||
)
|
||||
|
||||
type signingSecretMutation struct {
|
||||
SetSecretRef string
|
||||
Clear bool
|
||||
Generated string
|
||||
}
|
||||
|
||||
func (a *CallbacksAPI) rotateSecret(r *http.Request, account *model.Account, accessToken *sresponse.TokenData) http.HandlerFunc {
|
||||
callbackRef, err := a.Cph.GetRef(r)
|
||||
if err != nil {
|
||||
a.Logger.Warn("Failed to parse callback reference", zap.Error(err), mutil.PLog(a.Cph, r))
|
||||
return response.BadReference(a.Logger, a.Name(), a.Cph.Name(), a.Cph.GetID(r), err)
|
||||
}
|
||||
|
||||
var callback model.Callback
|
||||
if err := a.db.Get(r.Context(), *account.GetID(), callbackRef, &callback); err != nil {
|
||||
a.Logger.Warn("Failed to fetch callback for secret rotation", zap.Error(err))
|
||||
return response.Auto(a.Logger, a.Name(), err)
|
||||
}
|
||||
|
||||
if callback.RetryPolicy.SigningMode != model.CallbackSigningModeHMACSHA256 {
|
||||
return response.BadRequest(a.Logger, a.Name(), "invalid_signing_mode", "rotate-secret is available only for hmac_sha256 callbacks")
|
||||
}
|
||||
|
||||
secretRef, generatedSecret, err := a.secrets.Provision(r.Context(), callback.OrganizationRef, callbackRef)
|
||||
if err != nil {
|
||||
a.Logger.Warn("Failed to rotate callback signing secret", zap.Error(err))
|
||||
return response.Auto(a.Logger, a.Name(), err)
|
||||
}
|
||||
if err := a.db.SetSigningSecretRef(r.Context(), *account.GetID(), callbackRef, secretRef); err != nil {
|
||||
a.Logger.Warn("Failed to persist rotated callback signing secret reference", zap.Error(err))
|
||||
return response.Auto(a.Logger, a.Name(), err)
|
||||
}
|
||||
|
||||
return a.callbackResponse(&callback, accessToken, generatedSecret, false)
|
||||
}
|
||||
|
||||
func (a *CallbacksAPI) normalizeAndPrepare(
|
||||
ctx context.Context,
|
||||
callback *model.Callback,
|
||||
organizationRef bson.ObjectID,
|
||||
existingSecretRef string,
|
||||
allowSecretGeneration bool,
|
||||
) (signingSecretMutation, error) {
|
||||
if callback == nil {
|
||||
return signingSecretMutation{}, merrors.InvalidArgument("callback payload is required")
|
||||
}
|
||||
if organizationRef.IsZero() {
|
||||
return signingSecretMutation{}, merrors.InvalidArgument("organization reference is required", "organizationRef")
|
||||
}
|
||||
|
||||
callback.Name = strings.TrimSpace(callback.Name)
|
||||
callback.Description = trimDescription(callback.Description)
|
||||
|
||||
callback.URL = strings.TrimSpace(callback.URL)
|
||||
if callback.URL == "" {
|
||||
return signingSecretMutation{}, merrors.InvalidArgument("url is required", "url")
|
||||
}
|
||||
if err := validateCallbackURL(callback.URL); err != nil {
|
||||
return signingSecretMutation{}, err
|
||||
}
|
||||
if callback.Name == "" {
|
||||
callback.Name = callback.URL
|
||||
}
|
||||
|
||||
status, err := normalizeStatus(callback.Status, a.config.DefaultStatus)
|
||||
if err != nil {
|
||||
return signingSecretMutation{}, err
|
||||
}
|
||||
callback.Status = status
|
||||
callback.EventTypes = normalizeEventTypes(callback.EventTypes, a.config.DefaultEventTypes)
|
||||
|
||||
callback.RetryPolicy.Backoff.MinDelayMS = defaultInt(callback.RetryPolicy.Backoff.MinDelayMS, defaultRetryMinDelayMS)
|
||||
callback.RetryPolicy.Backoff.MaxDelayMS = defaultInt(callback.RetryPolicy.Backoff.MaxDelayMS, defaultRetryMaxDelayMS)
|
||||
if callback.RetryPolicy.Backoff.MaxDelayMS < callback.RetryPolicy.Backoff.MinDelayMS {
|
||||
callback.RetryPolicy.Backoff.MaxDelayMS = callback.RetryPolicy.Backoff.MinDelayMS
|
||||
}
|
||||
callback.RetryPolicy.MaxAttempts = defaultInt(callback.RetryPolicy.MaxAttempts, defaultRetryMaxAttempts)
|
||||
callback.RetryPolicy.RequestTimeoutMS = defaultInt(callback.RetryPolicy.RequestTimeoutMS, defaultRetryRequestTimeoutMS)
|
||||
callback.RetryPolicy.Headers = normalizeHeaders(callback.RetryPolicy.Headers)
|
||||
|
||||
mode, err := normalizeSigningMode(callback.RetryPolicy.SigningMode)
|
||||
if err != nil {
|
||||
return signingSecretMutation{}, err
|
||||
}
|
||||
callback.RetryPolicy.SigningMode = mode
|
||||
|
||||
existingSecretRef = strings.TrimSpace(existingSecretRef)
|
||||
switch callback.RetryPolicy.SigningMode {
|
||||
case model.CallbackSigningModeNone:
|
||||
return signingSecretMutation{Clear: existingSecretRef != ""}, nil
|
||||
case model.CallbackSigningModeHMACSHA256:
|
||||
if existingSecretRef != "" {
|
||||
return signingSecretMutation{SetSecretRef: existingSecretRef}, nil
|
||||
}
|
||||
if !allowSecretGeneration {
|
||||
return signingSecretMutation{}, merrors.InvalidArgument("signing secret is required for hmac_sha256 callbacks", "retryPolicy.signingMode")
|
||||
}
|
||||
if callback.GetID().IsZero() {
|
||||
callback.SetID(bson.NewObjectID())
|
||||
}
|
||||
secretRef, generatedSecret, err := a.secrets.Provision(ctx, organizationRef, *callback.GetID())
|
||||
if err != nil {
|
||||
return signingSecretMutation{}, err
|
||||
}
|
||||
return signingSecretMutation{SetSecretRef: secretRef, Generated: generatedSecret}, nil
|
||||
default:
|
||||
return signingSecretMutation{}, merrors.InvalidArgument("unsupported signing mode", "retryPolicy.signingMode")
|
||||
}
|
||||
}
|
||||
|
||||
func (a *CallbacksAPI) applySigningSecretMutation(
|
||||
ctx context.Context,
|
||||
accountRef,
|
||||
callbackRef bson.ObjectID,
|
||||
mutation signingSecretMutation,
|
||||
) error {
|
||||
if callbackRef.IsZero() {
|
||||
return merrors.InvalidArgument("callback reference is required", "callbackRef")
|
||||
}
|
||||
if strings.TrimSpace(mutation.SetSecretRef) != "" {
|
||||
return a.db.SetSigningSecretRef(ctx, accountRef, callbackRef, mutation.SetSecretRef)
|
||||
}
|
||||
if mutation.Clear {
|
||||
err := a.db.ClearSigningSecretRef(ctx, accountRef, callbackRef)
|
||||
if err != nil && !errors.Is(err, merrors.ErrNoData) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *CallbacksAPI) callbackResponse(
|
||||
callback *model.Callback,
|
||||
accessToken *sresponse.TokenData,
|
||||
generatedSecret string,
|
||||
created bool,
|
||||
) http.HandlerFunc {
|
||||
if callback == nil || accessToken == nil {
|
||||
return response.Internal(a.Logger, a.Name(), merrors.Internal("failed to build callback response"))
|
||||
}
|
||||
|
||||
return sresponse.Callback(a.Logger, callback, accessToken, generatedSecret, created)
|
||||
}
|
||||
|
||||
func normalizeStatus(raw, fallback model.CallbackStatus) (model.CallbackStatus, error) {
|
||||
candidate := strings.ToLower(strings.TrimSpace(string(raw)))
|
||||
if candidate == "" {
|
||||
candidate = strings.ToLower(strings.TrimSpace(string(fallback)))
|
||||
}
|
||||
|
||||
switch candidate {
|
||||
case "", "active", "enabled":
|
||||
return model.CallbackStatusActive, nil
|
||||
case "disabled", "inactive":
|
||||
return model.CallbackStatusDisabled, nil
|
||||
default:
|
||||
return "", merrors.InvalidArgument("unsupported callback status", "status")
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeSigningMode(raw model.CallbackSigningMode) (model.CallbackSigningMode, error) {
|
||||
mode := strings.ToLower(strings.TrimSpace(string(raw)))
|
||||
switch mode {
|
||||
case "", "none":
|
||||
return model.CallbackSigningModeNone, nil
|
||||
case "hmac_sha256", "hmac-sha256", "hmac":
|
||||
return model.CallbackSigningModeHMACSHA256, nil
|
||||
default:
|
||||
return "", merrors.InvalidArgument("unsupported callback signing mode", "retryPolicy.signingMode")
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeEventTypes(eventTypes []string, defaults []string) []string {
|
||||
if len(eventTypes) == 0 {
|
||||
return normalizeEventTypes(defaults, nil)
|
||||
}
|
||||
seen := make(map[string]struct{}, len(eventTypes))
|
||||
out := make([]string, 0, len(eventTypes))
|
||||
for _, eventType := range eventTypes {
|
||||
value := strings.TrimSpace(eventType)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[value]; exists {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
out = append(out, value)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
if len(defaults) > 0 {
|
||||
return normalizeEventTypes(defaults, nil)
|
||||
}
|
||||
return []string{model.PaymentStatusUpdatedType}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeHeaders(headers map[string]string) map[string]string {
|
||||
if len(headers) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(headers))
|
||||
for key, value := range headers {
|
||||
k := strings.TrimSpace(key)
|
||||
if k == "" {
|
||||
continue
|
||||
}
|
||||
out[k] = strings.TrimSpace(value)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mergeCallbackMutable(dst, src *model.Callback) {
|
||||
dst.OrganizationRef = src.OrganizationRef
|
||||
dst.Describable = src.Describable
|
||||
dst.Status = src.Status
|
||||
dst.URL = src.URL
|
||||
dst.EventTypes = append([]string(nil), src.EventTypes...)
|
||||
dst.RetryPolicy = model.CallbackRetryPolicy{
|
||||
Backoff: model.CallbackBackoff{
|
||||
MinDelayMS: src.RetryPolicy.Backoff.MinDelayMS,
|
||||
MaxDelayMS: src.RetryPolicy.Backoff.MaxDelayMS,
|
||||
},
|
||||
SigningMode: src.RetryPolicy.SigningMode,
|
||||
Headers: normalizeHeaders(src.RetryPolicy.Headers),
|
||||
MaxAttempts: src.RetryPolicy.MaxAttempts,
|
||||
RequestTimeoutMS: src.RetryPolicy.RequestTimeoutMS,
|
||||
}
|
||||
}
|
||||
|
||||
func defaultInt(value, fallback int) int {
|
||||
if value > 0 {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func trimDescription(in *string) *string {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
value := strings.TrimSpace(*in)
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
return &value
|
||||
}
|
||||
|
||||
func validateCallbackURL(raw string) error {
|
||||
parsed, err := url.ParseRequestURI(raw)
|
||||
if err != nil {
|
||||
return merrors.InvalidArgument("url is invalid", "url")
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(parsed.Scheme)) {
|
||||
case "https", "http":
|
||||
default:
|
||||
return merrors.InvalidArgument("url scheme must be http or https", "url")
|
||||
}
|
||||
if strings.TrimSpace(parsed.Host) == "" {
|
||||
return merrors.InvalidArgument("url host is required", "url")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
179
api/edge/bff/internal/server/callbacksimp/secrets.go
Normal file
179
api/edge/bff/internal/server/callbacksimp/secrets.go
Normal file
@@ -0,0 +1,179 @@
|
||||
package callbacksimp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"path"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/mutil/mzap"
|
||||
"github.com/tech/sendico/pkg/vault/kv"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type signingSecretManager interface {
|
||||
Provision(ctx context.Context, organizationRef, callbackRef bson.ObjectID) (secretRef string, generatedSecret string, err error)
|
||||
}
|
||||
|
||||
type vaultSigningSecretManager struct {
|
||||
logger mlogger.Logger
|
||||
store kv.Client
|
||||
pathPrefix string
|
||||
field string
|
||||
secretLength int
|
||||
}
|
||||
|
||||
const (
|
||||
metricsResultSuccess = "success"
|
||||
metricsResultError = "error"
|
||||
)
|
||||
|
||||
var (
|
||||
signingSecretMetricsOnce sync.Once
|
||||
signingSecretStatus *prometheus.CounterVec
|
||||
signingSecretLatency *prometheus.HistogramVec
|
||||
)
|
||||
|
||||
func ensureSigningSecretMetrics() {
|
||||
signingSecretMetricsOnce.Do(func() {
|
||||
signingSecretStatus = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||
Namespace: "sendico",
|
||||
Subsystem: "bff_callbacks",
|
||||
Name: "signing_secret_provision_total",
|
||||
Help: "Total callback signing secret provisioning attempts.",
|
||||
}, []string{"result"})
|
||||
signingSecretLatency = promauto.NewHistogramVec(prometheus.HistogramOpts{
|
||||
Namespace: "sendico",
|
||||
Subsystem: "bff_callbacks",
|
||||
Name: "signing_secret_provision_duration_seconds",
|
||||
Help: "Duration of callback signing secret provisioning attempts.",
|
||||
Buckets: prometheus.DefBuckets,
|
||||
}, []string{"result"})
|
||||
})
|
||||
}
|
||||
|
||||
func newSigningSecretManager(logger mlogger.Logger, cfg callbacksConfig) (signingSecretManager, error) {
|
||||
if err := cfg.validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
|
||||
manager := &vaultSigningSecretManager{
|
||||
logger: logger.Named("callbacks_secrets"),
|
||||
pathPrefix: strings.Trim(strings.TrimSpace(cfg.SecretPathPrefix), "/"),
|
||||
field: strings.TrimSpace(cfg.SecretField),
|
||||
secretLength: cfg.SecretLengthBytes,
|
||||
}
|
||||
if manager.pathPrefix == "" {
|
||||
manager.pathPrefix = defaultSigningSecretPathPrefix
|
||||
}
|
||||
if manager.field == "" {
|
||||
manager.field = defaultSigningSecretField
|
||||
}
|
||||
|
||||
if isVaultConfigEmpty(cfg.Vault) {
|
||||
manager.logger.Warn("Callbacks Vault config is not set; hmac signing secret generation is disabled")
|
||||
ensureSigningSecretMetrics()
|
||||
return manager, nil
|
||||
}
|
||||
|
||||
store, err := kv.New(kv.Options{
|
||||
Logger: manager.logger,
|
||||
Config: kv.Config{
|
||||
Address: strings.TrimSpace(cfg.Vault.Address),
|
||||
TokenEnv: strings.TrimSpace(cfg.Vault.TokenEnv),
|
||||
TokenFileEnv: strings.TrimSpace(cfg.Vault.TokenFileEnv),
|
||||
TokenFile: strings.TrimSpace(cfg.Vault.TokenFile),
|
||||
Namespace: strings.TrimSpace(cfg.Vault.Namespace),
|
||||
MountPath: strings.TrimSpace(cfg.Vault.MountPath),
|
||||
},
|
||||
Component: "bff callbacks signing secret manager",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
manager.store = store
|
||||
ensureSigningSecretMetrics()
|
||||
|
||||
return manager, nil
|
||||
}
|
||||
|
||||
func (m *vaultSigningSecretManager) Provision(
|
||||
ctx context.Context,
|
||||
organizationRef,
|
||||
callbackRef bson.ObjectID,
|
||||
) (string, string, error) {
|
||||
start := time.Now()
|
||||
result := metricsResultSuccess
|
||||
defer func() {
|
||||
signingSecretStatus.WithLabelValues(result).Inc()
|
||||
signingSecretLatency.WithLabelValues(result).Observe(time.Since(start).Seconds())
|
||||
}()
|
||||
|
||||
if organizationRef.IsZero() {
|
||||
result = metricsResultError
|
||||
return "", "", merrors.InvalidArgument("organization reference is required", "organizationRef")
|
||||
}
|
||||
if callbackRef.IsZero() {
|
||||
result = metricsResultError
|
||||
return "", "", merrors.InvalidArgument("callback reference is required", "callbackRef")
|
||||
}
|
||||
if m.store == nil {
|
||||
result = metricsResultError
|
||||
return "", "", merrors.InvalidArgument("callbacks vault config is required to generate signing secrets", "api.callbacks.vault")
|
||||
}
|
||||
|
||||
secret, err := generateSigningSecret(m.secretLength)
|
||||
if err != nil {
|
||||
result = metricsResultError
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
secretPath := path.Join(m.pathPrefix, organizationRef.Hex(), callbackRef.Hex())
|
||||
payload := map[string]interface{}{
|
||||
m.field: secret,
|
||||
"organization_ref": organizationRef.Hex(),
|
||||
"callback_ref": callbackRef.Hex(),
|
||||
"updated_at": time.Now().UTC().Format(time.RFC3339Nano),
|
||||
}
|
||||
if err := m.store.Put(ctx, secretPath, payload); err != nil {
|
||||
result = metricsResultError
|
||||
m.logger.Warn("Failed to store callback signing secret", zap.String("path", secretPath), zap.Error(err))
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
secretRef := "vault:" + secretPath + "#" + m.field
|
||||
m.logger.Info("Callback signing secret stored", zap.String("secret_ref", secretRef), mzap.ObjRef("callback_ref", callbackRef))
|
||||
|
||||
return secretRef, secret, nil
|
||||
}
|
||||
|
||||
func isVaultConfigEmpty(cfg VaultConfig) bool {
|
||||
return strings.TrimSpace(cfg.Address) == "" &&
|
||||
strings.TrimSpace(cfg.TokenEnv) == "" &&
|
||||
strings.TrimSpace(cfg.TokenFileEnv) == "" &&
|
||||
strings.TrimSpace(cfg.TokenFile) == "" &&
|
||||
strings.TrimSpace(cfg.MountPath) == "" &&
|
||||
strings.TrimSpace(cfg.Namespace) == ""
|
||||
}
|
||||
|
||||
func generateSigningSecret(length int) (string, error) {
|
||||
if length <= 0 {
|
||||
return "", merrors.InvalidArgument("secret length must be greater than zero", "secret_length")
|
||||
}
|
||||
raw := make([]byte, length)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", merrors.Internal("failed to generate signing secret: " + err.Error())
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(raw), nil
|
||||
}
|
||||
142
api/edge/bff/internal/server/callbacksimp/service.go
Normal file
142
api/edge/bff/internal/server/callbacksimp/service.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package callbacksimp
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
api "github.com/tech/sendico/pkg/api/http"
|
||||
"github.com/tech/sendico/pkg/db/callbacks"
|
||||
"github.com/tech/sendico/pkg/db/transaction"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
eapi "github.com/tech/sendico/server/interface/api"
|
||||
"github.com/tech/sendico/server/internal/server/papitemplate"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type CallbacksAPI struct {
|
||||
papitemplate.ProtectedAPI[model.Callback]
|
||||
db callbacks.DB
|
||||
tf transaction.Factory
|
||||
secrets signingSecretManager
|
||||
config callbacksConfig
|
||||
}
|
||||
|
||||
func (a *CallbacksAPI) Name() mservice.Type {
|
||||
return mservice.Callbacks
|
||||
}
|
||||
|
||||
func (a *CallbacksAPI) Finish(_ context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func CreateAPI(apiCtx eapi.API) (*CallbacksAPI, error) {
|
||||
dbFactory := func() (papitemplate.ProtectedDB[model.Callback], error) {
|
||||
return apiCtx.DBFactory().NewCallbacksDB()
|
||||
}
|
||||
|
||||
res := &CallbacksAPI{
|
||||
config: newCallbacksConfig(apiCtx.Config().Callbacks),
|
||||
tf: apiCtx.DBFactory().TransactionFactory(),
|
||||
}
|
||||
|
||||
p, err := papitemplate.CreateAPI(apiCtx, dbFactory, mservice.Organizations, mservice.Callbacks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res.ProtectedAPI = *p.
|
||||
WithNoCreateNotification().
|
||||
WithNoUpdateNotification().
|
||||
WithNoDeleteNotification().
|
||||
WithCreateHandler(res.create).
|
||||
WithUpdateHandler(res.update).
|
||||
Build()
|
||||
|
||||
if res.db, err = apiCtx.DBFactory().NewCallbacksDB(); err != nil {
|
||||
res.Logger.Warn("Failed to create callbacks database", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if res.secrets, err = newSigningSecretManager(res.Logger, res.config); err != nil {
|
||||
res.Logger.Warn("Failed to initialize callbacks signing secret manager", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
apiCtx.Register().AccountHandler(res.Name(), res.Cph.AddRef("/rotate-secret"), api.Post, res.rotateSecret)
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
const (
|
||||
defaultCallbackStatus = model.CallbackStatusActive
|
||||
defaultRetryMaxAttempts = 8
|
||||
defaultRetryMinDelayMS = 1000
|
||||
defaultRetryMaxDelayMS = 300000
|
||||
defaultRetryRequestTimeoutMS = 10000
|
||||
defaultSigningSecretLengthBytes = 32
|
||||
defaultSigningSecretField = "value"
|
||||
defaultSigningSecretPathPrefix = "sendico/callbacks"
|
||||
)
|
||||
|
||||
type callbacksConfig struct {
|
||||
DefaultEventTypes []string
|
||||
DefaultStatus model.CallbackStatus
|
||||
SecretPathPrefix string
|
||||
SecretField string
|
||||
SecretLengthBytes int
|
||||
Vault VaultConfig
|
||||
}
|
||||
|
||||
type VaultConfig struct {
|
||||
Address string
|
||||
TokenEnv string
|
||||
TokenFileEnv string
|
||||
TokenFile string
|
||||
Namespace string
|
||||
MountPath string
|
||||
}
|
||||
|
||||
func newCallbacksConfig(source *eapi.CallbacksConfig) callbacksConfig {
|
||||
cfg := callbacksConfig{
|
||||
DefaultEventTypes: []string{model.PaymentStatusUpdatedType},
|
||||
DefaultStatus: defaultCallbackStatus,
|
||||
SecretPathPrefix: defaultSigningSecretPathPrefix,
|
||||
SecretField: defaultSigningSecretField,
|
||||
SecretLengthBytes: defaultSigningSecretLengthBytes,
|
||||
}
|
||||
if source == nil {
|
||||
return cfg
|
||||
}
|
||||
|
||||
if source.SecretPathPrefix != "" {
|
||||
cfg.SecretPathPrefix = source.SecretPathPrefix
|
||||
}
|
||||
if source.SecretField != "" {
|
||||
cfg.SecretField = source.SecretField
|
||||
}
|
||||
if source.SecretLengthBytes > 0 {
|
||||
cfg.SecretLengthBytes = source.SecretLengthBytes
|
||||
}
|
||||
if len(source.DefaultEventTypes) > 0 {
|
||||
cfg.DefaultEventTypes = source.DefaultEventTypes
|
||||
}
|
||||
if source.DefaultStatus != "" {
|
||||
cfg.DefaultStatus = model.CallbackStatus(source.DefaultStatus)
|
||||
}
|
||||
cfg.Vault = VaultConfig{
|
||||
Address: source.Vault.Address,
|
||||
TokenEnv: source.Vault.TokenEnv,
|
||||
TokenFileEnv: source.Vault.TokenFileEnv,
|
||||
TokenFile: source.Vault.TokenFile,
|
||||
Namespace: source.Vault.Namespace,
|
||||
MountPath: source.Vault.MountPath,
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func (c callbacksConfig) validate() error {
|
||||
if c.SecretLengthBytes <= 0 {
|
||||
return merrors.InvalidArgument("callbacks signing secret length must be greater than zero", "api.callbacks.secret_length_bytes")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
59
api/edge/bff/internal/server/callbacksimp/update.go
Normal file
59
api/edge/bff/internal/server/callbacksimp/update.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package callbacksimp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/tech/sendico/pkg/api/http/response"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/server/interface/api/sresponse"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (a *CallbacksAPI) update(r *http.Request, account *model.Account, accessToken *sresponse.TokenData) http.HandlerFunc {
|
||||
var input model.Callback
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
a.Logger.Warn("Failed to decode callback payload", zap.Error(err))
|
||||
return response.BadPayload(a.Logger, a.Name(), err)
|
||||
}
|
||||
|
||||
callbackRef := *input.GetID()
|
||||
if callbackRef.IsZero() {
|
||||
return response.Auto(a.Logger, a.Name(), merrors.InvalidArgument("callback ref is required", "id"))
|
||||
}
|
||||
|
||||
var existing model.Callback
|
||||
if err := a.db.Get(r.Context(), *account.GetID(), callbackRef, &existing); err != nil {
|
||||
a.Logger.Warn("Failed to fetch callback before update", zap.Error(err))
|
||||
return response.Auto(a.Logger, a.Name(), err)
|
||||
}
|
||||
existingSecretRef, err := a.db.GetSigningSecretRef(r.Context(), *account.GetID(), callbackRef)
|
||||
if err != nil && !errors.Is(err, merrors.ErrNoData) {
|
||||
a.Logger.Warn("Failed to fetch callback signing secret metadata", zap.Error(err))
|
||||
return response.Auto(a.Logger, a.Name(), err)
|
||||
}
|
||||
|
||||
mergeCallbackMutable(&existing, &input)
|
||||
mutation, err := a.normalizeAndPrepare(r.Context(), &existing, existing.OrganizationRef, existingSecretRef, true)
|
||||
if err != nil {
|
||||
return response.Auto(a.Logger, a.Name(), err)
|
||||
}
|
||||
|
||||
if _, err := a.tf.CreateTransaction().Execute(r.Context(), func(ctx context.Context) (any, error) {
|
||||
if err := a.DB.Update(ctx, *account.GetID(), &existing); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := a.applySigningSecretMutation(ctx, *account.GetID(), callbackRef, mutation); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, nil
|
||||
}); err != nil {
|
||||
a.Logger.Warn("Failed to update callback transaction", zap.Error(err))
|
||||
return response.Auto(a.Logger, a.Name(), err)
|
||||
}
|
||||
|
||||
return a.callbackResponse(&existing, accessToken, mutation.Generated, false)
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/pkg/model/account_role"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
"github.com/tech/sendico/pkg/mutil/mzap"
|
||||
describablev1 "github.com/tech/sendico/pkg/proto/common/describable/v1"
|
||||
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
|
||||
"github.com/tech/sendico/server/interface/api/srequest"
|
||||
@@ -88,7 +89,7 @@ func (a *LedgerAPI) createAccount(r *http.Request, account *model.Account, token
|
||||
Describable: describable,
|
||||
})
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to create ledger account", zap.Error(err), zap.String("organization_ref", orgRef.Hex()))
|
||||
a.logger.Warn("Failed to create ledger account", zap.Error(err), mzap.ObjRef("organization_ref", orgRef))
|
||||
return response.Auto(a.logger, mservice.Ledger, err)
|
||||
}
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ func (a *LedgerAPI) listAccounts(r *http.Request, account *model.Account, token
|
||||
|
||||
resp, err := a.client.ListAccounts(ctx, req)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to list ledger accounts", zap.Error(err), zap.String("organization_ref", orgRef.Hex()))
|
||||
a.logger.Warn("Failed to list ledger accounts", zap.Error(err), mzap.ObjRef("organization_ref", orgRef))
|
||||
return response.Auto(a.logger, mservice.Ledger, err)
|
||||
}
|
||||
|
||||
|
||||
@@ -105,10 +105,7 @@ func (a *PaymentAPI) getActDocument(r *http.Request, account *model.Account, _ *
|
||||
}
|
||||
|
||||
func (a *PaymentAPI) fetchActDocument(ctx context.Context, invokeURI, paymentRef string) (*documentsv1.GetDocumentResponse, error) {
|
||||
dialCtx, cancel := context.WithTimeout(ctx, documentsDialTimeout)
|
||||
defer cancel()
|
||||
|
||||
conn, err := grpc.DialContext(dialCtx, invokeURI, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
conn, err := grpc.NewClient(invokeURI, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
if err != nil {
|
||||
return nil, merrors.InternalWrap(err, "dial billing documents")
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||
endpointv1 "github.com/tech/sendico/pkg/proto/payments/endpoint/v1"
|
||||
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"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
@@ -59,7 +60,7 @@ func mapQuoteIntent(intent *srequest.PaymentIntent) (*quotationv2.QuoteIntent, e
|
||||
SettlementMode: resolvedSettlementMode,
|
||||
FeeTreatment: resolvedFeeTreatment,
|
||||
SettlementCurrency: settlementCurrency,
|
||||
FxSide: mapFXSide(intent),
|
||||
Fx: mapFXIntent(intent),
|
||||
}
|
||||
if comment := strings.TrimSpace(intent.Attributes["comment"]); comment != "" {
|
||||
quoteIntent.Comment = comment
|
||||
@@ -67,17 +68,30 @@ func mapQuoteIntent(intent *srequest.PaymentIntent) (*quotationv2.QuoteIntent, e
|
||||
return quoteIntent, nil
|
||||
}
|
||||
|
||||
func mapFXSide(intent *srequest.PaymentIntent) fxv1.Side {
|
||||
if intent == nil || intent.FX == nil {
|
||||
return fxv1.Side_SIDE_UNSPECIFIED
|
||||
func mapFXIntent(intent *srequest.PaymentIntent) *sharedv1.FXIntent {
|
||||
if intent == nil || intent.FX == nil || intent.FX.Pair == nil {
|
||||
return nil
|
||||
}
|
||||
side := fxv1.Side_SIDE_UNSPECIFIED
|
||||
switch strings.TrimSpace(string(intent.FX.Side)) {
|
||||
case string(srequest.FXSideBuyBaseSellQuote):
|
||||
return fxv1.Side_BUY_BASE_SELL_QUOTE
|
||||
side = fxv1.Side_BUY_BASE_SELL_QUOTE
|
||||
case string(srequest.FXSideSellBaseBuyQuote):
|
||||
return fxv1.Side_SELL_BASE_BUY_QUOTE
|
||||
default:
|
||||
return fxv1.Side_SIDE_UNSPECIFIED
|
||||
side = fxv1.Side_SELL_BASE_BUY_QUOTE
|
||||
}
|
||||
if side == fxv1.Side_SIDE_UNSPECIFIED {
|
||||
side = fxv1.Side_SELL_BASE_BUY_QUOTE
|
||||
}
|
||||
return &sharedv1.FXIntent{
|
||||
Pair: &fxv1.CurrencyPair{
|
||||
Base: strings.ToUpper(strings.TrimSpace(intent.FX.Pair.Base)),
|
||||
Quote: strings.ToUpper(strings.TrimSpace(intent.FX.Pair.Quote)),
|
||||
},
|
||||
Side: side,
|
||||
Firm: intent.FX.Firm,
|
||||
TtlMs: intent.FX.TTLms,
|
||||
PreferredProvider: strings.TrimSpace(intent.FX.PreferredProvider),
|
||||
MaxAgeMs: intent.FX.MaxAgeMs,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -202,8 +202,14 @@ func TestMapQuoteIntent_DerivesSettlementCurrencyFromFX(t *testing.T) {
|
||||
if got.GetSettlementCurrency() != "RUB" {
|
||||
t.Fatalf("unexpected settlement currency: got=%q", got.GetSettlementCurrency())
|
||||
}
|
||||
if got.GetFxSide() != fxv1.Side_SELL_BASE_BUY_QUOTE {
|
||||
t.Fatalf("unexpected fx_side: got=%s", got.GetFxSide().String())
|
||||
if got.GetFx() == nil || got.GetFx().GetPair() == nil {
|
||||
t.Fatalf("expected fx intent")
|
||||
}
|
||||
if got.GetFx().GetSide() != fxv1.Side_SELL_BASE_BUY_QUOTE {
|
||||
t.Fatalf("unexpected fx side: got=%s", got.GetFx().GetSide().String())
|
||||
}
|
||||
if got.GetFx().GetPair().GetBase() != "USDT" || got.GetFx().GetPair().GetQuote() != "RUB" {
|
||||
t.Fatalf("unexpected fx pair: got=%s/%s", got.GetFx().GetPair().GetBase(), got.GetFx().GetPair().GetQuote())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,8 +252,11 @@ func TestMapQuoteIntent_PropagatesFXSideBuyBaseSellQuote(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got.GetFxSide() != fxv1.Side_BUY_BASE_SELL_QUOTE {
|
||||
t.Fatalf("unexpected fx_side: got=%s", got.GetFxSide().String())
|
||||
if got.GetFx() == nil || got.GetFx().GetPair() == nil {
|
||||
t.Fatalf("expected fx intent")
|
||||
}
|
||||
if got.GetFx().GetSide() != fxv1.Side_BUY_BASE_SELL_QUOTE {
|
||||
t.Fatalf("unexpected fx side: got=%s", got.GetFx().GetSide().String())
|
||||
}
|
||||
if got.GetSettlementCurrency() != "RUB" {
|
||||
t.Fatalf("unexpected settlement currency: got=%q", got.GetSettlementCurrency())
|
||||
|
||||
@@ -89,7 +89,7 @@ func (a *PaymentAPI) initiatePayment(r *http.Request, account *model.Account, to
|
||||
req := &orchestrationv2.ExecutePaymentRequest{
|
||||
Meta: requestMeta(orgRef.Hex(), payload.IdempotencyKey),
|
||||
QuotationRef: quotationRef,
|
||||
ClientPaymentRef: metadataValue(payload.Metadata, "client_payment_ref"),
|
||||
ClientPaymentRef: strings.TrimSpace(payload.ClientPaymentRef),
|
||||
}
|
||||
|
||||
resp, err := a.execution.ExecutePayment(ctx, req)
|
||||
@@ -110,6 +110,7 @@ func decodeInitiatePayload(r *http.Request) (*srequest.InitiatePayment, error) {
|
||||
}
|
||||
payload.IdempotencyKey = strings.TrimSpace(payload.IdempotencyKey)
|
||||
payload.QuoteRef = strings.TrimSpace(payload.QuoteRef)
|
||||
payload.ClientPaymentRef = strings.TrimSpace(payload.ClientPaymentRef)
|
||||
|
||||
if err := payload.Validate(); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -14,12 +14,12 @@ import (
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
func TestInitiateByQuote_DoesNotUseIntentRef(t *testing.T) {
|
||||
func TestInitiateByQuote_ForwardsClientPaymentRef(t *testing.T) {
|
||||
orgRef := bson.NewObjectID()
|
||||
exec := &fakeExecutionClientForBatch{}
|
||||
api := newBatchAPI(exec)
|
||||
|
||||
body := `{"idempotencyKey":"idem-by-quote","quoteRef":"quote-1","metadata":{"client_payment_ref":"client-ref-1"}}`
|
||||
body := `{"idempotencyKey":"idem-by-quote","quoteRef":"quote-1","clientPaymentRef":"client-ref-1"}`
|
||||
rr := invokeInitiateByQuote(t, api, orgRef, body)
|
||||
if got, want := rr.Code, http.StatusOK; got != want {
|
||||
t.Fatalf("status mismatch: got=%d want=%d body=%s", got, want, rr.Body.String())
|
||||
@@ -32,6 +32,24 @@ func TestInitiateByQuote_DoesNotUseIntentRef(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitiateByQuote_DoesNotForwardLegacyClientPaymentRefFromMetadata(t *testing.T) {
|
||||
orgRef := bson.NewObjectID()
|
||||
exec := &fakeExecutionClientForBatch{}
|
||||
api := newBatchAPI(exec)
|
||||
|
||||
body := `{"idempotencyKey":"idem-by-quote","quoteRef":"quote-1","metadata":{"client_payment_ref":"legacy-client-ref"}}`
|
||||
rr := invokeInitiateByQuote(t, api, orgRef, body)
|
||||
if got, want := rr.Code, http.StatusOK; got != want {
|
||||
t.Fatalf("status mismatch: got=%d want=%d body=%s", got, want, rr.Body.String())
|
||||
}
|
||||
if got, want := len(exec.executeReqs), 1; got != want {
|
||||
t.Fatalf("execute calls mismatch: got=%d want=%d", got, want)
|
||||
}
|
||||
if got := exec.executeReqs[0].GetClientPaymentRef(); got != "" {
|
||||
t.Fatalf("expected empty client_payment_ref, got=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitiateByQuote_RejectsMetadataIntentRef(t *testing.T) {
|
||||
orgRef := bson.NewObjectID()
|
||||
exec := &fakeExecutionClientForBatch{}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"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"
|
||||
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"
|
||||
@@ -39,7 +40,7 @@ func (a *PaymentAPI) initiatePaymentsByQuote(r *http.Request, account *model.Acc
|
||||
return response.BadPayload(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
clientPaymentRef := metadataValue(payload.Metadata, "client_payment_ref")
|
||||
clientPaymentRef := strings.TrimSpace(payload.ClientPaymentRef)
|
||||
idempotencyKey := strings.TrimSpace(payload.IdempotencyKey)
|
||||
quotationRef := strings.TrimSpace(payload.QuoteRef)
|
||||
|
||||
@@ -50,7 +51,7 @@ func (a *PaymentAPI) initiatePaymentsByQuote(r *http.Request, account *model.Acc
|
||||
}
|
||||
resp, err := a.execution.ExecuteBatchPayment(ctx, req)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to initiate batch payments", zap.Error(err), zap.String("organization_ref", orgRef.Hex()))
|
||||
a.logger.Warn("Failed to initiate batch payments", zap.Error(err), mzap.ObjRef("organization_ref", orgRef))
|
||||
return grpcErrorResponse(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
@@ -72,6 +73,7 @@ func decodeInitiatePaymentsPayload(r *http.Request) (*srequest.InitiatePayments,
|
||||
}
|
||||
payload.IdempotencyKey = strings.TrimSpace(payload.IdempotencyKey)
|
||||
payload.QuoteRef = strings.TrimSpace(payload.QuoteRef)
|
||||
payload.ClientPaymentRef = strings.TrimSpace(payload.ClientPaymentRef)
|
||||
|
||||
if err := payload.Validate(); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -50,7 +50,7 @@ func TestInitiatePaymentsByQuote_ForwardsClientPaymentRef(t *testing.T) {
|
||||
exec := &fakeExecutionClientForBatch{}
|
||||
api := newBatchAPI(exec)
|
||||
|
||||
body := `{"idempotencyKey":"idem-batch","quoteRef":"quote-1","metadata":{"client_payment_ref":"client-ref-1"}}`
|
||||
body := `{"idempotencyKey":"idem-batch","quoteRef":"quote-1","clientPaymentRef":"client-ref-1"}`
|
||||
rr := invokeInitiatePaymentsByQuote(t, api, orgRef, body)
|
||||
if got, want := rr.Code, http.StatusOK; got != want {
|
||||
t.Fatalf("status mismatch: got=%d want=%d body=%s", got, want, rr.Body.String())
|
||||
@@ -67,6 +67,25 @@ func TestInitiatePaymentsByQuote_ForwardsClientPaymentRef(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitiatePaymentsByQuote_DoesNotForwardLegacyClientPaymentRefFromMetadata(t *testing.T) {
|
||||
orgRef := bson.NewObjectID()
|
||||
exec := &fakeExecutionClientForBatch{}
|
||||
api := newBatchAPI(exec)
|
||||
|
||||
body := `{"idempotencyKey":"idem-batch","quoteRef":"quote-1","metadata":{"client_payment_ref":"legacy-client-ref"}}`
|
||||
rr := invokeInitiatePaymentsByQuote(t, api, orgRef, body)
|
||||
if got, want := rr.Code, http.StatusOK; got != want {
|
||||
t.Fatalf("status mismatch: got=%d want=%d body=%s", got, want, rr.Body.String())
|
||||
}
|
||||
|
||||
if got, want := len(exec.executeBatchReqs), 1; got != want {
|
||||
t.Fatalf("execute batch calls mismatch: got=%d want=%d", got, want)
|
||||
}
|
||||
if got := exec.executeBatchReqs[0].GetClientPaymentRef(); got != "" {
|
||||
t.Fatalf("expected empty client_payment_ref, got=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitiatePaymentsByQuote_RejectsDeprecatedIntentRefField(t *testing.T) {
|
||||
orgRef := bson.NewObjectID()
|
||||
exec := &fakeExecutionClientForBatch{}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"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"
|
||||
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"
|
||||
@@ -61,7 +62,7 @@ func (a *PaymentAPI) quotePayment(r *http.Request, account *model.Account, token
|
||||
|
||||
resp, err := a.quotation.QuotePayment(ctx, req)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to quote payment", zap.Error(err), zap.String("organization_ref", orgRef.Hex()))
|
||||
a.logger.Warn("Failed to quote payment", zap.Error(err), mzap.ObjRef("organization_ref", orgRef))
|
||||
return grpcErrorResponse(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
@@ -117,7 +118,7 @@ func (a *PaymentAPI) quotePayments(r *http.Request, account *model.Account, toke
|
||||
|
||||
resp, err := a.quotation.QuotePayments(ctx, req)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to quote payments", zap.Error(err), zap.String("organization_ref", orgRef.Hex()))
|
||||
a.logger.Warn("Failed to quote payments", zap.Error(err), mzap.ObjRef("organization_ref", orgRef))
|
||||
return grpcErrorResponse(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
|
||||
@@ -213,9 +213,6 @@ func newQuotationClient(ctx context.Context, cfg quotationClientConfig, opts ...
|
||||
return nil, merrors.InvalidArgument("payment quotation: address is required")
|
||||
}
|
||||
|
||||
dialCtx, cancel := context.WithTimeout(ctx, cfg.DialTimeout)
|
||||
defer cancel()
|
||||
|
||||
dialOpts := make([]grpc.DialOption, 0, len(opts)+1)
|
||||
dialOpts = append(dialOpts, opts...)
|
||||
if cfg.Insecure {
|
||||
@@ -224,7 +221,7 @@ func newQuotationClient(ctx context.Context, cfg quotationClientConfig, opts ...
|
||||
dialOpts = append(dialOpts, grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{})))
|
||||
}
|
||||
|
||||
conn, err := grpc.DialContext(dialCtx, cfg.Address, dialOpts...)
|
||||
conn, err := grpc.NewClient(cfg.Address, dialOpts...)
|
||||
if err != nil {
|
||||
return nil, merrors.InternalWrap(err, fmt.Sprintf("payment-quotation: dial %s", cfg.Address))
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
"github.com/tech/sendico/pkg/mutil/mzap"
|
||||
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
|
||||
"github.com/tech/sendico/server/interface/api/sresponse"
|
||||
mutil "github.com/tech/sendico/server/internal/mutil/param"
|
||||
@@ -65,24 +66,24 @@ func (a *WalletAPI) getWalletBalance(r *http.Request, account *model.Account, to
|
||||
return response.Auto(a.logger, a.Name(), merrors.NoData("no crypto gateways available"))
|
||||
}
|
||||
a.logger.Debug("Resolved CRYPTO gateways for wallet balance lookup",
|
||||
zap.String("organization_ref", orgRef.Hex()),
|
||||
mzap.ObjRef("organization_ref", orgRef),
|
||||
zap.String("wallet_ref", walletRef),
|
||||
zap.Int("gateway_count", len(cryptoGateways)))
|
||||
|
||||
route, routeErr := a.walletRoute(ctx, orgRef.Hex(), walletRef)
|
||||
if routeErr != nil {
|
||||
a.logger.Warn("Failed to resolve wallet route", zap.Error(routeErr), zap.String("wallet_ref", walletRef), zap.String("organization_ref", orgRef.Hex()))
|
||||
a.logger.Warn("Failed to resolve wallet route", zap.Error(routeErr), zap.String("wallet_ref", walletRef), mzap.ObjRef("organization_ref", orgRef))
|
||||
}
|
||||
if route != nil {
|
||||
a.logger.Debug("Resolved stored wallet route",
|
||||
zap.String("organization_ref", orgRef.Hex()),
|
||||
mzap.ObjRef("organization_ref", orgRef),
|
||||
zap.String("wallet_ref", walletRef),
|
||||
zap.String("route_network", route.Network),
|
||||
zap.String("route_gateway_id", route.GatewayID))
|
||||
preferred := findGatewayForRoute(cryptoGateways, route)
|
||||
if preferred != nil {
|
||||
a.logger.Debug("Using preferred gateway from stored wallet route",
|
||||
zap.String("organization_ref", orgRef.Hex()),
|
||||
mzap.ObjRef("organization_ref", orgRef),
|
||||
zap.String("wallet_ref", walletRef),
|
||||
zap.String("gateway_id", preferred.ID),
|
||||
zap.String("network", preferred.Network),
|
||||
@@ -91,7 +92,7 @@ func (a *WalletAPI) getWalletBalance(r *http.Request, account *model.Account, to
|
||||
if preferredErr == nil && bal != nil {
|
||||
a.rememberWalletRoute(ctx, orgRef.Hex(), walletRef, preferred.Network, preferred.ID)
|
||||
a.logger.Debug("Wallet balance resolved via preferred gateway",
|
||||
zap.String("organization_ref", orgRef.Hex()),
|
||||
mzap.ObjRef("organization_ref", orgRef),
|
||||
zap.String("wallet_ref", walletRef),
|
||||
zap.String("gateway_id", preferred.ID),
|
||||
zap.String("network", preferred.Network))
|
||||
@@ -124,20 +125,20 @@ func (a *WalletAPI) getWalletBalance(r *http.Request, account *model.Account, to
|
||||
}
|
||||
} else {
|
||||
a.logger.Warn("Stored wallet route did not match any healthy discovery gateway",
|
||||
zap.String("organization_ref", orgRef.Hex()),
|
||||
mzap.ObjRef("organization_ref", orgRef),
|
||||
zap.String("wallet_ref", walletRef),
|
||||
zap.String("route_network", route.Network),
|
||||
zap.String("route_gateway_id", route.GatewayID))
|
||||
}
|
||||
} else {
|
||||
a.logger.Debug("Stored wallet route not found; using gateway fallback",
|
||||
zap.String("organization_ref", orgRef.Hex()),
|
||||
mzap.ObjRef("organization_ref", orgRef),
|
||||
zap.String("wallet_ref", walletRef))
|
||||
}
|
||||
|
||||
// Fall back to querying remaining gateways in parallel.
|
||||
a.logger.Debug("Starting fallback wallet balance fan-out",
|
||||
zap.String("organization_ref", orgRef.Hex()),
|
||||
mzap.ObjRef("organization_ref", orgRef),
|
||||
zap.String("wallet_ref", walletRef),
|
||||
zap.Int("gateway_count", len(cryptoGateways)))
|
||||
bal, err := a.queryBalanceFromGateways(ctx, cryptoGateways, orgRef.Hex(), walletRef)
|
||||
@@ -226,10 +227,6 @@ func (a *WalletAPI) queryBalanceFromGateways(ctx context.Context, gateways []dis
|
||||
}
|
||||
|
||||
func (a *WalletAPI) queryGatewayBalance(ctx context.Context, gateway discovery.GatewaySummary, walletRef string) (*connectorv1.Balance, error) {
|
||||
// Create connection with timeout
|
||||
dialCtx, cancel := context.WithTimeout(ctx, a.dialTimeout)
|
||||
defer cancel()
|
||||
|
||||
var dialOpts []grpc.DialOption
|
||||
if a.insecure {
|
||||
dialOpts = append(dialOpts, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
@@ -237,7 +234,7 @@ func (a *WalletAPI) queryGatewayBalance(ctx context.Context, gateway discovery.G
|
||||
dialOpts = append(dialOpts, grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{})))
|
||||
}
|
||||
|
||||
conn, err := grpc.DialContext(dialCtx, gateway.InvokeURI, dialOpts...)
|
||||
conn, err := grpc.NewClient(gateway.InvokeURI, dialOpts...)
|
||||
if err != nil {
|
||||
return nil, merrors.InternalWrap(err, "dial gateway")
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ func (a *WalletAPI) create(r *http.Request, account *model.Account, token *sresp
|
||||
return response.Auto(a.logger, a.Name(), merrors.InvalidArgument("no gateway available for network: "+networkName))
|
||||
}
|
||||
a.logger.Debug("Selected gateway for wallet creation",
|
||||
zap.String("organization_ref", orgRef.Hex()),
|
||||
mzap.ObjRef("organization_ref", orgRef),
|
||||
zap.String("network", networkName),
|
||||
zap.String("gateway_id", gateway.ID),
|
||||
zap.String("gateway_network", gateway.Network),
|
||||
@@ -134,7 +134,7 @@ func (a *WalletAPI) create(r *http.Request, account *model.Account, token *sresp
|
||||
a.rememberWalletRoute(ctx, orgRef.Hex(), walletRef, networkName, gateway.ID)
|
||||
a.rememberWalletRoute(ctx, orgRef.Hex(), walletRef, gateway.Network, gateway.ID)
|
||||
a.logger.Debug("Persisted wallet route after wallet creation",
|
||||
zap.String("organization_ref", orgRef.Hex()),
|
||||
mzap.ObjRef("organization_ref", orgRef),
|
||||
zap.String("wallet_ref", walletRef),
|
||||
zap.String("network", networkName),
|
||||
zap.String("gateway_id", gateway.ID))
|
||||
@@ -162,10 +162,6 @@ func findGatewayForNetwork(gateways []discovery.GatewaySummary, network string)
|
||||
}
|
||||
|
||||
func (a *WalletAPI) createWalletOnGateway(ctx context.Context, gateway discovery.GatewaySummary, req *connectorv1.OpenAccountRequest) (string, error) {
|
||||
// Create connection with timeout
|
||||
dialCtx, cancel := context.WithTimeout(ctx, a.dialTimeout)
|
||||
defer cancel()
|
||||
|
||||
var dialOpts []grpc.DialOption
|
||||
if a.insecure {
|
||||
dialOpts = append(dialOpts, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
@@ -173,7 +169,7 @@ func (a *WalletAPI) createWalletOnGateway(ctx context.Context, gateway discovery
|
||||
dialOpts = append(dialOpts, grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{})))
|
||||
}
|
||||
|
||||
conn, err := grpc.DialContext(dialCtx, gateway.InvokeURI, dialOpts...)
|
||||
conn, err := grpc.NewClient(gateway.InvokeURI, dialOpts...)
|
||||
if err != nil {
|
||||
return "", merrors.InternalWrap(err, "dial gateway")
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ func (a *WalletAPI) listWallets(r *http.Request, account *model.Account, token *
|
||||
return sresponse.Wallets(a.logger, nil, token)
|
||||
}
|
||||
a.logger.Debug("Resolved CRYPTO gateways for wallet list",
|
||||
zap.String("organization_ref", orgRef.Hex()),
|
||||
mzap.ObjRef("organization_ref", orgRef),
|
||||
zap.Int("gateway_count", len(cryptoGateways)))
|
||||
|
||||
// Build request
|
||||
@@ -80,7 +80,7 @@ func (a *WalletAPI) listWallets(r *http.Request, account *model.Account, token *
|
||||
allAccounts := a.queryAllGateways(ctx, cryptoGateways, req)
|
||||
dedupedAccounts := dedupeAccountsByWalletRef(allAccounts)
|
||||
a.logger.Debug("Wallet list fan-out completed",
|
||||
zap.String("organization_ref", orgRef.Hex()),
|
||||
mzap.ObjRef("organization_ref", orgRef),
|
||||
zap.Int("accounts_raw", len(allAccounts)),
|
||||
zap.Int("accounts_deduped", len(dedupedAccounts)),
|
||||
zap.Int("gateway_count", len(cryptoGateways)))
|
||||
@@ -215,10 +215,6 @@ func (a *WalletAPI) queryAllGateways(ctx context.Context, gateways []discovery.G
|
||||
}
|
||||
|
||||
func (a *WalletAPI) queryGateway(ctx context.Context, gateway discovery.GatewaySummary, req *connectorv1.ListAccountsRequest) ([]*connectorv1.Account, error) {
|
||||
// Create connection with timeout
|
||||
dialCtx, cancel := context.WithTimeout(ctx, a.dialTimeout)
|
||||
defer cancel()
|
||||
|
||||
var dialOpts []grpc.DialOption
|
||||
if a.insecure {
|
||||
dialOpts = append(dialOpts, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
@@ -226,7 +222,7 @@ func (a *WalletAPI) queryGateway(ctx context.Context, gateway discovery.GatewayS
|
||||
dialOpts = append(dialOpts, grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{})))
|
||||
}
|
||||
|
||||
conn, err := grpc.DialContext(dialCtx, gateway.InvokeURI, dialOpts...)
|
||||
conn, err := grpc.NewClient(gateway.InvokeURI, dialOpts...)
|
||||
if err != nil {
|
||||
return nil, merrors.InternalWrap(err, "dial gateway")
|
||||
}
|
||||
|
||||
@@ -10,10 +10,12 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/edge/callbacks/internal/model"
|
||||
"github.com/tech/sendico/edge/callbacks/internal/signing"
|
||||
"github.com/tech/sendico/edge/callbacks/internal/storage"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/mutil/mzap"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
@@ -154,7 +156,7 @@ func (s *service) runWorker(ctx context.Context, workerID string) {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *service) handleTask(ctx context.Context, workerID string, task *storage.Task) {
|
||||
func (s *service) handleTask(ctx context.Context, workerID string, task *model.Task) {
|
||||
started := time.Now()
|
||||
statusCode := 0
|
||||
result := "failed"
|
||||
@@ -168,7 +170,19 @@ func (s *service) handleTask(ctx context.Context, workerID string, task *storage
|
||||
|
||||
if err := s.security.ValidateURL(ctx, task.EndpointURL); err != nil {
|
||||
result = "blocked"
|
||||
_ = s.tasks.MarkFailed(ctx, task.ID, attempt, err.Error(), statusCode, time.Now().UTC())
|
||||
s.logger.Warn("Blocked task delivery due to URL validation failure",
|
||||
zap.String("worker_id", workerID),
|
||||
mzap.ObjRef("task_ref", task.ID),
|
||||
zap.String("event_id", task.EventID),
|
||||
zap.Error(err),
|
||||
)
|
||||
if markErr := s.tasks.MarkFailed(ctx, task.ID, attempt, err.Error(), statusCode, time.Now().UTC()); markErr != nil {
|
||||
s.logger.Warn("Failed to mark blocked task as failed",
|
||||
zap.String("worker_id", workerID),
|
||||
mzap.ObjRef("task_ref", task.ID),
|
||||
zap.Error(markErr),
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -180,7 +194,20 @@ func (s *service) handleTask(ctx context.Context, workerID string, task *storage
|
||||
signed, err := s.signer.Sign(ctx, task.SigningMode, task.SecretRef, task.Payload, time.Now().UTC())
|
||||
if err != nil {
|
||||
result = "sign_error"
|
||||
_ = s.tasks.MarkFailed(ctx, task.ID, attempt, err.Error(), statusCode, time.Now().UTC())
|
||||
s.logger.Warn("Failed to sign task payload",
|
||||
zap.String("worker_id", workerID),
|
||||
mzap.ObjRef("task_ref", task.ID),
|
||||
zap.String("event_id", task.EventID),
|
||||
zap.String("signing_mode", task.SigningMode),
|
||||
zap.Error(err),
|
||||
)
|
||||
if markErr := s.tasks.MarkFailed(ctx, task.ID, attempt, err.Error(), statusCode, time.Now().UTC()); markErr != nil {
|
||||
s.logger.Warn("Failed to mark signing-error task as failed",
|
||||
zap.String("worker_id", workerID),
|
||||
mzap.ObjRef("task_ref", task.ID),
|
||||
zap.Error(markErr),
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -190,7 +217,20 @@ func (s *service) handleTask(ctx context.Context, workerID string, task *storage
|
||||
req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, task.EndpointURL, bytes.NewReader(signed.Body))
|
||||
if err != nil {
|
||||
result = "request_error"
|
||||
_ = s.tasks.MarkFailed(ctx, task.ID, attempt, err.Error(), statusCode, time.Now().UTC())
|
||||
s.logger.Warn("Failed to build callback request",
|
||||
zap.String("worker_id", workerID),
|
||||
mzap.ObjRef("task_ref", task.ID),
|
||||
zap.String("event_id", task.EventID),
|
||||
zap.String("endpoint_url", task.EndpointURL),
|
||||
zap.Error(err),
|
||||
)
|
||||
if markErr := s.tasks.MarkFailed(ctx, task.ID, attempt, err.Error(), statusCode, time.Now().UTC()); markErr != nil {
|
||||
s.logger.Warn("Failed to mark request-error task as failed",
|
||||
zap.String("worker_id", workerID),
|
||||
mzap.ObjRef("task_ref", task.ID),
|
||||
zap.Error(markErr),
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
@@ -214,7 +254,7 @@ func (s *service) handleTask(ctx context.Context, workerID string, task *storage
|
||||
case outcomeDelivered:
|
||||
result = string(outcomeDelivered)
|
||||
if err := s.tasks.MarkDelivered(ctx, task.ID, statusCode, time.Since(started), now); err != nil {
|
||||
s.logger.Warn("Failed to mark task delivered", zap.String("worker_id", workerID), zap.String("task_id", task.ID.Hex()), zap.Error(err))
|
||||
s.logger.Warn("Failed to mark task delivered", zap.String("worker_id", workerID), mzap.ObjRef("task_ref", task.ID), zap.Error(err))
|
||||
}
|
||||
case outcomeRetry:
|
||||
if attempt < task.MaxAttempts {
|
||||
@@ -224,8 +264,17 @@ func (s *service) handleTask(ctx context.Context, workerID string, task *storage
|
||||
if reqErr == nil && statusCode > 0 {
|
||||
lastErr = "upstream returned retryable status"
|
||||
}
|
||||
s.logger.Warn("Task delivery retry scheduled",
|
||||
zap.String("worker_id", workerID),
|
||||
mzap.ObjRef("task_ref", task.ID),
|
||||
zap.String("event_id", task.EventID),
|
||||
zap.Int("attempt", attempt),
|
||||
zap.Int("status_code", statusCode),
|
||||
zap.String("reason", lastErr),
|
||||
zap.Time("next_attempt_at", next),
|
||||
)
|
||||
if err := s.tasks.MarkRetry(ctx, task.ID, attempt, next, lastErr, statusCode, now); err != nil {
|
||||
s.logger.Warn("Failed to mark task retry", zap.String("worker_id", workerID), zap.String("task_id", task.ID.Hex()), zap.Error(err))
|
||||
s.logger.Warn("Failed to mark task retry", zap.String("worker_id", workerID), mzap.ObjRef("task_ref", task.ID), zap.Error(err))
|
||||
}
|
||||
} else {
|
||||
result = string(outcomeFailed)
|
||||
@@ -233,8 +282,17 @@ func (s *service) handleTask(ctx context.Context, workerID string, task *storage
|
||||
if reqErr == nil && statusCode > 0 {
|
||||
lastErr = "upstream returned retryable status but max attempts reached"
|
||||
}
|
||||
s.logger.Warn("Task delivery failed after reaching max attempts",
|
||||
zap.String("worker_id", workerID),
|
||||
mzap.ObjRef("task_ref", task.ID),
|
||||
zap.String("event_id", task.EventID),
|
||||
zap.Int("attempt", attempt),
|
||||
zap.Int("max_attempts", task.MaxAttempts),
|
||||
zap.Int("status_code", statusCode),
|
||||
zap.String("reason", lastErr),
|
||||
)
|
||||
if err := s.tasks.MarkFailed(ctx, task.ID, attempt, lastErr, statusCode, now); err != nil {
|
||||
s.logger.Warn("Failed to mark task failed", zap.String("worker_id", workerID), zap.String("task_id", task.ID.Hex()), zap.Error(err))
|
||||
s.logger.Warn("Failed to mark task failed", zap.String("worker_id", workerID), mzap.ObjRef("task_ref", task.ID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
default:
|
||||
@@ -243,8 +301,16 @@ func (s *service) handleTask(ctx context.Context, workerID string, task *storage
|
||||
if reqErr == nil && statusCode > 0 {
|
||||
lastErr = "upstream returned non-retryable status"
|
||||
}
|
||||
s.logger.Warn("Task delivery failed",
|
||||
zap.String("worker_id", workerID),
|
||||
mzap.ObjRef("task_ref", task.ID),
|
||||
zap.String("event_id", task.EventID),
|
||||
zap.Int("attempt", attempt),
|
||||
zap.Int("status_code", statusCode),
|
||||
zap.String("reason", lastErr),
|
||||
)
|
||||
if err := s.tasks.MarkFailed(ctx, task.ID, attempt, lastErr, statusCode, now); err != nil {
|
||||
s.logger.Warn("Failed to mark task failed", zap.String("worker_id", workerID), zap.String("task_id", task.ID.Hex()), zap.Error(err))
|
||||
s.logger.Warn("Failed to mark task failed", zap.String("worker_id", workerID), mzap.ObjRef("task_ref", task.ID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,16 +4,18 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// Envelope is the canonical incoming event envelope.
|
||||
type Envelope struct {
|
||||
EventID string `json:"event_id"`
|
||||
Type string `json:"type"`
|
||||
ClientID string `json:"client_id"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
PublishedAt time.Time `json:"published_at,omitempty"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
EventID string `json:"event_id"`
|
||||
Type string `json:"type"`
|
||||
OrganizationRef bson.ObjectID `json:"organization_ref"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
PublishedAt time.Time `json:"published_at,omitempty"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
// Service parses incoming messages and builds outbound payload bytes.
|
||||
@@ -24,10 +26,10 @@ type Service interface {
|
||||
|
||||
// Payload is the stable outbound JSON body.
|
||||
type Payload struct {
|
||||
EventID string `json:"event_id"`
|
||||
Type string `json:"type"`
|
||||
ClientID string `json:"client_id"`
|
||||
OccurredAt string `json:"occurred_at"`
|
||||
PublishedAt string `json:"published_at,omitempty"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
EventID string `json:"event_id"`
|
||||
Type string `json:"type"`
|
||||
OrganizationRef bson.ObjectID `json:"organization_ref"`
|
||||
OccurredAt string `json:"occurred_at"`
|
||||
PublishedAt string `json:"published_at,omitempty"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
@@ -39,8 +40,8 @@ func (s *parserService) Parse(data []byte) (*Envelope, error) {
|
||||
if strings.TrimSpace(envelope.Type) == "" {
|
||||
return nil, merrors.InvalidArgument("type is required", "type")
|
||||
}
|
||||
if strings.TrimSpace(envelope.ClientID) == "" {
|
||||
return nil, merrors.InvalidArgument("client_id is required", "client_id")
|
||||
if envelope.OrganizationRef == bson.NilObjectID {
|
||||
return nil, merrors.InvalidArgument("organization_ref is required", "organization_ref")
|
||||
}
|
||||
if envelope.OccurredAt.IsZero() {
|
||||
return nil, merrors.InvalidArgument("occurred_at is required", "occurred_at")
|
||||
@@ -51,7 +52,6 @@ func (s *parserService) Parse(data []byte) (*Envelope, error) {
|
||||
|
||||
envelope.EventID = strings.TrimSpace(envelope.EventID)
|
||||
envelope.Type = strings.TrimSpace(envelope.Type)
|
||||
envelope.ClientID = strings.TrimSpace(envelope.ClientID)
|
||||
envelope.OccurredAt = envelope.OccurredAt.UTC()
|
||||
if !envelope.PublishedAt.IsZero() {
|
||||
envelope.PublishedAt = envelope.PublishedAt.UTC()
|
||||
@@ -66,11 +66,11 @@ func (s *parserService) BuildPayload(_ context.Context, envelope *Envelope) ([]b
|
||||
}
|
||||
|
||||
payload := Payload{
|
||||
EventID: envelope.EventID,
|
||||
Type: envelope.Type,
|
||||
ClientID: envelope.ClientID,
|
||||
OccurredAt: envelope.OccurredAt.UTC().Format(time.RFC3339Nano),
|
||||
Data: envelope.Data,
|
||||
EventID: envelope.EventID,
|
||||
Type: envelope.Type,
|
||||
OrganizationRef: envelope.OrganizationRef,
|
||||
OccurredAt: envelope.OccurredAt.UTC().Format(time.RFC3339Nano),
|
||||
Data: envelope.Data,
|
||||
}
|
||||
if !envelope.PublishedAt.IsZero() {
|
||||
payload.PublishedAt = envelope.PublishedAt.UTC().Format(time.RFC3339Nano)
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
np "github.com/tech/sendico/pkg/messaging/notifications/processor"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
@@ -178,7 +179,7 @@ func (s *service) handlePaymentStatusUpdated(ctx context.Context, msg *model.Pay
|
||||
result = ingestResultEmptyPayload
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(msg.EventID) == "" || strings.TrimSpace(msg.ClientID) == "" || msg.OccurredAt.IsZero() {
|
||||
if strings.TrimSpace(msg.EventID) == "" || msg.Data.OrganizationRef == bson.NilObjectID || msg.OccurredAt.IsZero() {
|
||||
result = ingestResultInvalidEvent
|
||||
return nil
|
||||
}
|
||||
@@ -195,15 +196,15 @@ func (s *service) handlePaymentStatusUpdated(ctx context.Context, msg *model.Pay
|
||||
}
|
||||
|
||||
parsed := &events.Envelope{
|
||||
EventID: strings.TrimSpace(msg.EventID),
|
||||
Type: eventType,
|
||||
ClientID: strings.TrimSpace(msg.ClientID),
|
||||
OccurredAt: msg.OccurredAt.UTC(),
|
||||
PublishedAt: msg.PublishedAt.UTC(),
|
||||
Data: data,
|
||||
EventID: strings.TrimSpace(msg.EventID),
|
||||
Type: eventType,
|
||||
OrganizationRef: msg.Data.OrganizationRef,
|
||||
OccurredAt: msg.OccurredAt.UTC(),
|
||||
PublishedAt: msg.PublishedAt.UTC(),
|
||||
Data: data,
|
||||
}
|
||||
|
||||
inserted, err := s.deps.InboxRepo.TryInsert(ctx, parsed.EventID, parsed.ClientID, parsed.Type, time.Now().UTC())
|
||||
inserted, err := s.deps.InboxRepo.TryInsert(ctx, parsed.EventID, parsed.Type, parsed.OrganizationRef, time.Now().UTC())
|
||||
if err != nil {
|
||||
result = ingestResultInboxError
|
||||
return err
|
||||
@@ -213,7 +214,7 @@ func (s *service) handlePaymentStatusUpdated(ctx context.Context, msg *model.Pay
|
||||
return nil
|
||||
}
|
||||
|
||||
endpoints, err := s.deps.Resolver.Resolve(ctx, parsed.ClientID, parsed.Type)
|
||||
endpoints, err := s.deps.Resolver.Resolve(ctx, parsed.Type, parsed.OrganizationRef)
|
||||
if err != nil {
|
||||
result = ingestResultResolveError
|
||||
return err
|
||||
|
||||
8
api/edge/callbacks/internal/model/callback.go
Normal file
8
api/edge/callbacks/internal/model/callback.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package model
|
||||
|
||||
import pmodel "github.com/tech/sendico/pkg/model"
|
||||
|
||||
type CallbackInternal struct {
|
||||
pmodel.Callback `bson:",inline" json:",inline"`
|
||||
SecretRef string `bson:"secretRef"`
|
||||
}
|
||||
22
api/edge/callbacks/internal/model/endpoint.go
Normal file
22
api/edge/callbacks/internal/model/endpoint.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/pkg/db/storable"
|
||||
pmodel "github.com/tech/sendico/pkg/model"
|
||||
)
|
||||
|
||||
// Endpoint describes one target callback endpoint.
|
||||
type Endpoint struct {
|
||||
storable.Base
|
||||
pmodel.OrganizationBoundBase
|
||||
URL string
|
||||
SigningMode string
|
||||
SecretRef string
|
||||
Headers map[string]string
|
||||
MaxAttempts int
|
||||
MinDelay time.Duration
|
||||
MaxDelay time.Duration
|
||||
RequestTimeout time.Duration
|
||||
}
|
||||
37
api/edge/callbacks/internal/model/task.go
Normal file
37
api/edge/callbacks/internal/model/task.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/pkg/db/storable"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// TaskStatus tracks delivery task lifecycle.
|
||||
type TaskStatus string
|
||||
|
||||
const (
|
||||
TaskStatusPending TaskStatus = "PENDING"
|
||||
TaskStatusRetry TaskStatus = "RETRY"
|
||||
TaskStatusDelivered TaskStatus = "DELIVERED"
|
||||
TaskStatusFailed TaskStatus = "FAILED"
|
||||
)
|
||||
|
||||
// Task is one callback delivery job.
|
||||
type Task struct {
|
||||
storable.Base
|
||||
EventID string
|
||||
EndpointRef bson.ObjectID
|
||||
EndpointURL string
|
||||
SigningMode string
|
||||
SecretRef string
|
||||
Headers map[string]string
|
||||
Payload []byte
|
||||
Attempt int
|
||||
MaxAttempts int
|
||||
MinDelay time.Duration
|
||||
MaxDelay time.Duration
|
||||
RequestTimeout time.Duration
|
||||
Status TaskStatus
|
||||
NextAttemptAt time.Time
|
||||
}
|
||||
@@ -10,9 +10,12 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type service struct {
|
||||
logger mlogger.Logger
|
||||
requireHTTPS bool
|
||||
allowedHosts map[string]struct{}
|
||||
allowedPorts map[int]struct{}
|
||||
@@ -21,7 +24,11 @@ type service struct {
|
||||
}
|
||||
|
||||
// New creates URL validator.
|
||||
func New(cfg Config) Validator {
|
||||
func New(logger mlogger.Logger, cfg Config) Validator {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
|
||||
hosts := make(map[string]struct{}, len(cfg.AllowedHosts))
|
||||
for _, host := range cfg.AllowedHosts {
|
||||
h := strings.ToLower(strings.TrimSpace(host))
|
||||
@@ -43,6 +50,7 @@ func New(cfg Config) Validator {
|
||||
}
|
||||
|
||||
return &service{
|
||||
logger: logger.Named("security"),
|
||||
requireHTTPS: cfg.RequireHTTPS,
|
||||
allowedHosts: hosts,
|
||||
allowedPorts: ports,
|
||||
@@ -54,24 +62,33 @@ func New(cfg Config) Validator {
|
||||
func (s *service) ValidateURL(ctx context.Context, target string) error {
|
||||
parsed, err := url.Parse(strings.TrimSpace(target))
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to parse callback URL", zap.Error(err))
|
||||
return merrors.InvalidArgumentWrap(err, "invalid callback URL", "url")
|
||||
}
|
||||
if parsed == nil || parsed.Host == "" {
|
||||
s.logger.Warn("Callback URL host is required")
|
||||
return merrors.InvalidArgument("callback URL host is required", "url")
|
||||
}
|
||||
if parsed.User != nil {
|
||||
s.logger.Warn("Rejected callback URL with credentials", zap.String("host", parsed.Hostname()))
|
||||
return merrors.InvalidArgument("callback URL credentials are not allowed", "url")
|
||||
}
|
||||
if s.requireHTTPS && !strings.EqualFold(parsed.Scheme, "https") {
|
||||
s.logger.Warn("Rejected callback URL due to non-https scheme",
|
||||
zap.String("scheme", parsed.Scheme),
|
||||
zap.String("host", parsed.Hostname()),
|
||||
)
|
||||
return merrors.InvalidArgument("callback URL must use HTTPS", "url")
|
||||
}
|
||||
|
||||
host := strings.ToLower(strings.TrimSpace(parsed.Hostname()))
|
||||
if host == "" {
|
||||
s.logger.Warn("Callback URL host is empty")
|
||||
return merrors.InvalidArgument("callback URL host is empty", "url")
|
||||
}
|
||||
if len(s.allowedHosts) > 0 {
|
||||
if _, ok := s.allowedHosts[host]; !ok {
|
||||
s.logger.Warn("Rejected callback host not present in allowlist", zap.String("host", host))
|
||||
return merrors.InvalidArgument("callback host is not in allowlist", "url.host")
|
||||
}
|
||||
}
|
||||
@@ -82,12 +99,20 @@ func (s *service) ValidateURL(ctx context.Context, target string) error {
|
||||
}
|
||||
if len(s.allowedPorts) > 0 {
|
||||
if _, ok := s.allowedPorts[port]; !ok {
|
||||
s.logger.Warn("Rejected callback URL port not present in allowlist",
|
||||
zap.String("host", host),
|
||||
zap.Int("port", port),
|
||||
)
|
||||
return merrors.InvalidArgument("callback URL port is not allowed", "url.port")
|
||||
}
|
||||
}
|
||||
|
||||
if addr, addrErr := netip.ParseAddr(host); addrErr == nil {
|
||||
if isBlocked(addr) {
|
||||
s.logger.Warn("Rejected callback URL with blocked IP address",
|
||||
zap.String("host", host),
|
||||
zap.String("ip", addr.String()),
|
||||
)
|
||||
return merrors.InvalidArgument("callback URL resolves to blocked IP range", "url")
|
||||
}
|
||||
return nil
|
||||
@@ -102,9 +127,11 @@ func (s *service) ValidateURL(ctx context.Context, target string) error {
|
||||
|
||||
ips, err := s.resolver.LookupIPAddr(lookupCtx, host)
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to resolve callback host", zap.String("host", host), zap.Error(err))
|
||||
return merrors.InternalWrap(err, "failed to resolve callback host")
|
||||
}
|
||||
if len(ips) == 0 {
|
||||
s.logger.Warn("Callback host did not resolve", zap.String("host", host))
|
||||
return merrors.InvalidArgument("callback host did not resolve", "url.host")
|
||||
}
|
||||
for _, ip := range ips {
|
||||
@@ -113,6 +140,10 @@ func (s *service) ValidateURL(ctx context.Context, target string) error {
|
||||
}
|
||||
addr, ok := netip.AddrFromSlice(ip.IP)
|
||||
if ok && isBlocked(addr) {
|
||||
s.logger.Warn("Rejected callback URL resolving to blocked IP address",
|
||||
zap.String("host", host),
|
||||
zap.String("ip", addr.String()),
|
||||
)
|
||||
return merrors.InvalidArgument("callback URL resolves to blocked IP range", "url.host")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,13 +65,16 @@ func (i *Imp) Start() error {
|
||||
return err
|
||||
}
|
||||
|
||||
resolver, err := subscriptions.New(subscriptions.Dependencies{EndpointRepo: repo.Endpoints()})
|
||||
resolver, err := subscriptions.New(subscriptions.Dependencies{
|
||||
EndpointRepo: repo.Endpoints(),
|
||||
Logger: i.logger,
|
||||
})
|
||||
if err != nil {
|
||||
i.shutdownRuntime(context.Background())
|
||||
return err
|
||||
}
|
||||
|
||||
securityValidator := security.New(security.Config{
|
||||
securityValidator := security.New(i.logger, security.Config{
|
||||
RequireHTTPS: cfg.Security.RequireHTTPS,
|
||||
AllowedHosts: cfg.Security.AllowedHosts,
|
||||
AllowedPorts: cfg.Security.AllowedPorts,
|
||||
|
||||
@@ -4,54 +4,12 @@ import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/edge/callbacks/internal/model"
|
||||
"github.com/tech/sendico/pkg/db"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// TaskStatus tracks delivery task lifecycle.
|
||||
type TaskStatus string
|
||||
|
||||
const (
|
||||
TaskStatusPending TaskStatus = "PENDING"
|
||||
TaskStatusRetry TaskStatus = "RETRY"
|
||||
TaskStatusDelivered TaskStatus = "DELIVERED"
|
||||
TaskStatusFailed TaskStatus = "FAILED"
|
||||
)
|
||||
|
||||
// Endpoint describes one target callback endpoint.
|
||||
type Endpoint struct {
|
||||
ID bson.ObjectID
|
||||
ClientID string
|
||||
URL string
|
||||
SigningMode string
|
||||
SecretRef string
|
||||
Headers map[string]string
|
||||
MaxAttempts int
|
||||
MinDelay time.Duration
|
||||
MaxDelay time.Duration
|
||||
RequestTimeout time.Duration
|
||||
}
|
||||
|
||||
// Task is one callback delivery job.
|
||||
type Task struct {
|
||||
ID bson.ObjectID
|
||||
EventID string
|
||||
EndpointID bson.ObjectID
|
||||
EndpointURL string
|
||||
SigningMode string
|
||||
SecretRef string
|
||||
Headers map[string]string
|
||||
Payload []byte
|
||||
Attempt int
|
||||
MaxAttempts int
|
||||
MinDelay time.Duration
|
||||
MaxDelay time.Duration
|
||||
RequestTimeout time.Duration
|
||||
Status TaskStatus
|
||||
NextAttemptAt time.Time
|
||||
}
|
||||
|
||||
// TaskDefaults are applied when creating tasks.
|
||||
type TaskDefaults struct {
|
||||
MaxAttempts int
|
||||
@@ -69,18 +27,18 @@ type Options struct {
|
||||
|
||||
// InboxRepo controls event dedupe state.
|
||||
type InboxRepo interface {
|
||||
TryInsert(ctx context.Context, eventID, clientID, eventType string, at time.Time) (bool, error)
|
||||
TryInsert(ctx context.Context, eventID, ceventType string, organizationRef bson.ObjectID, at time.Time) (bool, error)
|
||||
}
|
||||
|
||||
// EndpointRepo resolves endpoints for events.
|
||||
type EndpointRepo interface {
|
||||
FindActiveByClientAndType(ctx context.Context, clientID, eventType string) ([]Endpoint, error)
|
||||
FindActive(ctx context.Context, eventType string, organizationRef bson.ObjectID) ([]model.Endpoint, error)
|
||||
}
|
||||
|
||||
// TaskRepo manages callback tasks.
|
||||
type TaskRepo interface {
|
||||
UpsertTasks(ctx context.Context, eventID string, endpoints []Endpoint, payload []byte, defaults TaskDefaults, at time.Time) error
|
||||
LockNextTask(ctx context.Context, now time.Time, workerID string, lockTTL time.Duration) (*Task, error)
|
||||
UpsertTasks(ctx context.Context, eventID string, endpoints []model.Endpoint, payload []byte, defaults TaskDefaults, at time.Time) error
|
||||
LockNextTask(ctx context.Context, now time.Time, workerID string, lockTTL time.Duration) (*model.Task, error)
|
||||
MarkDelivered(ctx context.Context, taskID bson.ObjectID, httpCode int, latency time.Duration, at time.Time) error
|
||||
MarkRetry(ctx context.Context, taskID bson.ObjectID, attempt int, nextAttemptAt time.Time, lastError string, httpCode int, at time.Time) error
|
||||
MarkFailed(ctx context.Context, taskID bson.ObjectID, attempt int, lastError string, httpCode int, at time.Time) error
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/edge/callbacks/internal/model"
|
||||
"github.com/tech/sendico/pkg/db"
|
||||
"github.com/tech/sendico/pkg/db/repository"
|
||||
"github.com/tech/sendico/pkg/db/repository/builder"
|
||||
@@ -13,6 +14,8 @@ import (
|
||||
"github.com/tech/sendico/pkg/db/storable"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
pmodel "github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
mutil "github.com/tech/sendico/pkg/mutil/db"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
@@ -22,7 +25,7 @@ import (
|
||||
const (
|
||||
inboxCollection string = "inbox"
|
||||
tasksCollection string = "tasks"
|
||||
endpointsCollection string = "endpoints"
|
||||
endpointsCollection string = mservice.Callbacks
|
||||
)
|
||||
|
||||
type mongoRepository struct {
|
||||
@@ -38,10 +41,10 @@ type mongoRepository struct {
|
||||
}
|
||||
|
||||
type inboxDoc struct {
|
||||
storable.Base `bson:",inline"`
|
||||
EventID string `bson:"event_id"`
|
||||
ClientID string `bson:"client_id"`
|
||||
EventType string `bson:"event_type"`
|
||||
storable.Base `bson:",inline"`
|
||||
pmodel.OrganizationBoundBase `bson:",inline"`
|
||||
EventID string `bson:"event_id"`
|
||||
EventType string `bson:"event_type"`
|
||||
}
|
||||
|
||||
func (d *inboxDoc) Collection() string {
|
||||
@@ -63,12 +66,12 @@ type deliveryPolicy struct {
|
||||
}
|
||||
|
||||
type endpointDoc struct {
|
||||
storable.Base `bson:",inline"`
|
||||
deliveryPolicy `bson:"retry_policy"`
|
||||
ClientID string `bson:"client_id"`
|
||||
Status string `bson:"status"`
|
||||
URL string `bson:"url"`
|
||||
EventTypes []string `bson:"event_types"`
|
||||
storable.Base `bson:",inline"`
|
||||
pmodel.OrganizationBoundBase `bson:",inline"`
|
||||
deliveryPolicy `bson:"retry_policy"`
|
||||
Status string `bson:"status"`
|
||||
URL string `bson:"url"`
|
||||
EventTypes []string `bson:"event_types"`
|
||||
}
|
||||
|
||||
func (d *endpointDoc) Collection() string {
|
||||
@@ -78,18 +81,18 @@ func (d *endpointDoc) Collection() string {
|
||||
type taskDoc struct {
|
||||
storable.Base `bson:",inline"`
|
||||
deliveryPolicy `bson:"retry_policy"`
|
||||
EventID string `bson:"event_id"`
|
||||
EndpointID bson.ObjectID `bson:"endpoint_id"`
|
||||
EndpointURL string `bson:"endpoint_url"`
|
||||
Payload []byte `bson:"payload"`
|
||||
Status TaskStatus `bson:"status"`
|
||||
Attempt int `bson:"attempt"`
|
||||
LastError string `bson:"last_error,omitempty"`
|
||||
LastHTTPCode int `bson:"last_http_code,omitempty"`
|
||||
NextAttemptAt time.Time `bson:"next_attempt_at"`
|
||||
LockedUntil *time.Time `bson:"locked_until,omitempty"`
|
||||
WorkerID string `bson:"worker_id,omitempty"`
|
||||
DeliveredAt *time.Time `bson:"delivered_at,omitempty"`
|
||||
EventID string `bson:"event_id"`
|
||||
EndpointRef bson.ObjectID `bson:"endpoint_ref"`
|
||||
EndpointURL string `bson:"endpoint_url"`
|
||||
Payload []byte `bson:"payload"`
|
||||
Status model.TaskStatus `bson:"status"`
|
||||
Attempt int `bson:"attempt"`
|
||||
LastError string `bson:"last_error,omitempty"`
|
||||
LastHTTPCode int `bson:"last_http_code,omitempty"`
|
||||
NextAttemptAt time.Time `bson:"next_attempt_at"`
|
||||
LockedUntil *time.Time `bson:"locked_until,omitempty"`
|
||||
WorkerID string `bson:"worker_id,omitempty"`
|
||||
DeliveredAt *time.Time `bson:"delivered_at,omitempty"`
|
||||
}
|
||||
|
||||
func (d *taskDoc) Collection() string {
|
||||
@@ -151,7 +154,7 @@ func (m *mongoRepository) ensureIndexes() error {
|
||||
Unique: true,
|
||||
Keys: []ri.Key{
|
||||
{Field: "event_id", Sort: ri.Asc},
|
||||
{Field: "endpoint_id", Sort: ri.Asc},
|
||||
{Field: "endpoint_ref", Sort: ri.Asc},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -171,7 +174,7 @@ func (m *mongoRepository) ensureIndexes() error {
|
||||
if err := m.endpointsRepo.CreateIndex(&ri.Definition{
|
||||
Name: "idx_client_event",
|
||||
Keys: []ri.Key{
|
||||
{Field: "client_id", Sort: ri.Asc},
|
||||
{Field: "organization_ref", Sort: ri.Asc},
|
||||
{Field: "status", Sort: ri.Asc},
|
||||
{Field: "event_types", Sort: ri.Asc},
|
||||
},
|
||||
@@ -187,11 +190,11 @@ type inboxStore struct {
|
||||
repo repository.Repository
|
||||
}
|
||||
|
||||
func (r *inboxStore) TryInsert(ctx context.Context, eventID, clientID, eventType string, at time.Time) (bool, error) {
|
||||
func (r *inboxStore) TryInsert(ctx context.Context, eventID, eventType string, organizationRef bson.ObjectID, at time.Time) (bool, error) {
|
||||
doc := &inboxDoc{
|
||||
EventID: strings.TrimSpace(eventID),
|
||||
ClientID: strings.TrimSpace(clientID),
|
||||
EventType: strings.TrimSpace(eventType),
|
||||
OrganizationBoundBase: pmodel.OrganizationBoundBase{OrganizationRef: organizationRef},
|
||||
EventID: strings.TrimSpace(eventID),
|
||||
EventType: strings.TrimSpace(eventType),
|
||||
}
|
||||
|
||||
filter := repository.Filter("event_id", doc.EventID)
|
||||
@@ -211,21 +214,20 @@ type endpointStore struct {
|
||||
repo repository.Repository
|
||||
}
|
||||
|
||||
func (r *endpointStore) FindActiveByClientAndType(ctx context.Context, clientID, eventType string) ([]Endpoint, error) {
|
||||
clientID = strings.TrimSpace(clientID)
|
||||
func (r *endpointStore) FindActive(ctx context.Context, eventType string, organizationRef bson.ObjectID) ([]model.Endpoint, error) {
|
||||
eventType = strings.TrimSpace(eventType)
|
||||
if clientID == "" {
|
||||
return nil, merrors.InvalidArgument("client_id is required", "client_id")
|
||||
if organizationRef == bson.NilObjectID {
|
||||
return nil, merrors.InvalidArgument("organization_ref is required", "organization_ref")
|
||||
}
|
||||
if eventType == "" {
|
||||
return nil, merrors.InvalidArgument("event type is required", "event_type")
|
||||
}
|
||||
|
||||
query := repository.Query().
|
||||
Filter(repository.Field("client_id"), clientID).
|
||||
Filter(repository.OrgField(), organizationRef).
|
||||
In(repository.Field("status"), "active", "enabled")
|
||||
|
||||
out := make([]Endpoint, 0)
|
||||
out := make([]model.Endpoint, 0)
|
||||
err := r.repo.FindManyByFilter(ctx, query, func(cur *mongo.Cursor) error {
|
||||
doc := &endpointDoc{}
|
||||
if err := cur.Decode(doc); err != nil {
|
||||
@@ -237,17 +239,17 @@ func (r *endpointStore) FindActiveByClientAndType(ctx context.Context, clientID,
|
||||
if !supportsEventType(doc.EventTypes, eventType) {
|
||||
return nil
|
||||
}
|
||||
out = append(out, Endpoint{
|
||||
ID: doc.ID,
|
||||
ClientID: doc.ClientID,
|
||||
URL: strings.TrimSpace(doc.URL),
|
||||
SigningMode: strings.TrimSpace(doc.SigningMode),
|
||||
SecretRef: strings.TrimSpace(doc.SecretRef),
|
||||
Headers: cloneHeaders(doc.Headers),
|
||||
MaxAttempts: doc.MaxAttempts,
|
||||
MinDelay: time.Duration(doc.MinDelayMS) * time.Millisecond,
|
||||
MaxDelay: time.Duration(doc.MaxDelayMS) * time.Millisecond,
|
||||
RequestTimeout: time.Duration(doc.RequestTimeoutMS) * time.Millisecond,
|
||||
out = append(out, model.Endpoint{
|
||||
Base: doc.Base,
|
||||
OrganizationBoundBase: doc.OrganizationBoundBase,
|
||||
URL: strings.TrimSpace(doc.URL),
|
||||
SigningMode: strings.TrimSpace(doc.SigningMode),
|
||||
SecretRef: strings.TrimSpace(doc.SecretRef),
|
||||
Headers: cloneHeaders(doc.Headers),
|
||||
MaxAttempts: doc.MaxAttempts,
|
||||
MinDelay: time.Duration(doc.MinDelayMS) * time.Millisecond,
|
||||
MaxDelay: time.Duration(doc.MaxDelayMS) * time.Millisecond,
|
||||
RequestTimeout: time.Duration(doc.RequestTimeoutMS) * time.Millisecond,
|
||||
})
|
||||
return nil
|
||||
})
|
||||
@@ -280,7 +282,7 @@ type taskStore struct {
|
||||
repo repository.Repository
|
||||
}
|
||||
|
||||
func (r *taskStore) UpsertTasks(ctx context.Context, eventID string, endpoints []Endpoint, payload []byte, defaults TaskDefaults, at time.Time) error {
|
||||
func (r *taskStore) UpsertTasks(ctx context.Context, eventID string, endpoints []model.Endpoint, payload []byte, defaults TaskDefaults, at time.Time) error {
|
||||
eventID = strings.TrimSpace(eventID)
|
||||
if eventID == "" {
|
||||
return merrors.InvalidArgument("event id is required", "event_id")
|
||||
@@ -291,7 +293,7 @@ func (r *taskStore) UpsertTasks(ctx context.Context, eventID string, endpoints [
|
||||
|
||||
now := at.UTC()
|
||||
for _, endpoint := range endpoints {
|
||||
if endpoint.ID == bson.NilObjectID {
|
||||
if endpoint.GetID() == nil || *endpoint.GetID() == bson.NilObjectID {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -326,13 +328,13 @@ func (r *taskStore) UpsertTasks(ctx context.Context, eventID string, endpoints [
|
||||
|
||||
doc := &taskDoc{}
|
||||
doc.EventID = eventID
|
||||
doc.EndpointID = endpoint.ID
|
||||
doc.EndpointRef = *endpoint.GetID()
|
||||
doc.EndpointURL = strings.TrimSpace(endpoint.URL)
|
||||
doc.SigningMode = strings.TrimSpace(endpoint.SigningMode)
|
||||
doc.SecretRef = strings.TrimSpace(endpoint.SecretRef)
|
||||
doc.Headers = cloneHeaders(endpoint.Headers)
|
||||
doc.Payload = append([]byte(nil), payload...)
|
||||
doc.Status = TaskStatusPending
|
||||
doc.Status = model.TaskStatusPending
|
||||
doc.Attempt = 0
|
||||
doc.MaxAttempts = maxAttempts
|
||||
doc.MinDelayMS = int(minDelay / time.Millisecond)
|
||||
@@ -340,7 +342,7 @@ func (r *taskStore) UpsertTasks(ctx context.Context, eventID string, endpoints [
|
||||
doc.RequestTimeoutMS = int(requestTimeout / time.Millisecond)
|
||||
doc.NextAttemptAt = now
|
||||
|
||||
filter := repository.Filter("event_id", eventID).And(repository.Filter("endpoint_id", endpoint.ID))
|
||||
filter := repository.Filter("event_id", eventID).And(repository.Filter("endpoint_ref", endpoint.ID))
|
||||
if err := r.repo.Insert(ctx, doc, filter); err != nil {
|
||||
if errors.Is(err, merrors.ErrDataConflict) {
|
||||
continue
|
||||
@@ -352,7 +354,7 @@ func (r *taskStore) UpsertTasks(ctx context.Context, eventID string, endpoints [
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *taskStore) LockNextTask(ctx context.Context, now time.Time, workerID string, lockTTL time.Duration) (*Task, error) {
|
||||
func (r *taskStore) LockNextTask(ctx context.Context, now time.Time, workerID string, lockTTL time.Duration) (*model.Task, error) {
|
||||
workerID = strings.TrimSpace(workerID)
|
||||
if workerID == "" {
|
||||
return nil, merrors.InvalidArgument("worker id is required", "worker_id")
|
||||
@@ -367,7 +369,7 @@ func (r *taskStore) LockNextTask(ctx context.Context, now time.Time, workerID st
|
||||
)
|
||||
|
||||
query := repository.Query().
|
||||
In(repository.Field("status"), string(TaskStatusPending), string(TaskStatusRetry)).
|
||||
In(repository.Field("status"), string(model.TaskStatusPending), string(model.TaskStatusRetry)).
|
||||
Comparison(repository.Field("next_attempt_at"), builder.Lte, now).
|
||||
And(lockFilter).
|
||||
Sort(repository.Field("next_attempt_at"), true).
|
||||
@@ -389,7 +391,7 @@ func (r *taskStore) LockNextTask(ctx context.Context, now time.Time, workerID st
|
||||
Set(repository.Field("worker_id"), workerID)
|
||||
|
||||
conditional := repository.IDFilter(candidate.ID).And(
|
||||
repository.Query().In(repository.Field("status"), string(TaskStatusPending), string(TaskStatusRetry)),
|
||||
repository.Query().In(repository.Field("status"), string(model.TaskStatusPending), string(model.TaskStatusRetry)),
|
||||
repository.Query().Comparison(repository.Field("next_attempt_at"), builder.Lte, now),
|
||||
lockFilter,
|
||||
)
|
||||
@@ -426,7 +428,7 @@ func (r *taskStore) MarkDelivered(ctx context.Context, taskID bson.ObjectID, htt
|
||||
}
|
||||
|
||||
patch := repository.Patch().
|
||||
Set(repository.Field("status"), TaskStatusDelivered).
|
||||
Set(repository.Field("status"), model.TaskStatusDelivered).
|
||||
Set(repository.Field("last_http_code"), httpCode).
|
||||
Set(repository.Field("delivered_at"), time.Now()).
|
||||
Set(repository.Field("locked_until"), nil).
|
||||
@@ -445,7 +447,7 @@ func (r *taskStore) MarkRetry(ctx context.Context, taskID bson.ObjectID, attempt
|
||||
}
|
||||
|
||||
patch := repository.Patch().
|
||||
Set(repository.Field("status"), TaskStatusRetry).
|
||||
Set(repository.Field("status"), model.TaskStatusRetry).
|
||||
Set(repository.Field("attempt"), attempt).
|
||||
Set(repository.Field("next_attempt_at"), nextAttemptAt.UTC()).
|
||||
Set(repository.Field("last_error"), strings.TrimSpace(lastError)).
|
||||
@@ -465,7 +467,7 @@ func (r *taskStore) MarkFailed(ctx context.Context, taskID bson.ObjectID, attemp
|
||||
}
|
||||
|
||||
patch := repository.Patch().
|
||||
Set(repository.Field("status"), TaskStatusFailed).
|
||||
Set(repository.Field("status"), model.TaskStatusFailed).
|
||||
Set(repository.Field("attempt"), attempt).
|
||||
Set(repository.Field("last_error"), strings.TrimSpace(lastError)).
|
||||
Set(repository.Field("last_http_code"), httpCode).
|
||||
@@ -478,14 +480,14 @@ func (r *taskStore) MarkFailed(ctx context.Context, taskID bson.ObjectID, attemp
|
||||
return nil
|
||||
}
|
||||
|
||||
func mapTaskDoc(doc *taskDoc) *Task {
|
||||
func mapTaskDoc(doc *taskDoc) *model.Task {
|
||||
if doc == nil {
|
||||
return nil
|
||||
}
|
||||
return &Task{
|
||||
ID: doc.ID,
|
||||
return &model.Task{
|
||||
Base: doc.Base,
|
||||
EventID: doc.EventID,
|
||||
EndpointID: doc.EndpointID,
|
||||
EndpointRef: doc.EndpointRef,
|
||||
EndpointURL: doc.EndpointURL,
|
||||
SigningMode: doc.SigningMode,
|
||||
SecretRef: doc.SecretRef,
|
||||
|
||||
@@ -3,15 +3,19 @@ package subscriptions
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/tech/sendico/edge/callbacks/internal/model"
|
||||
"github.com/tech/sendico/edge/callbacks/internal/storage"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// Resolver resolves active webhook endpoints for an event.
|
||||
type Resolver interface {
|
||||
Resolve(ctx context.Context, clientID, eventType string) ([]storage.Endpoint, error)
|
||||
Resolve(ctx context.Context, eventType string, organizationRef bson.ObjectID) ([]model.Endpoint, error)
|
||||
}
|
||||
|
||||
// Dependencies defines subscriptions resolver dependencies.
|
||||
type Dependencies struct {
|
||||
EndpointRepo storage.EndpointRepo
|
||||
Logger mlogger.Logger
|
||||
}
|
||||
|
||||
@@ -4,12 +4,18 @@ import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/edge/callbacks/internal/model"
|
||||
"github.com/tech/sendico/edge/callbacks/internal/storage"
|
||||
"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"
|
||||
)
|
||||
|
||||
type service struct {
|
||||
repo storage.EndpointRepo
|
||||
logger mlogger.Logger
|
||||
repo storage.EndpointRepo
|
||||
}
|
||||
|
||||
// New creates endpoint resolver service.
|
||||
@@ -17,22 +23,40 @@ func New(deps Dependencies) (Resolver, error) {
|
||||
if deps.EndpointRepo == nil {
|
||||
return nil, merrors.InvalidArgument("subscriptions: endpoint repo is required", "endpointRepo")
|
||||
}
|
||||
logger := deps.Logger
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
|
||||
return &service{repo: deps.EndpointRepo}, nil
|
||||
return &service{
|
||||
logger: logger.Named("subscriptions"),
|
||||
repo: deps.EndpointRepo,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *service) Resolve(ctx context.Context, clientID, eventType string) ([]storage.Endpoint, error) {
|
||||
if strings.TrimSpace(clientID) == "" {
|
||||
func (s *service) Resolve(ctx context.Context, eventType string, organizationRef bson.ObjectID) ([]model.Endpoint, error) {
|
||||
if organizationRef == bson.NilObjectID {
|
||||
return nil, merrors.InvalidArgument("subscriptions: client id is required", "clientID")
|
||||
}
|
||||
if strings.TrimSpace(eventType) == "" {
|
||||
return nil, merrors.InvalidArgument("subscriptions: event type is required", "eventType")
|
||||
}
|
||||
|
||||
endpoints, err := s.repo.FindActiveByClientAndType(ctx, clientID, eventType)
|
||||
endpoints, err := s.repo.FindActive(ctx, eventType, organizationRef)
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to resolve active endpoints",
|
||||
zap.String("event_type", eventType),
|
||||
mzap.ObjRef("organization_ref", organizationRef),
|
||||
zap.Error(err),
|
||||
)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.logger.Debug("Resolved active endpoints",
|
||||
zap.String("event_type", eventType),
|
||||
mzap.ObjRef("organization_ref", organizationRef),
|
||||
zap.Int("endpoints", len(endpoints)),
|
||||
)
|
||||
|
||||
return endpoints, nil
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
msg "github.com/tech/sendico/pkg/messaging"
|
||||
msgproducer "github.com/tech/sendico/pkg/messaging/producer"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
@@ -85,7 +86,7 @@ func (a *App) Run(ctx context.Context) error {
|
||||
} else {
|
||||
producer := msgproducer.NewProducer(a.logger.Named("discovery_producer"), broker)
|
||||
announce := discovery.Announcement{
|
||||
Service: "FX_INGESTOR",
|
||||
Service: mservice.FXIngestor,
|
||||
Operations: []string{discovery.OperationFXIngest},
|
||||
Version: appversion.Create().Short(),
|
||||
}
|
||||
|
||||
@@ -92,9 +92,6 @@ func New(ctx context.Context, cfg Config, opts ...grpc.DialOption) (Client, erro
|
||||
return nil, merrors.InvalidArgument("oracle: address is required")
|
||||
}
|
||||
|
||||
dialCtx, cancel := context.WithTimeout(ctx, cfg.DialTimeout)
|
||||
defer cancel()
|
||||
|
||||
dialOpts := make([]grpc.DialOption, 0, len(opts)+1)
|
||||
dialOpts = append(dialOpts, opts...)
|
||||
|
||||
@@ -104,7 +101,7 @@ func New(ctx context.Context, cfg Config, opts ...grpc.DialOption) (Client, erro
|
||||
dialOpts = append(dialOpts, grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{})))
|
||||
}
|
||||
|
||||
conn, err := grpc.DialContext(dialCtx, cfg.Address, dialOpts...)
|
||||
conn, err := grpc.NewClient(cfg.Address, dialOpts...)
|
||||
if err != nil {
|
||||
return nil, merrors.InternalWrap(err, fmt.Sprintf("oracle: dial %s", cfg.Address))
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ func (s *Service) startDiscoveryAnnouncer() {
|
||||
return
|
||||
}
|
||||
announce := discovery.Announcement{
|
||||
Service: "FX_ORACLE",
|
||||
Service: mservice.FXOracle,
|
||||
Operations: []string{discovery.OperationFXQuote},
|
||||
InvokeURI: s.invokeURI,
|
||||
Version: appversion.Create().Short(),
|
||||
|
||||
@@ -63,9 +63,6 @@ func New(ctx context.Context, cfg Config, opts ...grpc.DialOption) (Client, erro
|
||||
return nil, merrors.InvalidArgument("chain-gateway: address is required")
|
||||
}
|
||||
|
||||
dialCtx, cancel := context.WithTimeout(ctx, cfg.DialTimeout)
|
||||
defer cancel()
|
||||
|
||||
dialOpts := make([]grpc.DialOption, 0, len(opts)+1)
|
||||
dialOpts = append(dialOpts, opts...)
|
||||
|
||||
@@ -75,7 +72,7 @@ func New(ctx context.Context, cfg Config, opts ...grpc.DialOption) (Client, erro
|
||||
dialOpts = append(dialOpts, grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{})))
|
||||
}
|
||||
|
||||
conn, err := grpc.DialContext(dialCtx, cfg.Address, dialOpts...)
|
||||
conn, err := grpc.NewClient(cfg.Address, dialOpts...)
|
||||
if err != nil {
|
||||
return nil, merrors.Internal(fmt.Sprintf("chain-gateway: dial %s: %s", cfg.Address, err.Error()))
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ replace github.com/tech/sendico/pkg => ../../pkg
|
||||
replace github.com/tech/sendico/gateway/common => ../common
|
||||
|
||||
require (
|
||||
github.com/ethereum/go-ethereum v1.17.0
|
||||
github.com/ethereum/go-ethereum v1.17.1
|
||||
github.com/mitchellh/mapstructure v1.5.0
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/shopspring/decimal v1.4.0
|
||||
@@ -23,7 +23,7 @@ require (
|
||||
|
||||
require (
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260225065256-91dd007ecddc // indirect
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260303081205-342d20cce05a // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bits-and-blooms/bitset v1.24.4 // indirect
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect
|
||||
@@ -79,9 +79,9 @@ require (
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/otel v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.41.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
|
||||
@@ -6,8 +6,8 @@ github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ=
|
||||
github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260225065256-91dd007ecddc h1:1stW1OipdBj8Me+nj26SzT8yil7OYve0r3cWobzk1JQ=
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260225065256-91dd007ecddc/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260303081205-342d20cce05a h1:vz21GPRVGgZazqOsqLgJq3r8C9LlV+av4Sm2g2LipCU=
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260303081205-342d20cce05a/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
|
||||
github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0=
|
||||
github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
@@ -76,8 +76,8 @@ github.com/ethereum/c-kzg-4844/v2 v2.1.6 h1:xQymkKCT5E2Jiaoqf3v4wsNgjZLY0lRSkZn2
|
||||
github.com/ethereum/c-kzg-4844/v2 v2.1.6/go.mod h1:8HMkUZ5JRv4hpw/XUrYWSQNAUzhHMg2UDb/U+5m+XNw=
|
||||
github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab h1:rvv6MJhy07IMfEKuARQ9TKojGqLVNxQajaXEp/BoqSk=
|
||||
github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab/go.mod h1:IuLm4IsPipXKF7CW5Lzf68PIbZ5yl7FFd74l/E0o9A8=
|
||||
github.com/ethereum/go-ethereum v1.17.0 h1:2D+1Fe23CwZ5tQoAS5DfwKFNI1HGcTwi65/kRlAVxes=
|
||||
github.com/ethereum/go-ethereum v1.17.0/go.mod h1:2W3msvdosS/MCWytpqTcqgFiRYbTH59FxDJzqah120o=
|
||||
github.com/ethereum/go-ethereum v1.17.1 h1:IjlQDjgxg2uL+GzPRkygGULPMLzcYWncEI7wbaizvho=
|
||||
github.com/ethereum/go-ethereum v1.17.1/go.mod h1:7UWOVHL7K3b8RfVRea022btnzLCaanwHtBuH1jUCH/I=
|
||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
@@ -293,16 +293,16 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 h1:UP6IpuHFkUgOQL9FFQFrZ+5LiwhhYRbi7VZSIx6Nj5s=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM=
|
||||
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
|
||||
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
|
||||
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
|
||||
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
|
||||
go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
|
||||
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
|
||||
go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ=
|
||||
go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
|
||||
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
|
||||
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
|
||||
go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
|
||||
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
|
||||
@@ -224,7 +224,7 @@ func (s *Service) startDiscoveryAnnouncers() {
|
||||
announce := discovery.Announcement{
|
||||
ID: discovery.StableCryptoRailGatewayID(string(network.Name)),
|
||||
InstanceID: discovery.InstanceID(),
|
||||
Service: "CRYPTO_RAIL_GATEWAY",
|
||||
Service: mservice.ChainWallets,
|
||||
Rail: discovery.RailCrypto,
|
||||
Operations: discovery.CryptoRailGatewayOperations(),
|
||||
Currencies: currencies,
|
||||
|
||||
@@ -13,6 +13,7 @@ This service now supports Monetix “payout by card”.
|
||||
- `MONETIX_PROJECT_ID` – integer project ID
|
||||
- `MONETIX_SECRET_KEY` – signature secret
|
||||
- Optional: `allowed_currencies`, `require_customer_address`, `request_timeout_seconds`
|
||||
- Optional execution mode: `mcards.strict_operation_mode=true` to process only one unresolved payout operation at a time (strict isolated mode)
|
||||
- Gateway descriptor: `gateway.id`, optional `gateway.currencies`, `gateway.limits` (for per-payout minimum use `gateway.limits.per_tx_min_amount`)
|
||||
- Callback server: `MNTX_GATEWAY_HTTP_PORT` (exposed as 8084), `http.callback.path`, optional `allowed_cidrs`
|
||||
|
||||
@@ -20,12 +21,15 @@ This service now supports Monetix “payout by card”.
|
||||
Payload is built per Monetix spec:
|
||||
```
|
||||
{
|
||||
"general": { "project_id": <int>, "payment_id": "<payout_id>", "signature": "<hmac>" },
|
||||
"general": { "project_id": <int>, "payment_id": "<operation_ref>", "signature": "<hmac>" },
|
||||
"customer": { id, first_name, last_name, middle_name?, ip_address, zip?, country?, state?, city?, address? },
|
||||
"payment": { amount: <minor_units>, currency: "<ISO-4217>" },
|
||||
"card": { pan, year?, month?, card_holder }
|
||||
}
|
||||
```
|
||||
Gateway request contract additionally requires `parent_payment_ref` as a first-class field
|
||||
(separate from Monetix `payment_id`).
|
||||
|
||||
Signature: HMAC-SHA256 over the JSON body (without `signature`), using `MONETIX_SECRET_KEY`.
|
||||
|
||||
## Callback handling
|
||||
|
||||
@@ -45,14 +45,11 @@ func New(ctx context.Context, cfg Config, opts ...grpc.DialOption) (Client, erro
|
||||
if strings.TrimSpace(cfg.Address) == "" {
|
||||
return nil, merrors.InvalidArgument("mntx: address is required")
|
||||
}
|
||||
dialCtx, cancel := context.WithTimeout(ctx, cfg.DialTimeout)
|
||||
defer cancel()
|
||||
|
||||
dialOpts := make([]grpc.DialOption, 0, len(opts)+1)
|
||||
dialOpts = append(dialOpts, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
dialOpts = append(dialOpts, opts...)
|
||||
|
||||
conn, err := grpc.DialContext(dialCtx, cfg.Address, dialOpts...)
|
||||
conn, err := grpc.NewClient(cfg.Address, dialOpts...)
|
||||
if err != nil {
|
||||
return nil, merrors.Internal("mntx: dial failed: " + err.Error())
|
||||
}
|
||||
@@ -107,7 +104,7 @@ func (g *gatewayClient) CreateCardPayout(ctx context.Context, req *mntxv1.CardPa
|
||||
if resp.GetReceipt() != nil && resp.GetReceipt().GetError() != nil {
|
||||
return nil, connectorError(resp.GetReceipt().GetError())
|
||||
}
|
||||
return &mntxv1.CardPayoutResponse{Payout: payoutFromReceipt(req.GetPayoutId(), resp.GetReceipt())}, nil
|
||||
return &mntxv1.CardPayoutResponse{Payout: payoutFromReceipt(req.GetPayoutId(), req.GetOperationRef(), req.GetParentPaymentRef(), resp.GetReceipt())}, nil
|
||||
}
|
||||
|
||||
func (g *gatewayClient) CreateCardTokenPayout(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error) {
|
||||
@@ -124,7 +121,7 @@ func (g *gatewayClient) CreateCardTokenPayout(ctx context.Context, req *mntxv1.C
|
||||
if resp.GetReceipt() != nil && resp.GetReceipt().GetError() != nil {
|
||||
return nil, connectorError(resp.GetReceipt().GetError())
|
||||
}
|
||||
return &mntxv1.CardTokenPayoutResponse{Payout: payoutFromReceipt(req.GetPayoutId(), resp.GetReceipt())}, nil
|
||||
return &mntxv1.CardTokenPayoutResponse{Payout: payoutFromReceipt(req.GetPayoutId(), req.GetOperationRef(), req.GetParentPaymentRef(), resp.GetReceipt())}, nil
|
||||
}
|
||||
|
||||
func (g *gatewayClient) GetCardPayoutStatus(ctx context.Context, req *mntxv1.GetCardPayoutStatusRequest) (*mntxv1.GetCardPayoutStatusResponse, error) {
|
||||
@@ -150,10 +147,12 @@ func operationFromCardPayout(req *mntxv1.CardPayoutRequest) (*connectorv1.Operat
|
||||
}
|
||||
params := payoutParamsFromCard(req)
|
||||
money := moneyFromMinor(req.GetAmountMinor(), req.GetCurrency())
|
||||
operationRef := fallbackNonEmpty(req.GetOperationRef(), req.GetPayoutId())
|
||||
idempotencyKey := fallbackNonEmpty(req.GetIdempotencyKey(), operationRef)
|
||||
op := &connectorv1.Operation{
|
||||
Type: connectorv1.OperationType_PAYOUT,
|
||||
IdempotencyKey: strings.TrimSpace(req.GetIdempotencyKey()),
|
||||
OperationRef: strings.TrimSpace(req.GetOperationRef()),
|
||||
IdempotencyKey: idempotencyKey,
|
||||
OperationRef: operationRef,
|
||||
IntentRef: strings.TrimSpace(req.GetIntentRef()),
|
||||
Money: money,
|
||||
Params: structFromMap(params),
|
||||
@@ -168,9 +167,13 @@ func operationFromTokenPayout(req *mntxv1.CardTokenPayoutRequest) (*connectorv1.
|
||||
}
|
||||
params := payoutParamsFromToken(req)
|
||||
money := moneyFromMinor(req.GetAmountMinor(), req.GetCurrency())
|
||||
operationRef := fallbackNonEmpty(req.GetOperationRef(), req.GetPayoutId())
|
||||
idempotencyKey := fallbackNonEmpty(req.GetIdempotencyKey(), operationRef)
|
||||
op := &connectorv1.Operation{
|
||||
Type: connectorv1.OperationType_PAYOUT,
|
||||
IdempotencyKey: strings.TrimSpace(req.GetPayoutId()),
|
||||
IdempotencyKey: idempotencyKey,
|
||||
OperationRef: operationRef,
|
||||
IntentRef: strings.TrimSpace(req.GetIntentRef()),
|
||||
Money: money,
|
||||
Params: structFromMap(params),
|
||||
}
|
||||
@@ -195,9 +198,10 @@ func setOperationRolesFromMetadata(op *connectorv1.Operation, metadata map[strin
|
||||
}
|
||||
|
||||
func payoutParamsFromCard(req *mntxv1.CardPayoutRequest) map[string]interface{} {
|
||||
metadata := sanitizeMetadata(req.GetMetadata())
|
||||
params := map[string]interface{}{
|
||||
"payout_id": strings.TrimSpace(req.GetPayoutId()),
|
||||
"project_id": req.GetProjectId(),
|
||||
"parent_payment_ref": strings.TrimSpace(req.GetParentPaymentRef()),
|
||||
"customer_id": strings.TrimSpace(req.GetCustomerId()),
|
||||
"customer_first_name": strings.TrimSpace(req.GetCustomerFirstName()),
|
||||
"customer_middle_name": strings.TrimSpace(req.GetCustomerMiddleName()),
|
||||
@@ -215,16 +219,17 @@ func payoutParamsFromCard(req *mntxv1.CardPayoutRequest) map[string]interface{}
|
||||
"card_exp_month": req.GetCardExpMonth(),
|
||||
"card_holder": strings.TrimSpace(req.GetCardHolder()),
|
||||
}
|
||||
if len(req.GetMetadata()) > 0 {
|
||||
params["metadata"] = mapStringToInterface(req.GetMetadata())
|
||||
if len(metadata) > 0 {
|
||||
params["metadata"] = mapStringToInterface(metadata)
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func payoutParamsFromToken(req *mntxv1.CardTokenPayoutRequest) map[string]interface{} {
|
||||
metadata := sanitizeMetadata(req.GetMetadata())
|
||||
params := map[string]interface{}{
|
||||
"payout_id": strings.TrimSpace(req.GetPayoutId()),
|
||||
"project_id": req.GetProjectId(),
|
||||
"parent_payment_ref": strings.TrimSpace(req.GetParentPaymentRef()),
|
||||
"customer_id": strings.TrimSpace(req.GetCustomerId()),
|
||||
"customer_first_name": strings.TrimSpace(req.GetCustomerFirstName()),
|
||||
"customer_middle_name": strings.TrimSpace(req.GetCustomerMiddleName()),
|
||||
@@ -241,8 +246,8 @@ func payoutParamsFromToken(req *mntxv1.CardTokenPayoutRequest) map[string]interf
|
||||
"card_holder": strings.TrimSpace(req.GetCardHolder()),
|
||||
"masked_pan": strings.TrimSpace(req.GetMaskedPan()),
|
||||
}
|
||||
if len(req.GetMetadata()) > 0 {
|
||||
params["metadata"] = mapStringToInterface(req.GetMetadata())
|
||||
if len(metadata) > 0 {
|
||||
params["metadata"] = mapStringToInterface(metadata)
|
||||
}
|
||||
return params
|
||||
}
|
||||
@@ -258,16 +263,50 @@ func moneyFromMinor(amount int64, currency string) *moneyv1.Money {
|
||||
}
|
||||
}
|
||||
|
||||
func payoutFromReceipt(payoutID string, receipt *connectorv1.OperationReceipt) *mntxv1.CardPayoutState {
|
||||
state := &mntxv1.CardPayoutState{PayoutId: strings.TrimSpace(payoutID)}
|
||||
func payoutFromReceipt(payoutID, operationRef, parentPaymentRef string, receipt *connectorv1.OperationReceipt) *mntxv1.CardPayoutState {
|
||||
state := &mntxv1.CardPayoutState{
|
||||
PayoutId: fallbackNonEmpty(operationRef, payoutID),
|
||||
ParentPaymentRef: strings.TrimSpace(parentPaymentRef),
|
||||
}
|
||||
if receipt == nil {
|
||||
return state
|
||||
}
|
||||
if opID := strings.TrimSpace(receipt.GetOperationId()); opID != "" {
|
||||
state.PayoutId = opID
|
||||
}
|
||||
state.Status = payoutStatusFromOperation(receipt.GetStatus())
|
||||
state.ProviderPaymentId = strings.TrimSpace(receipt.GetProviderRef())
|
||||
return state
|
||||
}
|
||||
|
||||
func fallbackNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
clean := strings.TrimSpace(value)
|
||||
if clean != "" {
|
||||
return clean
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func sanitizeMetadata(source map[string]string) map[string]string {
|
||||
if len(source) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := map[string]string{}
|
||||
for key, value := range source {
|
||||
k := strings.TrimSpace(key)
|
||||
if k == "" {
|
||||
continue
|
||||
}
|
||||
out[k] = strings.TrimSpace(value)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func payoutFromOperation(op *connectorv1.Operation) *mntxv1.CardPayoutState {
|
||||
if op == nil {
|
||||
return nil
|
||||
|
||||
@@ -44,6 +44,7 @@ mcards:
|
||||
request_timeout_seconds: 15
|
||||
status_success: "success"
|
||||
status_processing: "processing"
|
||||
strict_operation_mode: false
|
||||
|
||||
gateway:
|
||||
id: "mcards"
|
||||
|
||||
@@ -44,6 +44,7 @@ mcards:
|
||||
request_timeout_seconds: 15
|
||||
status_success: "success"
|
||||
status_processing: "processing"
|
||||
strict_operation_mode: true
|
||||
|
||||
gateway:
|
||||
id: "mcards"
|
||||
|
||||
@@ -59,6 +59,7 @@ type monetixConfig struct {
|
||||
RequestTimeoutSeconds int `yaml:"request_timeout_seconds"`
|
||||
StatusSuccess string `yaml:"status_success"`
|
||||
StatusProcessing string `yaml:"status_processing"`
|
||||
StrictOperationMode bool `yaml:"strict_operation_mode"`
|
||||
}
|
||||
|
||||
type gatewayConfig struct {
|
||||
@@ -167,6 +168,7 @@ func (i *Imp) Start() error {
|
||||
zap.Duration("request_timeout", monetixCfg.RequestTimeout),
|
||||
zap.String("status_success", monetixCfg.SuccessStatus()),
|
||||
zap.String("status_processing", monetixCfg.ProcessingStatus()),
|
||||
zap.Bool("strict_operation_mode", cfg.Monetix.StrictOperationMode),
|
||||
)
|
||||
|
||||
gatewayDescriptor := resolveGatewayDescriptor(cfg.Gateway, monetixCfg)
|
||||
@@ -196,6 +198,7 @@ func (i *Imp) Start() error {
|
||||
mntxservice.WithDiscoveryInvokeURI(invokeURI),
|
||||
mntxservice.WithProducer(producer),
|
||||
mntxservice.WithMonetixConfig(monetixCfg),
|
||||
mntxservice.WithStrictOperationIsolation(cfg.Monetix.StrictOperationMode),
|
||||
mntxservice.WithGatewayDescriptor(gatewayDescriptor),
|
||||
mntxservice.WithHTTPClient(&http.Client{Timeout: monetixCfg.Timeout()}),
|
||||
mntxservice.WithStorage(repo),
|
||||
|
||||
@@ -110,6 +110,7 @@ func mapCallbackToState(clock clockpkg.Clock, cfg monetix.Config, cb monetixCall
|
||||
ProviderCode: cb.Operation.Code,
|
||||
ProviderMessage: cb.Operation.Message,
|
||||
ProviderPaymentId: fallbackProviderPaymentID(cb),
|
||||
OperationRef: strings.TrimSpace(cb.Payment.ID),
|
||||
UpdatedAt: now,
|
||||
CreatedAt: now,
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ func (s *Service) handleCreateCardPayout(ctx context.Context, req *mntxv1.CardPa
|
||||
log := s.logger.Named("card_payout")
|
||||
log.Info("Create card payout request received",
|
||||
zap.String("payout_id", strings.TrimSpace(req.GetPayoutId())),
|
||||
zap.String("operation_ref", strings.TrimSpace(req.GetOperationRef())),
|
||||
zap.String("parent_payment_ref", strings.TrimSpace(req.GetParentPaymentRef())),
|
||||
zap.String("customer_id", strings.TrimSpace(req.GetCustomerId())),
|
||||
zap.Int64("amount_minor", req.GetAmountMinor()),
|
||||
zap.String("currency", strings.ToUpper(strings.TrimSpace(req.GetCurrency()))),
|
||||
@@ -47,6 +49,8 @@ func (s *Service) handleCreateCardTokenPayout(ctx context.Context, req *mntxv1.C
|
||||
log := s.logger.Named("card_token_payout")
|
||||
log.Info("Create card token payout request received",
|
||||
zap.String("payout_id", strings.TrimSpace(req.GetPayoutId())),
|
||||
zap.String("operation_ref", strings.TrimSpace(req.GetOperationRef())),
|
||||
zap.String("parent_payment_ref", strings.TrimSpace(req.GetParentPaymentRef())),
|
||||
zap.String("customer_id", strings.TrimSpace(req.GetCustomerId())),
|
||||
zap.Int64("amount_minor", req.GetAmountMinor()),
|
||||
zap.String("currency", strings.ToUpper(strings.TrimSpace(req.GetCurrency()))),
|
||||
@@ -120,6 +124,7 @@ func sanitizeCardPayoutRequest(req *mntxv1.CardPayoutRequest) *mntxv1.CardPayout
|
||||
return req
|
||||
}
|
||||
r.PayoutId = strings.TrimSpace(r.GetPayoutId())
|
||||
r.ParentPaymentRef = strings.TrimSpace(r.GetParentPaymentRef())
|
||||
r.CustomerId = strings.TrimSpace(r.GetCustomerId())
|
||||
r.CustomerFirstName = strings.TrimSpace(r.GetCustomerFirstName())
|
||||
r.CustomerMiddleName = strings.TrimSpace(r.GetCustomerMiddleName())
|
||||
@@ -133,6 +138,9 @@ func sanitizeCardPayoutRequest(req *mntxv1.CardPayoutRequest) *mntxv1.CardPayout
|
||||
r.Currency = strings.ToUpper(strings.TrimSpace(r.GetCurrency()))
|
||||
r.CardPan = strings.TrimSpace(r.GetCardPan())
|
||||
r.CardHolder = strings.TrimSpace(r.GetCardHolder())
|
||||
r.OperationRef = strings.TrimSpace(r.GetOperationRef())
|
||||
r.IdempotencyKey = strings.TrimSpace(r.GetIdempotencyKey())
|
||||
r.IntentRef = strings.TrimSpace(r.GetIntentRef())
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -146,6 +154,7 @@ func sanitizeCardTokenPayoutRequest(req *mntxv1.CardTokenPayoutRequest) *mntxv1.
|
||||
return req
|
||||
}
|
||||
r.PayoutId = strings.TrimSpace(r.GetPayoutId())
|
||||
r.ParentPaymentRef = strings.TrimSpace(r.GetParentPaymentRef())
|
||||
r.CustomerId = strings.TrimSpace(r.GetCustomerId())
|
||||
r.CustomerFirstName = strings.TrimSpace(r.GetCustomerFirstName())
|
||||
r.CustomerMiddleName = strings.TrimSpace(r.GetCustomerMiddleName())
|
||||
@@ -160,6 +169,9 @@ func sanitizeCardTokenPayoutRequest(req *mntxv1.CardTokenPayoutRequest) *mntxv1.
|
||||
r.CardToken = strings.TrimSpace(r.GetCardToken())
|
||||
r.CardHolder = strings.TrimSpace(r.GetCardHolder())
|
||||
r.MaskedPan = strings.TrimSpace(r.GetMaskedPan())
|
||||
r.OperationRef = strings.TrimSpace(r.GetOperationRef())
|
||||
r.IdempotencyKey = strings.TrimSpace(r.GetIdempotencyKey())
|
||||
r.IntentRef = strings.TrimSpace(r.GetIntentRef())
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -206,7 +218,7 @@ func buildCardPayoutRequest(projectID int64, req *mntxv1.CardPayoutRequest) mone
|
||||
return monetix.CardPayoutRequest{
|
||||
General: monetix.General{
|
||||
ProjectID: projectID,
|
||||
PaymentID: req.GetPayoutId(),
|
||||
PaymentID: findOperationRef(req.GetOperationRef(), req.GetPayoutId()),
|
||||
},
|
||||
Customer: monetix.Customer{
|
||||
ID: req.GetCustomerId(),
|
||||
@@ -232,7 +244,7 @@ func buildCardTokenPayoutRequest(projectID int64, req *mntxv1.CardTokenPayoutReq
|
||||
return monetix.CardTokenPayoutRequest{
|
||||
General: monetix.General{
|
||||
ProjectID: projectID,
|
||||
PaymentID: req.GetPayoutId(),
|
||||
PaymentID: findOperationRef(req.GetOperationRef(), req.GetPayoutId()),
|
||||
},
|
||||
Customer: monetix.Customer{
|
||||
ID: req.GetCustomerId(),
|
||||
|
||||
@@ -2,6 +2,7 @@ package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/tech/sendico/gateway/mntx/storage"
|
||||
"github.com/tech/sendico/gateway/mntx/storage/model"
|
||||
@@ -24,9 +25,20 @@ func (r *mockRepository) Payouts() storage.PayoutsStore {
|
||||
|
||||
// cardPayoutStore implements storage.PayoutsStore for tests.
|
||||
type cardPayoutStore struct {
|
||||
mu sync.RWMutex
|
||||
data map[string]*model.CardPayout
|
||||
}
|
||||
|
||||
func payoutStoreKey(state *model.CardPayout) string {
|
||||
if state == nil {
|
||||
return ""
|
||||
}
|
||||
if ref := state.OperationRef; ref != "" {
|
||||
return ref
|
||||
}
|
||||
return state.PaymentRef
|
||||
}
|
||||
|
||||
func newCardPayoutStore() *cardPayoutStore {
|
||||
return &cardPayoutStore{
|
||||
data: make(map[string]*model.CardPayout),
|
||||
@@ -34,6 +46,8 @@ func newCardPayoutStore() *cardPayoutStore {
|
||||
}
|
||||
|
||||
func (s *cardPayoutStore) FindByIdempotencyKey(_ context.Context, key string) (*model.CardPayout, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for _, v := range s.data {
|
||||
if v.IdempotencyKey == key {
|
||||
return v, nil
|
||||
@@ -42,26 +56,53 @@ func (s *cardPayoutStore) FindByIdempotencyKey(_ context.Context, key string) (*
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *cardPayoutStore) FindByPaymentID(_ context.Context, id string) (*model.CardPayout, error) {
|
||||
v, ok := s.data[id]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
func (s *cardPayoutStore) FindByOperationRef(_ context.Context, ref string) (*model.CardPayout, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for _, v := range s.data {
|
||||
if v.OperationRef == ref {
|
||||
return v, nil
|
||||
}
|
||||
}
|
||||
return v, nil
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *cardPayoutStore) FindByPaymentID(_ context.Context, id string) (*model.CardPayout, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for _, v := range s.data {
|
||||
if v.PaymentRef == id {
|
||||
return v, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *cardPayoutStore) Upsert(_ context.Context, record *model.CardPayout) error {
|
||||
s.data[record.PaymentRef] = record
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.data[payoutStoreKey(record)] = record
|
||||
return nil
|
||||
}
|
||||
|
||||
// Save is a helper for tests to pre-populate data.
|
||||
func (s *cardPayoutStore) Save(state *model.CardPayout) {
|
||||
s.data[state.PaymentRef] = state
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.data[payoutStoreKey(state)] = state
|
||||
}
|
||||
|
||||
// Get is a helper for tests to retrieve data.
|
||||
func (s *cardPayoutStore) Get(id string) (*model.CardPayout, bool) {
|
||||
v, ok := s.data[id]
|
||||
return v, ok
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if v, ok := s.data[id]; ok {
|
||||
return v, true
|
||||
}
|
||||
for _, v := range s.data {
|
||||
if v.PaymentRef == id || v.OperationRef == id {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
@@ -14,8 +14,11 @@ func validateCardPayoutRequest(req *mntxv1.CardPayoutRequest, cfg monetix.Config
|
||||
return newPayoutError("invalid_request", merrors.InvalidArgument("request cannot be empty"))
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.GetPayoutId()) == "" {
|
||||
return newPayoutError("missing_payout_id", merrors.InvalidArgument("payout_id is required", "payout_id"))
|
||||
if err := validateOperationIdentity(strings.TrimSpace(req.GetPayoutId()), strings.TrimSpace(req.GetOperationRef())); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(req.GetParentPaymentRef()) == "" {
|
||||
return newPayoutError("missing_parent_payment_ref", merrors.InvalidArgument("parent_payment_ref is required", "parent_payment_ref"))
|
||||
}
|
||||
if strings.TrimSpace(req.GetCustomerId()) == "" {
|
||||
return newPayoutError("missing_customer_id", merrors.InvalidArgument("customer_id is required", "customer_id"))
|
||||
@@ -81,3 +84,16 @@ func validateCardExpiryFields(month uint32, year uint32) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateOperationIdentity(payoutID, operationRef string) error {
|
||||
payoutID = strings.TrimSpace(payoutID)
|
||||
operationRef = strings.TrimSpace(operationRef)
|
||||
switch {
|
||||
case payoutID == "" && operationRef == "":
|
||||
return newPayoutError("missing_operation_ref", merrors.InvalidArgument("operation_ref or payout_id is required", "operation_ref"))
|
||||
case payoutID != "" && operationRef != "":
|
||||
return newPayoutError("ambiguous_operation_ref", merrors.InvalidArgument("provide either operation_ref or payout_id, not both"))
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,9 +24,22 @@ func TestValidateCardPayoutRequest_Errors(t *testing.T) {
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "missing_payout_id",
|
||||
name: "missing_operation_identity",
|
||||
mutate: func(r *mntxv1.CardPayoutRequest) { r.PayoutId = "" },
|
||||
expected: "missing_payout_id",
|
||||
expected: "missing_operation_ref",
|
||||
},
|
||||
{
|
||||
name: "missing_parent_payment_ref",
|
||||
mutate: func(r *mntxv1.CardPayoutRequest) { r.ParentPaymentRef = "" },
|
||||
expected: "missing_parent_payment_ref",
|
||||
},
|
||||
{
|
||||
name: "both_operation_and_payout_identity",
|
||||
mutate: func(r *mntxv1.CardPayoutRequest) {
|
||||
r.PayoutId = "parent-1"
|
||||
r.OperationRef = "parent-1:hop_1_card_payout_send"
|
||||
},
|
||||
expected: "ambiguous_operation_ref",
|
||||
},
|
||||
{
|
||||
name: "missing_customer_id",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,8 +5,11 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -45,8 +48,9 @@ func TestCardPayoutProcessor_Submit_Success(t *testing.T) {
|
||||
|
||||
repo := newMockRepository()
|
||||
repo.payouts.Save(&model.CardPayout{
|
||||
PaymentRef: "payout-1",
|
||||
CreatedAt: existingCreated,
|
||||
PaymentRef: "payment-parent-1",
|
||||
OperationRef: "payout-1",
|
||||
CreatedAt: existingCreated,
|
||||
})
|
||||
|
||||
httpClient := &http.Client{
|
||||
@@ -227,3 +231,512 @@ func TestCardPayoutProcessor_ProcessCallback(t *testing.T) {
|
||||
t.Fatalf("expected success status in model, got %v", state.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardPayoutProcessor_Submit_SameParentDifferentOperationsStoredSeparately(t *testing.T) {
|
||||
cfg := monetix.Config{
|
||||
BaseURL: "https://monetix.test",
|
||||
SecretKey: "secret",
|
||||
ProjectID: 99,
|
||||
AllowedCurrencies: []string{"RUB"},
|
||||
}
|
||||
|
||||
repo := newMockRepository()
|
||||
var callN int
|
||||
httpClient := &http.Client{
|
||||
Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
|
||||
callN++
|
||||
resp := monetix.APIResponse{}
|
||||
resp.Operation.RequestID = fmt.Sprintf("req-%d", callN)
|
||||
body, _ := json.Marshal(resp)
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(bytes.NewReader(body)),
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
}, nil
|
||||
}),
|
||||
}
|
||||
processor := newCardPayoutProcessor(
|
||||
zap.NewNop(),
|
||||
cfg,
|
||||
staticClock{now: time.Date(2026, 3, 4, 1, 2, 3, 0, time.UTC)},
|
||||
repo,
|
||||
httpClient,
|
||||
nil,
|
||||
)
|
||||
|
||||
parentPaymentRef := "payment-parent-1"
|
||||
op1 := parentPaymentRef + ":hop_4_card_payout_send"
|
||||
op2 := parentPaymentRef + ":hop_4_card_payout_send_2"
|
||||
|
||||
req1 := validCardPayoutRequest()
|
||||
req1.PayoutId = ""
|
||||
req1.OperationRef = op1
|
||||
req1.IdempotencyKey = "idem-1"
|
||||
req1.ParentPaymentRef = parentPaymentRef
|
||||
req1.CardPan = "2204310000002456"
|
||||
|
||||
req2 := validCardPayoutRequest()
|
||||
req2.PayoutId = ""
|
||||
req2.OperationRef = op2
|
||||
req2.IdempotencyKey = "idem-2"
|
||||
req2.ParentPaymentRef = parentPaymentRef
|
||||
req2.CardPan = "2204320000009754"
|
||||
|
||||
if _, err := processor.Submit(context.Background(), req1); err != nil {
|
||||
t.Fatalf("first submit failed: %v", err)
|
||||
}
|
||||
if _, err := processor.Submit(context.Background(), req2); err != nil {
|
||||
t.Fatalf("second submit failed: %v", err)
|
||||
}
|
||||
|
||||
first, err := repo.payouts.FindByOperationRef(context.Background(), op1)
|
||||
if err != nil || first == nil {
|
||||
t.Fatalf("expected first operation stored, err=%v", err)
|
||||
}
|
||||
second, err := repo.payouts.FindByOperationRef(context.Background(), op2)
|
||||
if err != nil || second == nil {
|
||||
t.Fatalf("expected second operation stored, err=%v", err)
|
||||
}
|
||||
|
||||
if got, want := first.PaymentRef, parentPaymentRef; got != want {
|
||||
t.Fatalf("first parent payment ref mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := second.PaymentRef, parentPaymentRef; got != want {
|
||||
t.Fatalf("second parent payment ref mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := first.OperationRef, op1; got != want {
|
||||
t.Fatalf("first operation ref mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := second.OperationRef, op2; got != want {
|
||||
t.Fatalf("second operation ref mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if first.ProviderPaymentID == "" || second.ProviderPaymentID == "" {
|
||||
t.Fatalf("expected provider payment ids for both operations")
|
||||
}
|
||||
if first.ProviderPaymentID == second.ProviderPaymentID {
|
||||
t.Fatalf("expected different provider payment ids, got=%q", first.ProviderPaymentID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardPayoutProcessor_StrictMode_BlocksSecondOperationUntilFirstFinalCallback(t *testing.T) {
|
||||
cfg := monetix.Config{
|
||||
BaseURL: "https://monetix.test",
|
||||
SecretKey: "secret",
|
||||
ProjectID: 99,
|
||||
StatusSuccess: "success",
|
||||
StatusProcessing: "processing",
|
||||
AllowedCurrencies: []string{"RUB"},
|
||||
}
|
||||
|
||||
repo := newMockRepository()
|
||||
var callN atomic.Int32
|
||||
httpClient := &http.Client{
|
||||
Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
|
||||
n := callN.Add(1)
|
||||
resp := monetix.APIResponse{}
|
||||
resp.Operation.RequestID = fmt.Sprintf("req-%d", n)
|
||||
body, _ := json.Marshal(resp)
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(bytes.NewReader(body)),
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
}, nil
|
||||
}),
|
||||
}
|
||||
|
||||
processor := newCardPayoutProcessor(
|
||||
zap.NewNop(),
|
||||
cfg,
|
||||
staticClock{now: time.Date(2026, 3, 4, 2, 3, 4, 0, time.UTC)},
|
||||
repo,
|
||||
httpClient,
|
||||
nil,
|
||||
)
|
||||
defer processor.stopRetries()
|
||||
processor.dispatchThrottleInterval = 0
|
||||
processor.setExecutionMode(newStrictIsolatedPayoutExecutionMode())
|
||||
|
||||
req1 := validCardPayoutRequest()
|
||||
req1.PayoutId = ""
|
||||
req1.OperationRef = "op-strict-1"
|
||||
req1.ParentPaymentRef = "payment-strict-1"
|
||||
req1.IdempotencyKey = "idem-strict-1"
|
||||
req1.CardPan = "2204310000002456"
|
||||
|
||||
req2 := validCardPayoutRequest()
|
||||
req2.PayoutId = ""
|
||||
req2.OperationRef = "op-strict-2"
|
||||
req2.ParentPaymentRef = "payment-strict-2"
|
||||
req2.IdempotencyKey = "idem-strict-2"
|
||||
req2.CardPan = "2204320000009754"
|
||||
|
||||
if _, err := processor.Submit(context.Background(), req1); err != nil {
|
||||
t.Fatalf("first submit failed: %v", err)
|
||||
}
|
||||
|
||||
secondDone := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := processor.Submit(context.Background(), req2)
|
||||
secondDone <- err
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-secondDone:
|
||||
t.Fatalf("second submit should block before first operation is final, err=%v", err)
|
||||
case <-time.After(120 * time.Millisecond):
|
||||
}
|
||||
|
||||
cb := baseCallback()
|
||||
cb.Payment.ID = req1.GetOperationRef()
|
||||
cb.Payment.Status = "success"
|
||||
cb.Operation.Status = "success"
|
||||
cb.Operation.Code = "0"
|
||||
cb.Operation.Message = "Success"
|
||||
cb.Payment.Sum.Currency = "RUB"
|
||||
|
||||
sig, err := monetix.SignPayload(cb, cfg.SecretKey)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to sign callback: %v", err)
|
||||
}
|
||||
cb.Signature = sig
|
||||
payload, err := json.Marshal(cb)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal callback: %v", err)
|
||||
}
|
||||
|
||||
status, err := processor.ProcessCallback(context.Background(), payload)
|
||||
if err != nil {
|
||||
t.Fatalf("callback failed: %v", err)
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("unexpected callback status: %d", status)
|
||||
}
|
||||
|
||||
select {
|
||||
case err := <-secondDone:
|
||||
if err != nil {
|
||||
t.Fatalf("second submit returned error: %v", err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatalf("timeout waiting for second submit to unblock")
|
||||
}
|
||||
|
||||
if got, want := callN.Load(), int32(2); got != want {
|
||||
t.Fatalf("unexpected provider call count: got=%d want=%d", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardPayoutProcessor_ProcessCallback_UpdatesMatchingOperationWithinSameParent(t *testing.T) {
|
||||
cfg := monetix.Config{
|
||||
SecretKey: "secret",
|
||||
StatusSuccess: "success",
|
||||
StatusProcessing: "processing",
|
||||
AllowedCurrencies: []string{"RUB"},
|
||||
}
|
||||
|
||||
parentPaymentRef := "payment-parent-1"
|
||||
op1 := parentPaymentRef + ":hop_4_card_payout_send"
|
||||
op2 := parentPaymentRef + ":hop_4_card_payout_send_2"
|
||||
now := time.Date(2026, 3, 4, 2, 3, 4, 0, time.UTC)
|
||||
|
||||
repo := newMockRepository()
|
||||
repo.payouts.Save(&model.CardPayout{
|
||||
PaymentRef: parentPaymentRef,
|
||||
OperationRef: op1,
|
||||
Status: model.PayoutStatusWaiting,
|
||||
CreatedAt: now.Add(-time.Minute),
|
||||
UpdatedAt: now.Add(-time.Minute),
|
||||
})
|
||||
repo.payouts.Save(&model.CardPayout{
|
||||
PaymentRef: parentPaymentRef,
|
||||
OperationRef: op2,
|
||||
Status: model.PayoutStatusWaiting,
|
||||
CreatedAt: now.Add(-time.Minute),
|
||||
UpdatedAt: now.Add(-time.Minute),
|
||||
})
|
||||
|
||||
processor := newCardPayoutProcessor(
|
||||
zap.NewNop(),
|
||||
cfg,
|
||||
staticClock{now: now},
|
||||
repo,
|
||||
&http.Client{},
|
||||
nil,
|
||||
)
|
||||
|
||||
cb := baseCallback()
|
||||
cb.Payment.ID = op2
|
||||
cb.Payment.Status = "success"
|
||||
cb.Operation.Status = "success"
|
||||
cb.Operation.Code = "0"
|
||||
cb.Operation.Provider.PaymentID = "provider-op-2"
|
||||
cb.Payment.Sum.Currency = "RUB"
|
||||
|
||||
sig, err := monetix.SignPayload(cb, cfg.SecretKey)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to sign callback: %v", err)
|
||||
}
|
||||
cb.Signature = sig
|
||||
payload, err := json.Marshal(cb)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal callback: %v", err)
|
||||
}
|
||||
|
||||
status, err := processor.ProcessCallback(context.Background(), payload)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("expected status ok, got %d", status)
|
||||
}
|
||||
|
||||
first, err := repo.payouts.FindByOperationRef(context.Background(), op1)
|
||||
if err != nil || first == nil {
|
||||
t.Fatalf("expected first operation present, err=%v", err)
|
||||
}
|
||||
second, err := repo.payouts.FindByOperationRef(context.Background(), op2)
|
||||
if err != nil || second == nil {
|
||||
t.Fatalf("expected second operation present, err=%v", err)
|
||||
}
|
||||
|
||||
if got, want := first.Status, model.PayoutStatusWaiting; got != want {
|
||||
t.Fatalf("first operation status mismatch: got=%v want=%v", got, want)
|
||||
}
|
||||
if got, want := second.Status, model.PayoutStatusSuccess; got != want {
|
||||
t.Fatalf("second operation status mismatch: got=%v want=%v", got, want)
|
||||
}
|
||||
if got, want := second.PaymentRef, parentPaymentRef; got != want {
|
||||
t.Fatalf("second parent payment ref mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardPayoutProcessor_Submit_RetriesProviderLimitDeclineUntilSuccess(t *testing.T) {
|
||||
cfg := monetix.Config{
|
||||
BaseURL: "https://monetix.test",
|
||||
SecretKey: "secret",
|
||||
ProjectID: 99,
|
||||
AllowedCurrencies: []string{"RUB"},
|
||||
}
|
||||
|
||||
repo := newMockRepository()
|
||||
var calls atomic.Int32
|
||||
httpClient := &http.Client{
|
||||
Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
|
||||
n := calls.Add(1)
|
||||
resp := monetix.APIResponse{}
|
||||
if n == 1 {
|
||||
resp.Code = providerCodeDeclineAmountOrFrequencyLimit
|
||||
resp.Message = "Decline due to amount or frequency limit"
|
||||
body, _ := json.Marshal(resp)
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusTooManyRequests,
|
||||
Body: io.NopCloser(bytes.NewReader(body)),
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
}, nil
|
||||
}
|
||||
resp.Operation.RequestID = "req-retry-success"
|
||||
body, _ := json.Marshal(resp)
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(bytes.NewReader(body)),
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
}, nil
|
||||
}),
|
||||
}
|
||||
|
||||
processor := newCardPayoutProcessor(
|
||||
zap.NewNop(),
|
||||
cfg,
|
||||
staticClock{now: time.Date(2026, 3, 4, 1, 2, 3, 0, time.UTC)},
|
||||
repo,
|
||||
httpClient,
|
||||
nil,
|
||||
)
|
||||
defer processor.stopRetries()
|
||||
processor.dispatchThrottleInterval = 0
|
||||
processor.retryDelayFn = func(uint32) time.Duration { return 10 * time.Millisecond }
|
||||
|
||||
req := validCardPayoutRequest()
|
||||
resp, err := processor.Submit(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("submit returned error: %v", err)
|
||||
}
|
||||
if !resp.GetAccepted() {
|
||||
t.Fatalf("expected accepted response when retry is scheduled")
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
state, ok := repo.payouts.Get(req.GetPayoutId())
|
||||
if ok && state != nil && state.Status == model.PayoutStatusWaiting && state.ProviderPaymentID == "req-retry-success" {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("timeout waiting for successful retry result")
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
if got, want := calls.Load(), int32(2); got != want {
|
||||
t.Fatalf("unexpected provider call count: got=%d want=%d", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardPayoutProcessor_Submit_RetriesProviderLimitDeclineThenFails(t *testing.T) {
|
||||
cfg := monetix.Config{
|
||||
BaseURL: "https://monetix.test",
|
||||
SecretKey: "secret",
|
||||
ProjectID: 99,
|
||||
AllowedCurrencies: []string{"RUB"},
|
||||
}
|
||||
|
||||
repo := newMockRepository()
|
||||
var calls atomic.Int32
|
||||
httpClient := &http.Client{
|
||||
Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
|
||||
_ = calls.Add(1)
|
||||
resp := monetix.APIResponse{
|
||||
Code: providerCodeDeclineAmountOrFrequencyLimit,
|
||||
Message: "Decline due to amount or frequency limit",
|
||||
}
|
||||
body, _ := json.Marshal(resp)
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusTooManyRequests,
|
||||
Body: io.NopCloser(bytes.NewReader(body)),
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
}, nil
|
||||
}),
|
||||
}
|
||||
|
||||
processor := newCardPayoutProcessor(
|
||||
zap.NewNop(),
|
||||
cfg,
|
||||
staticClock{now: time.Date(2026, 3, 4, 1, 2, 3, 0, time.UTC)},
|
||||
repo,
|
||||
httpClient,
|
||||
nil,
|
||||
)
|
||||
defer processor.stopRetries()
|
||||
processor.dispatchThrottleInterval = 0
|
||||
processor.retryDelayFn = func(uint32) time.Duration { return time.Millisecond }
|
||||
|
||||
req := validCardPayoutRequest()
|
||||
resp, err := processor.Submit(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("submit returned error: %v", err)
|
||||
}
|
||||
if !resp.GetAccepted() {
|
||||
t.Fatalf("expected accepted response when retry is scheduled")
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
state, ok := repo.payouts.Get(req.GetPayoutId())
|
||||
if ok && state != nil && state.Status == model.PayoutStatusFailed {
|
||||
if !strings.Contains(state.FailureReason, providerCodeDeclineAmountOrFrequencyLimit) {
|
||||
t.Fatalf("expected failure reason to include provider code, got=%q", state.FailureReason)
|
||||
}
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("timeout waiting for terminal failed status")
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if got, want := calls.Load(), int32(defaultMaxDispatchAttempts); got != want {
|
||||
t.Fatalf("unexpected provider call count: got=%d want=%d", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardPayoutProcessor_ProcessCallback_RetryableDeclineSchedulesRetry(t *testing.T) {
|
||||
cfg := monetix.Config{
|
||||
BaseURL: "https://monetix.test",
|
||||
SecretKey: "secret",
|
||||
ProjectID: 99,
|
||||
StatusSuccess: "success",
|
||||
StatusProcessing: "processing",
|
||||
AllowedCurrencies: []string{"RUB"},
|
||||
}
|
||||
|
||||
repo := newMockRepository()
|
||||
var calls atomic.Int32
|
||||
httpClient := &http.Client{
|
||||
Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
|
||||
n := calls.Add(1)
|
||||
resp := monetix.APIResponse{}
|
||||
if n == 1 {
|
||||
resp.Operation.RequestID = "req-initial"
|
||||
} else {
|
||||
resp.Operation.RequestID = "req-after-callback-retry"
|
||||
}
|
||||
body, _ := json.Marshal(resp)
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(bytes.NewReader(body)),
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
}, nil
|
||||
}),
|
||||
}
|
||||
|
||||
processor := newCardPayoutProcessor(
|
||||
zap.NewNop(),
|
||||
cfg,
|
||||
staticClock{now: time.Date(2026, 3, 4, 2, 0, 0, 0, time.UTC)},
|
||||
repo,
|
||||
httpClient,
|
||||
nil,
|
||||
)
|
||||
defer processor.stopRetries()
|
||||
processor.dispatchThrottleInterval = 0
|
||||
processor.retryDelayFn = func(uint32) time.Duration { return 5 * time.Millisecond }
|
||||
|
||||
req := validCardPayoutRequest()
|
||||
resp, err := processor.Submit(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("submit returned error: %v", err)
|
||||
}
|
||||
if !resp.GetAccepted() {
|
||||
t.Fatalf("expected accepted submit response")
|
||||
}
|
||||
|
||||
cb := baseCallback()
|
||||
cb.Payment.ID = req.GetPayoutId()
|
||||
cb.Payment.Status = "failed"
|
||||
cb.Operation.Status = "failed"
|
||||
cb.Operation.Code = providerCodeDeclineAmountOrFrequencyLimit
|
||||
cb.Operation.Message = "Decline due to amount or frequency limit"
|
||||
cb.Payment.Sum.Currency = "RUB"
|
||||
|
||||
sig, err := monetix.SignPayload(cb, cfg.SecretKey)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to sign callback: %v", err)
|
||||
}
|
||||
cb.Signature = sig
|
||||
payload, err := json.Marshal(cb)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal callback: %v", err)
|
||||
}
|
||||
|
||||
status, err := processor.ProcessCallback(context.Background(), payload)
|
||||
if err != nil {
|
||||
t.Fatalf("process callback returned error: %v", err)
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("unexpected callback status: %d", status)
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
state, ok := repo.payouts.Get(req.GetPayoutId())
|
||||
if ok && state != nil && state.Status == model.PayoutStatusWaiting && state.ProviderPaymentID == "req-after-callback-retry" {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("timeout waiting for callback-scheduled retry result")
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
|
||||
if got, want := calls.Load(), int32(2); got != want {
|
||||
t.Fatalf("unexpected provider call count: got=%d want=%d", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,8 +13,11 @@ func validateCardTokenPayoutRequest(req *mntxv1.CardTokenPayoutRequest, cfg mone
|
||||
return newPayoutError("invalid_request", merrors.InvalidArgument("request cannot be empty"))
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.GetPayoutId()) == "" {
|
||||
return newPayoutError("missing_payout_id", merrors.InvalidArgument("payout_id is required", "payout_id"))
|
||||
if err := validateOperationIdentity(strings.TrimSpace(req.GetPayoutId()), strings.TrimSpace(req.GetOperationRef())); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(req.GetParentPaymentRef()) == "" {
|
||||
return newPayoutError("missing_parent_payment_ref", merrors.InvalidArgument("parent_payment_ref is required", "parent_payment_ref"))
|
||||
}
|
||||
if strings.TrimSpace(req.GetCustomerId()) == "" {
|
||||
return newPayoutError("missing_customer_id", merrors.InvalidArgument("customer_id is required", "customer_id"))
|
||||
|
||||
@@ -24,9 +24,22 @@ func TestValidateCardTokenPayoutRequest_Errors(t *testing.T) {
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "missing_payout_id",
|
||||
name: "missing_operation_identity",
|
||||
mutate: func(r *mntxv1.CardTokenPayoutRequest) { r.PayoutId = "" },
|
||||
expected: "missing_payout_id",
|
||||
expected: "missing_operation_ref",
|
||||
},
|
||||
{
|
||||
name: "missing_parent_payment_ref",
|
||||
mutate: func(r *mntxv1.CardTokenPayoutRequest) { r.ParentPaymentRef = "" },
|
||||
expected: "missing_parent_payment_ref",
|
||||
},
|
||||
{
|
||||
name: "both_operation_and_payout_identity",
|
||||
mutate: func(r *mntxv1.CardTokenPayoutRequest) {
|
||||
r.PayoutId = "parent-1"
|
||||
r.OperationRef = "parent-1:hop_1_card_payout_send"
|
||||
},
|
||||
expected: "ambiguous_operation_ref",
|
||||
},
|
||||
{
|
||||
name: "missing_customer_id",
|
||||
@@ -63,7 +76,7 @@ func TestValidateCardTokenPayoutRequest_Errors(t *testing.T) {
|
||||
expected: "missing_card_token",
|
||||
},
|
||||
{
|
||||
name: "missing_customer_city_when_required",
|
||||
name: "missing_customer_city_when_required",
|
||||
mutate: func(r *mntxv1.CardTokenPayoutRequest) {
|
||||
r.CustomerCountry = "US"
|
||||
r.CustomerCity = ""
|
||||
|
||||
@@ -69,20 +69,18 @@ func (s *Service) SubmitOperation(ctx context.Context, req *connectorv1.SubmitOp
|
||||
if err != nil {
|
||||
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, err.Error(), op, "")}}, nil
|
||||
}
|
||||
parentPaymentRef := strings.TrimSpace(reader.String("parent_payment_ref"))
|
||||
|
||||
payoutID := strings.TrimSpace(reader.String("payout_id"))
|
||||
if payoutID == "" {
|
||||
payoutID = strings.TrimSpace(op.GetIdempotencyKey())
|
||||
}
|
||||
payoutID := operationIDForRequest(operationRef)
|
||||
|
||||
if strings.TrimSpace(reader.String("card_token")) != "" {
|
||||
resp, err := s.CreateCardTokenPayout(ctx, buildCardTokenPayoutRequestFromParams(reader, payoutID, amountMinor, currency))
|
||||
resp, err := s.CreateCardTokenPayout(ctx, buildCardTokenPayoutRequestFromParams(reader, payoutID, parentPaymentRef, idempotencyKey, operationRef, intentRef, amountMinor, currency))
|
||||
if err != nil {
|
||||
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, "")}}, nil
|
||||
}
|
||||
return &connectorv1.SubmitOperationResponse{Receipt: payoutReceipt(resp.GetPayout())}, nil
|
||||
}
|
||||
cr := buildCardPayoutRequestFromParams(reader, payoutID, idempotencyKey, operationRef, intentRef, amountMinor, currency)
|
||||
cr := buildCardPayoutRequestFromParams(reader, payoutID, parentPaymentRef, idempotencyKey, operationRef, intentRef, amountMinor, currency)
|
||||
resp, err := s.CreateCardPayout(ctx, cr)
|
||||
if err != nil {
|
||||
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, "")}}, nil
|
||||
@@ -119,6 +117,7 @@ func mntxOperationParams() []*connectorv1.OperationParamSpec {
|
||||
{Key: "card_holder", Type: connectorv1.ParamType_STRING, Required: false},
|
||||
{Key: "amount_minor", Type: connectorv1.ParamType_INT, Required: false},
|
||||
{Key: "project_id", Type: connectorv1.ParamType_INT, Required: false},
|
||||
{Key: "parent_payment_ref", Type: connectorv1.ParamType_STRING, Required: true},
|
||||
{Key: "customer_middle_name", Type: connectorv1.ParamType_STRING, Required: false},
|
||||
{Key: "customer_country", Type: connectorv1.ParamType_STRING, Required: false},
|
||||
{Key: "customer_state", Type: connectorv1.ParamType_STRING, Required: false},
|
||||
@@ -169,9 +168,29 @@ func currencyFromOperation(op *connectorv1.Operation) string {
|
||||
return strings.ToUpper(currency)
|
||||
}
|
||||
|
||||
func buildCardTokenPayoutRequestFromParams(reader params.Reader, payoutID string, amountMinor int64, currency string) *mntxv1.CardTokenPayoutRequest {
|
||||
func operationIDForRequest(operationRef string) string {
|
||||
return strings.TrimSpace(operationRef)
|
||||
}
|
||||
|
||||
func metadataFromReader(reader params.Reader) map[string]string {
|
||||
metadata := reader.StringMap("metadata")
|
||||
if len(metadata) == 0 {
|
||||
return nil
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
|
||||
func buildCardTokenPayoutRequestFromParams(reader params.Reader,
|
||||
payoutID, parentPaymentRef, idempotencyKey, operationRef, intentRef string,
|
||||
amountMinor int64, currency string) *mntxv1.CardTokenPayoutRequest {
|
||||
operationRef = strings.TrimSpace(operationRef)
|
||||
payoutID = strings.TrimSpace(payoutID)
|
||||
if operationRef != "" {
|
||||
payoutID = ""
|
||||
}
|
||||
req := &mntxv1.CardTokenPayoutRequest{
|
||||
PayoutId: payoutID,
|
||||
ParentPaymentRef: strings.TrimSpace(parentPaymentRef),
|
||||
ProjectId: readerInt64(reader, "project_id"),
|
||||
CustomerId: strings.TrimSpace(reader.String("customer_id")),
|
||||
CustomerFirstName: strings.TrimSpace(reader.String("customer_first_name")),
|
||||
@@ -188,16 +207,25 @@ func buildCardTokenPayoutRequestFromParams(reader params.Reader, payoutID string
|
||||
CardToken: strings.TrimSpace(reader.String("card_token")),
|
||||
CardHolder: strings.TrimSpace(reader.String("card_holder")),
|
||||
MaskedPan: strings.TrimSpace(reader.String("masked_pan")),
|
||||
Metadata: reader.StringMap("metadata"),
|
||||
Metadata: metadataFromReader(reader),
|
||||
OperationRef: operationRef,
|
||||
IdempotencyKey: strings.TrimSpace(idempotencyKey),
|
||||
IntentRef: strings.TrimSpace(intentRef),
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
func buildCardPayoutRequestFromParams(reader params.Reader,
|
||||
payoutID, idempotencyKey, operationRef, intentRef string,
|
||||
payoutID, parentPaymentRef, idempotencyKey, operationRef, intentRef string,
|
||||
amountMinor int64, currency string) *mntxv1.CardPayoutRequest {
|
||||
operationRef = strings.TrimSpace(operationRef)
|
||||
payoutID = strings.TrimSpace(payoutID)
|
||||
if operationRef != "" {
|
||||
payoutID = ""
|
||||
}
|
||||
return &mntxv1.CardPayoutRequest{
|
||||
PayoutId: payoutID,
|
||||
ParentPaymentRef: strings.TrimSpace(parentPaymentRef),
|
||||
ProjectId: readerInt64(reader, "project_id"),
|
||||
CustomerId: strings.TrimSpace(reader.String("customer_id")),
|
||||
CustomerFirstName: strings.TrimSpace(reader.String("customer_first_name")),
|
||||
@@ -215,10 +243,10 @@ func buildCardPayoutRequestFromParams(reader params.Reader,
|
||||
CardExpYear: uint32(readerInt64(reader, "card_exp_year")),
|
||||
CardExpMonth: uint32(readerInt64(reader, "card_exp_month")),
|
||||
CardHolder: strings.TrimSpace(reader.String("card_holder")),
|
||||
Metadata: reader.StringMap("metadata"),
|
||||
Metadata: metadataFromReader(reader),
|
||||
OperationRef: operationRef,
|
||||
IdempotencyKey: idempotencyKey,
|
||||
IntentRef: intentRef,
|
||||
IdempotencyKey: strings.TrimSpace(idempotencyKey),
|
||||
IntentRef: strings.TrimSpace(intentRef),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,7 +264,7 @@ func payoutReceipt(state *mntxv1.CardPayoutState) *connectorv1.OperationReceipt
|
||||
}
|
||||
}
|
||||
return &connectorv1.OperationReceipt{
|
||||
OperationId: strings.TrimSpace(state.GetPayoutId()),
|
||||
OperationId: firstNonEmpty(strings.TrimSpace(state.GetOperationRef()), strings.TrimSpace(state.GetPayoutId())),
|
||||
Status: payoutStatusToOperation(state.GetStatus()),
|
||||
ProviderRef: strings.TrimSpace(state.GetProviderPaymentId()),
|
||||
}
|
||||
@@ -247,7 +275,7 @@ func payoutToOperation(state *mntxv1.CardPayoutState) *connectorv1.Operation {
|
||||
return nil
|
||||
}
|
||||
return &connectorv1.Operation{
|
||||
OperationId: strings.TrimSpace(state.GetPayoutId()),
|
||||
OperationId: firstNonEmpty(strings.TrimSpace(state.GetOperationRef()), strings.TrimSpace(state.GetPayoutId())),
|
||||
Type: connectorv1.OperationType_PAYOUT,
|
||||
Status: payoutStatusToOperation(state.GetStatus()),
|
||||
Money: &moneyv1.Money{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/gateway/mntx/storage/model"
|
||||
@@ -22,7 +23,7 @@ func CardPayoutStateFromProto(clock clockpkg.Clock, p *mntxv1.CardPayoutState) *
|
||||
}
|
||||
|
||||
return &model.CardPayout{
|
||||
PaymentRef: p.PayoutId,
|
||||
PaymentRef: strings.TrimSpace(p.GetParentPaymentRef()),
|
||||
OperationRef: p.GetOperationRef(),
|
||||
IntentRef: p.GetIntentRef(),
|
||||
IdempotencyKey: p.GetIdempotencyKey(),
|
||||
@@ -41,7 +42,8 @@ func CardPayoutStateFromProto(clock clockpkg.Clock, p *mntxv1.CardPayoutState) *
|
||||
|
||||
func StateToProto(m *model.CardPayout) *mntxv1.CardPayoutState {
|
||||
return &mntxv1.CardPayoutState{
|
||||
PayoutId: m.PaymentRef,
|
||||
PayoutId: firstNonEmpty(m.OperationRef, m.PaymentRef),
|
||||
ParentPaymentRef: m.PaymentRef,
|
||||
ProjectId: m.ProjectID,
|
||||
CustomerId: m.CustomerID,
|
||||
AmountMinor: m.AmountMinor,
|
||||
|
||||
@@ -77,3 +77,10 @@ func WithMessagingSettings(settings pmodel.SettingsT) Option {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithStrictOperationIsolation serialises payout processing to one unresolved operation at a time.
|
||||
func WithStrictOperationIsolation(enabled bool) Option {
|
||||
return func(s *Service) {
|
||||
s.strictIsolation = enabled
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/tech/sendico/gateway/mntx/storage/model"
|
||||
)
|
||||
|
||||
const (
|
||||
payoutExecutionModeDefaultName = "default"
|
||||
payoutExecutionModeStrictIsolatedName = "strict_isolated"
|
||||
)
|
||||
|
||||
var errPayoutExecutionModeStopped = errors.New("payout execution mode stopped")
|
||||
|
||||
type payoutExecutionMode interface {
|
||||
Name() string
|
||||
BeforeDispatch(ctx context.Context, operationRef string) error
|
||||
OnPersistedState(operationRef string, status model.PayoutStatus)
|
||||
Shutdown()
|
||||
}
|
||||
|
||||
type defaultPayoutExecutionMode struct{}
|
||||
|
||||
func newDefaultPayoutExecutionMode() payoutExecutionMode {
|
||||
return &defaultPayoutExecutionMode{}
|
||||
}
|
||||
|
||||
func (m *defaultPayoutExecutionMode) Name() string {
|
||||
return payoutExecutionModeDefaultName
|
||||
}
|
||||
|
||||
func (m *defaultPayoutExecutionMode) BeforeDispatch(_ context.Context, _ string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *defaultPayoutExecutionMode) OnPersistedState(_ string, _ model.PayoutStatus) {}
|
||||
|
||||
func (m *defaultPayoutExecutionMode) Shutdown() {}
|
||||
|
||||
type strictIsolatedPayoutExecutionMode struct {
|
||||
mu sync.Mutex
|
||||
activeOperation string
|
||||
waitCh chan struct{}
|
||||
stopped bool
|
||||
}
|
||||
|
||||
func newStrictIsolatedPayoutExecutionMode() payoutExecutionMode {
|
||||
return &strictIsolatedPayoutExecutionMode{
|
||||
waitCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *strictIsolatedPayoutExecutionMode) Name() string {
|
||||
return payoutExecutionModeStrictIsolatedName
|
||||
}
|
||||
|
||||
func (m *strictIsolatedPayoutExecutionMode) BeforeDispatch(ctx context.Context, operationRef string) error {
|
||||
opRef := strings.TrimSpace(operationRef)
|
||||
if opRef == "" {
|
||||
return nil
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
for {
|
||||
waitCh, allowed, err := m.tryAcquire(opRef)
|
||||
if allowed {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-waitCh:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *strictIsolatedPayoutExecutionMode) OnPersistedState(operationRef string, status model.PayoutStatus) {
|
||||
opRef := strings.TrimSpace(operationRef)
|
||||
if opRef == "" {
|
||||
return
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.stopped {
|
||||
return
|
||||
}
|
||||
|
||||
if isFinalPayoutStatus(status) {
|
||||
if m.activeOperation == opRef {
|
||||
m.activeOperation = ""
|
||||
m.signalLocked()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if m.activeOperation == "" {
|
||||
m.activeOperation = opRef
|
||||
m.signalLocked()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *strictIsolatedPayoutExecutionMode) Shutdown() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.stopped {
|
||||
return
|
||||
}
|
||||
m.stopped = true
|
||||
m.activeOperation = ""
|
||||
m.signalLocked()
|
||||
}
|
||||
|
||||
func (m *strictIsolatedPayoutExecutionMode) tryAcquire(operationRef string) (<-chan struct{}, bool, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if m.stopped {
|
||||
return nil, false, errPayoutExecutionModeStopped
|
||||
}
|
||||
|
||||
switch owner := strings.TrimSpace(m.activeOperation); {
|
||||
case owner == "":
|
||||
m.activeOperation = operationRef
|
||||
m.signalLocked()
|
||||
return nil, true, nil
|
||||
case owner == operationRef:
|
||||
return nil, true, nil
|
||||
default:
|
||||
return m.waitCh, false, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (m *strictIsolatedPayoutExecutionMode) signalLocked() {
|
||||
if m.waitCh == nil {
|
||||
m.waitCh = make(chan struct{})
|
||||
return
|
||||
}
|
||||
close(m.waitCh)
|
||||
m.waitCh = make(chan struct{})
|
||||
}
|
||||
|
||||
func normalizePayoutExecutionMode(mode payoutExecutionMode) payoutExecutionMode {
|
||||
if mode == nil {
|
||||
return newDefaultPayoutExecutionMode()
|
||||
}
|
||||
return mode
|
||||
}
|
||||
|
||||
func payoutExecutionModeName(mode payoutExecutionMode) string {
|
||||
if mode == nil {
|
||||
return payoutExecutionModeDefaultName
|
||||
}
|
||||
name := strings.TrimSpace(mode.Name())
|
||||
if name == "" {
|
||||
return payoutExecutionModeDefaultName
|
||||
}
|
||||
return name
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/gateway/mntx/storage/model"
|
||||
)
|
||||
|
||||
func TestStrictIsolatedPayoutExecutionMode_BlocksOtherOperationUntilFinalStatus(t *testing.T) {
|
||||
mode := newStrictIsolatedPayoutExecutionMode()
|
||||
if err := mode.BeforeDispatch(context.Background(), "op-1"); err != nil {
|
||||
t.Fatalf("first acquire failed: %v", err)
|
||||
}
|
||||
|
||||
waitCtx, waitCancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer waitCancel()
|
||||
secondDone := make(chan error, 1)
|
||||
go func() {
|
||||
secondDone <- mode.BeforeDispatch(waitCtx, "op-2")
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-secondDone:
|
||||
t.Fatalf("second operation should be blocked before final status, got err=%v", err)
|
||||
case <-time.After(80 * time.Millisecond):
|
||||
}
|
||||
|
||||
mode.OnPersistedState("op-1", model.PayoutStatusWaiting)
|
||||
|
||||
select {
|
||||
case err := <-secondDone:
|
||||
t.Fatalf("second operation should remain blocked on non-final status, got err=%v", err)
|
||||
case <-time.After(80 * time.Millisecond):
|
||||
}
|
||||
|
||||
mode.OnPersistedState("op-1", model.PayoutStatusSuccess)
|
||||
|
||||
select {
|
||||
case err := <-secondDone:
|
||||
if err != nil {
|
||||
t.Fatalf("second operation should proceed after final status, got err=%v", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("timeout waiting for second operation to proceed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictIsolatedPayoutExecutionMode_AllowsSameOperationReentry(t *testing.T) {
|
||||
mode := newStrictIsolatedPayoutExecutionMode()
|
||||
if err := mode.BeforeDispatch(context.Background(), "op-1"); err != nil {
|
||||
t.Fatalf("first acquire failed: %v", err)
|
||||
}
|
||||
if err := mode.BeforeDispatch(context.Background(), "op-1"); err != nil {
|
||||
t.Fatalf("same operation should be re-entrant, got err=%v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
providerCodeDeclineAmountOrFrequencyLimit = "10101"
|
||||
)
|
||||
|
||||
type payoutFailureAction int
|
||||
|
||||
const (
|
||||
payoutFailureActionFail payoutFailureAction = iota + 1
|
||||
payoutFailureActionRetry
|
||||
)
|
||||
|
||||
type payoutFailureDecision struct {
|
||||
Action payoutFailureAction
|
||||
Reason string
|
||||
}
|
||||
|
||||
type payoutFailurePolicy struct {
|
||||
providerCodeActions map[string]payoutFailureAction
|
||||
}
|
||||
|
||||
func defaultPayoutFailurePolicy() payoutFailurePolicy {
|
||||
return payoutFailurePolicy{
|
||||
providerCodeActions: map[string]payoutFailureAction{
|
||||
providerCodeDeclineAmountOrFrequencyLimit: payoutFailureActionRetry,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (p payoutFailurePolicy) decideProviderFailure(code string) payoutFailureDecision {
|
||||
normalized := strings.TrimSpace(code)
|
||||
if normalized == "" {
|
||||
return payoutFailureDecision{
|
||||
Action: payoutFailureActionFail,
|
||||
Reason: "provider_failure",
|
||||
}
|
||||
}
|
||||
if action, ok := p.providerCodeActions[normalized]; ok {
|
||||
return payoutFailureDecision{
|
||||
Action: action,
|
||||
Reason: "provider_code_" + normalized,
|
||||
}
|
||||
}
|
||||
return payoutFailureDecision{
|
||||
Action: payoutFailureActionFail,
|
||||
Reason: "provider_code_" + normalized,
|
||||
}
|
||||
}
|
||||
|
||||
func (p payoutFailurePolicy) decideTransportFailure() payoutFailureDecision {
|
||||
return payoutFailureDecision{
|
||||
Action: payoutFailureActionRetry,
|
||||
Reason: "transport_failure",
|
||||
}
|
||||
}
|
||||
|
||||
func payoutFailureReason(code, message string) string {
|
||||
cleanCode := strings.TrimSpace(code)
|
||||
cleanMessage := strings.TrimSpace(message)
|
||||
switch {
|
||||
case cleanCode != "" && cleanMessage != "":
|
||||
return cleanCode + ": " + cleanMessage
|
||||
case cleanCode != "":
|
||||
return cleanCode
|
||||
default:
|
||||
return cleanMessage
|
||||
}
|
||||
}
|
||||
|
||||
func retryDelayForAttempt(attempt uint32) int {
|
||||
// Backoff in seconds by attempt number (attempt starts at 1).
|
||||
switch {
|
||||
case attempt <= 1:
|
||||
return 5
|
||||
case attempt == 2:
|
||||
return 15
|
||||
case attempt == 3:
|
||||
return 30
|
||||
default:
|
||||
return 60
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package gateway
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestPayoutFailurePolicy_DecideProviderFailure(t *testing.T) {
|
||||
policy := defaultPayoutFailurePolicy()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
code string
|
||||
action payoutFailureAction
|
||||
}{
|
||||
{
|
||||
name: "retryable provider limit code",
|
||||
code: providerCodeDeclineAmountOrFrequencyLimit,
|
||||
action: payoutFailureActionRetry,
|
||||
},
|
||||
{
|
||||
name: "unknown provider code",
|
||||
code: "99999",
|
||||
action: payoutFailureActionFail,
|
||||
},
|
||||
{
|
||||
name: "empty provider code",
|
||||
code: "",
|
||||
action: payoutFailureActionFail,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Helper()
|
||||
got := policy.decideProviderFailure(tc.code)
|
||||
if got.Action != tc.action {
|
||||
t.Fatalf("action mismatch: got=%v want=%v", got.Action, tc.action)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPayoutFailureReason(t *testing.T) {
|
||||
if got, want := payoutFailureReason("10101", "Decline due to amount or frequency limit"), "10101: Decline due to amount or frequency limit"; got != want {
|
||||
t.Fatalf("failure reason mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := payoutFailureReason("", "network error"), "network error"; got != want {
|
||||
t.Fatalf("failure reason mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := payoutFailureReason("10101", ""), "10101"; got != want {
|
||||
t.Fatalf("failure reason mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ type Service struct {
|
||||
gatewayDescriptor *gatewayv1.GatewayInstanceDescriptor
|
||||
announcer *discovery.Announcer
|
||||
invokeURI string
|
||||
strictIsolation bool
|
||||
|
||||
connectorv1.UnimplementedConnectorServiceServer
|
||||
}
|
||||
@@ -90,6 +91,9 @@ func NewService(logger mlogger.Logger, opts ...Option) *Service {
|
||||
}
|
||||
|
||||
svc.card = newCardPayoutProcessor(svc.logger, svc.config, svc.clock, svc.storage, svc.httpClient, svc.producer)
|
||||
if svc.strictIsolation {
|
||||
svc.card.setExecutionMode(newStrictIsolatedPayoutExecutionMode())
|
||||
}
|
||||
svc.card.outbox = &svc.outbox
|
||||
svc.card.msgCfg = svc.msgCfg
|
||||
if err := svc.card.startOutboxReliableProducer(); err != nil {
|
||||
@@ -112,6 +116,9 @@ func (s *Service) Shutdown() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
if s.card != nil {
|
||||
s.card.stopRetries()
|
||||
}
|
||||
s.outbox.Stop()
|
||||
if s.announcer != nil {
|
||||
s.announcer.Stop()
|
||||
@@ -151,7 +158,7 @@ func (s *Service) startDiscoveryAnnouncer() {
|
||||
return
|
||||
}
|
||||
announce := discovery.Announcement{
|
||||
Service: "CARD",
|
||||
Service: mservice.MntxGateway,
|
||||
Rail: discovery.RailCardPayout,
|
||||
Operations: discovery.CardPayoutRailGatewayOperations(),
|
||||
InvokeURI: s.invokeURI,
|
||||
|
||||
19
api/gateway/mntx/internal/service/gateway/service_test.go
Normal file
19
api/gateway/mntx/internal/service/gateway/service_test.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestNewService_StrictOperationIsolationOption(t *testing.T) {
|
||||
svc := NewService(zap.NewNop(), WithStrictOperationIsolation(true))
|
||||
t.Cleanup(svc.Shutdown)
|
||||
|
||||
if svc.card == nil {
|
||||
t.Fatalf("expected card processor to be initialised")
|
||||
}
|
||||
if got, want := payoutExecutionModeName(svc.card.executionMode), payoutExecutionModeStrictIsolatedName; got != want {
|
||||
t.Fatalf("execution mode mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ func testMonetixConfig() monetix.Config {
|
||||
func validCardPayoutRequest() *mntxv1.CardPayoutRequest {
|
||||
return &mntxv1.CardPayoutRequest{
|
||||
PayoutId: "payout-1",
|
||||
ParentPaymentRef: "payment-parent-1",
|
||||
CustomerId: "cust-1",
|
||||
CustomerFirstName: "Jane",
|
||||
CustomerLastName: "Doe",
|
||||
@@ -52,6 +53,7 @@ func validCardPayoutRequest() *mntxv1.CardPayoutRequest {
|
||||
func validCardTokenPayoutRequest() *mntxv1.CardTokenPayoutRequest {
|
||||
return &mntxv1.CardTokenPayoutRequest{
|
||||
PayoutId: "payout-1",
|
||||
ParentPaymentRef: "payment-parent-1",
|
||||
CustomerId: "cust-1",
|
||||
CustomerFirstName: "Jane",
|
||||
CustomerLastName: "Doe",
|
||||
|
||||
@@ -16,7 +16,14 @@ import (
|
||||
)
|
||||
|
||||
func isFinalStatus(t *model.CardPayout) bool {
|
||||
switch t.Status {
|
||||
if t == nil {
|
||||
return false
|
||||
}
|
||||
return isFinalPayoutStatus(t.Status)
|
||||
}
|
||||
|
||||
func isFinalPayoutStatus(status model.PayoutStatus) bool {
|
||||
switch status {
|
||||
case model.PayoutStatusFailed, model.PayoutStatusSuccess, model.PayoutStatusCancelled:
|
||||
return true
|
||||
default:
|
||||
@@ -45,6 +52,7 @@ func (p *cardPayoutProcessor) updatePayoutStatus(ctx context.Context, state *mod
|
||||
)
|
||||
return err
|
||||
}
|
||||
p.observeExecutionState(state)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -65,6 +73,7 @@ func (p *cardPayoutProcessor) updatePayoutStatus(ctx context.Context, state *mod
|
||||
)
|
||||
return err
|
||||
}
|
||||
p.observeExecutionState(state)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ const (
|
||||
payoutsCollection = "card_payouts"
|
||||
payoutIdemField = "idempotencyKey"
|
||||
payoutIdField = "paymentRef"
|
||||
payoutOpField = "operationRef"
|
||||
)
|
||||
|
||||
type Payouts struct {
|
||||
@@ -36,13 +37,21 @@ func NewPayouts(logger mlogger.Logger, db *mongo.Database) (*Payouts, error) {
|
||||
logger = logger.Named("payouts").With(zap.String("collection", payoutsCollection))
|
||||
|
||||
repo := repository.CreateMongoRepository(db, payoutsCollection)
|
||||
if err := repo.CreateIndex(&ri.Definition{
|
||||
Keys: []ri.Key{{Field: payoutOpField, Sort: ri.Asc}},
|
||||
Unique: true,
|
||||
Sparse: true,
|
||||
}); err != nil {
|
||||
logger.Error("Failed to create payouts operation index",
|
||||
zap.Error(err), zap.String("index_field", payoutOpField))
|
||||
return nil, err
|
||||
}
|
||||
if err := repo.CreateIndex(&ri.Definition{
|
||||
Keys: []ri.Key{{Field: payoutIdemField, Sort: ri.Asc}},
|
||||
Unique: true,
|
||||
}); err != nil {
|
||||
logger.Error("Failed to create payouts idempotency index",
|
||||
zap.Error(err),
|
||||
zap.String("index_field", payoutIdemField))
|
||||
zap.Error(err), zap.String("index_field", payoutIdemField))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -63,6 +72,10 @@ func (p *Payouts) FindByIdempotencyKey(ctx context.Context, key string) (*model.
|
||||
return p.findOneByField(ctx, payoutIdemField, key)
|
||||
}
|
||||
|
||||
func (p *Payouts) FindByOperationRef(ctx context.Context, operationRef string) (*model.CardPayout, error) {
|
||||
return p.findOneByField(ctx, payoutOpField, operationRef)
|
||||
}
|
||||
|
||||
func (p *Payouts) FindByPaymentID(ctx context.Context, paymentID string) (*model.CardPayout, error) {
|
||||
return p.findOneByField(ctx, payoutIdField, paymentID)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ type Repository interface {
|
||||
|
||||
type PayoutsStore interface {
|
||||
FindByIdempotencyKey(ctx context.Context, key string) (*model.CardPayout, error)
|
||||
FindByOperationRef(ctx context.Context, key string) (*model.CardPayout, error)
|
||||
FindByPaymentID(ctx context.Context, key string) (*model.CardPayout, error)
|
||||
Upsert(ctx context.Context, record *model.CardPayout) error
|
||||
}
|
||||
|
||||
@@ -534,7 +534,7 @@ func (s *Service) startAnnouncer() {
|
||||
announce := discovery.Announcement{
|
||||
ID: discovery.StablePaymentGatewayID(rail),
|
||||
InstanceID: discovery.InstanceID(),
|
||||
Service: string(mservice.PaymentGateway),
|
||||
Service: mservice.TgSettle,
|
||||
Rail: rail,
|
||||
Operations: caps,
|
||||
InvokeURI: s.invokeURI,
|
||||
|
||||
@@ -63,9 +63,6 @@ func New(ctx context.Context, cfg Config, opts ...grpc.DialOption) (Client, erro
|
||||
return nil, merrors.InvalidArgument("chain-gateway: address is required")
|
||||
}
|
||||
|
||||
dialCtx, cancel := context.WithTimeout(ctx, cfg.DialTimeout)
|
||||
defer cancel()
|
||||
|
||||
dialOpts := make([]grpc.DialOption, 0, len(opts)+1)
|
||||
dialOpts = append(dialOpts, opts...)
|
||||
|
||||
@@ -75,7 +72,7 @@ func New(ctx context.Context, cfg Config, opts ...grpc.DialOption) (Client, erro
|
||||
dialOpts = append(dialOpts, grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{})))
|
||||
}
|
||||
|
||||
conn, err := grpc.DialContext(dialCtx, cfg.Address, dialOpts...)
|
||||
conn, err := grpc.NewClient(cfg.Address, dialOpts...)
|
||||
if err != nil {
|
||||
return nil, merrors.Internal(fmt.Sprintf("chain-gateway: dial %s: %s", cfg.Address, err.Error()))
|
||||
}
|
||||
|
||||
@@ -40,8 +40,10 @@ chains:
|
||||
chain_id: 3448148188 # Nile testnet
|
||||
native_token: TRX
|
||||
rpc_url_env: TRON_GATEWAY_RPC_URL
|
||||
grpc_url_env: TRON_GATEWAY_GRPC_URL
|
||||
grpc_token_env: TRON_GATEWAY_GRPC_TOKEN
|
||||
grpc:
|
||||
url_env: TRON_GATEWAY_GRPC_URL
|
||||
token_env: TRON_GATEWAY_GRPC_TOKEN
|
||||
force_ipv4: false
|
||||
gas_topup_policy:
|
||||
buffer_percent: 0.10
|
||||
min_native_balance_trx: 10
|
||||
|
||||
@@ -40,8 +40,10 @@ chains:
|
||||
chain_id: 728126428 # 0x2b6653dc
|
||||
native_token: TRX
|
||||
rpc_url_env: TRON_GATEWAY_RPC_URL
|
||||
grpc_url_env: TRON_GATEWAY_GRPC_URL
|
||||
grpc_token_env: TRON_GATEWAY_GRPC_TOKEN
|
||||
grpc:
|
||||
url_env: TRON_GATEWAY_GRPC_URL
|
||||
token_env: TRON_GATEWAY_GRPC_TOKEN
|
||||
force_ipv4: true
|
||||
gas_topup_policy:
|
||||
buffer_percent: 0.10
|
||||
min_native_balance_trx: 10
|
||||
|
||||
@@ -8,7 +8,7 @@ replace github.com/tech/sendico/gateway/common => ../common
|
||||
|
||||
require (
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1
|
||||
github.com/ethereum/go-ethereum v1.17.0
|
||||
github.com/ethereum/go-ethereum v1.17.1
|
||||
github.com/fbsobreira/gotron-sdk v0.24.2
|
||||
github.com/mitchellh/mapstructure v1.5.0
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
@@ -26,7 +26,7 @@ require (
|
||||
|
||||
require (
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260225065256-91dd007ecddc // indirect
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260303081205-342d20cce05a // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bits-and-blooms/bitset v1.24.4 // indirect
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect
|
||||
@@ -87,9 +87,9 @@ require (
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/otel v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.41.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
|
||||
@@ -6,8 +6,8 @@ github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ=
|
||||
github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260225065256-91dd007ecddc h1:1stW1OipdBj8Me+nj26SzT8yil7OYve0r3cWobzk1JQ=
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260225065256-91dd007ecddc/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260303081205-342d20cce05a h1:vz21GPRVGgZazqOsqLgJq3r8C9LlV+av4Sm2g2LipCU=
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260303081205-342d20cce05a/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
|
||||
github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0=
|
||||
github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
@@ -80,8 +80,8 @@ github.com/ethereum/c-kzg-4844/v2 v2.1.6 h1:xQymkKCT5E2Jiaoqf3v4wsNgjZLY0lRSkZn2
|
||||
github.com/ethereum/c-kzg-4844/v2 v2.1.6/go.mod h1:8HMkUZ5JRv4hpw/XUrYWSQNAUzhHMg2UDb/U+5m+XNw=
|
||||
github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab h1:rvv6MJhy07IMfEKuARQ9TKojGqLVNxQajaXEp/BoqSk=
|
||||
github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab/go.mod h1:IuLm4IsPipXKF7CW5Lzf68PIbZ5yl7FFd74l/E0o9A8=
|
||||
github.com/ethereum/go-ethereum v1.17.0 h1:2D+1Fe23CwZ5tQoAS5DfwKFNI1HGcTwi65/kRlAVxes=
|
||||
github.com/ethereum/go-ethereum v1.17.0/go.mod h1:2W3msvdosS/MCWytpqTcqgFiRYbTH59FxDJzqah120o=
|
||||
github.com/ethereum/go-ethereum v1.17.1 h1:IjlQDjgxg2uL+GzPRkygGULPMLzcYWncEI7wbaizvho=
|
||||
github.com/ethereum/go-ethereum v1.17.1/go.mod h1:7UWOVHL7K3b8RfVRea022btnzLCaanwHtBuH1jUCH/I=
|
||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||
github.com/fbsobreira/go-bip39 v1.2.0 h1:zp3VDGrQeGu8/iPB5wsHVSaOwQhBSLR71CE3nJVz4mY=
|
||||
@@ -308,16 +308,16 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 h1:UP6IpuHFkUgOQL9FFQFrZ+5LiwhhYRbi7VZSIx6Nj5s=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM=
|
||||
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
|
||||
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
|
||||
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
|
||||
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
|
||||
go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
|
||||
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
|
||||
go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ=
|
||||
go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
|
||||
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
|
||||
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
|
||||
go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
|
||||
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
|
||||
@@ -53,14 +53,19 @@ type config struct {
|
||||
type chainConfig struct {
|
||||
Name string `yaml:"name"`
|
||||
RPCURLEnv string `yaml:"rpc_url_env"`
|
||||
GRPCURLEnv string `yaml:"grpc_url_env"` // Native TRON gRPC endpoint
|
||||
GRPCTokenEnv string `yaml:"grpc_token_env"`
|
||||
GRPC chainGRPCConfig `yaml:"grpc"`
|
||||
ChainID uint64 `yaml:"chain_id"`
|
||||
NativeToken string `yaml:"native_token"`
|
||||
Tokens []tokenConfig `yaml:"tokens"`
|
||||
GasTopUpPolicy *gasTopUpPolicyConfig `yaml:"gas_topup_policy"`
|
||||
}
|
||||
|
||||
type chainGRPCConfig struct {
|
||||
URLEnv string `yaml:"url_env"` // Native TRON gRPC endpoint env var
|
||||
TokenEnv string `yaml:"token_env"` // Optional auth token env var for x-token header
|
||||
ForceIPv4 bool `yaml:"force_ipv4"` // Force IPv4 sockets when dialing TRON gRPC.
|
||||
}
|
||||
|
||||
type serviceWalletConfig struct {
|
||||
Chain string `yaml:"chain"`
|
||||
Address string `yaml:"address"`
|
||||
@@ -282,14 +287,14 @@ func resolveNetworkConfigs(logger mlogger.Logger, chains []chainConfig) ([]gatew
|
||||
|
||||
// Resolve optional TRON gRPC URL
|
||||
grpcURL := ""
|
||||
if grpcEnv := strings.TrimSpace(chain.GRPCURLEnv); grpcEnv != "" {
|
||||
if grpcEnv := strings.TrimSpace(chain.GRPC.URLEnv); grpcEnv != "" {
|
||||
grpcURL = strings.TrimSpace(os.Getenv(grpcEnv))
|
||||
if grpcURL != "" {
|
||||
logger.Info("TRON gRPC URL configured", zap.String("chain", network.String()), zap.String("env", grpcEnv))
|
||||
}
|
||||
}
|
||||
grpcToken := ""
|
||||
if grpcTokenEnv := strings.TrimSpace(chain.GRPCTokenEnv); grpcTokenEnv != "" {
|
||||
if grpcTokenEnv := strings.TrimSpace(chain.GRPC.TokenEnv); grpcTokenEnv != "" {
|
||||
grpcToken = strings.TrimSpace(os.Getenv(grpcTokenEnv))
|
||||
if grpcToken != "" {
|
||||
logger.Info("TRON gRPC token configured", zap.String("chain", network.String()), zap.String("env", grpcTokenEnv))
|
||||
@@ -301,6 +306,7 @@ func resolveNetworkConfigs(logger mlogger.Logger, chains []chainConfig) ([]gatew
|
||||
RPCURL: rpcURL,
|
||||
GRPCUrl: grpcURL,
|
||||
GRPCToken: grpcToken,
|
||||
GRPCForceIPv4: chain.GRPC.ForceIPv4,
|
||||
ChainID: chain.ChainID,
|
||||
NativeToken: chain.NativeToken,
|
||||
TokenConfigs: contracts,
|
||||
|
||||
@@ -2,7 +2,6 @@ package tron
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"math/big"
|
||||
"strings"
|
||||
|
||||
@@ -18,8 +17,6 @@ import (
|
||||
const (
|
||||
// Default fee limit for TRC20 transfers (100 TRX in SUN)
|
||||
defaultTRC20FeeLimit = 100_000_000
|
||||
// SUN per TRX
|
||||
sunPerTRX = 1_000_000
|
||||
)
|
||||
|
||||
// SubmitTransferNative submits a transfer using native TRON gRPC.
|
||||
@@ -299,11 +296,3 @@ func normalizeTxHash(txHash string) string {
|
||||
h = strings.TrimPrefix(h, "0X")
|
||||
return strings.ToLower(h)
|
||||
}
|
||||
|
||||
// txHashToHex converts a byte slice transaction ID to hex string.
|
||||
func txHashToHex(txID []byte) string {
|
||||
if len(txID) == 0 {
|
||||
return ""
|
||||
}
|
||||
return hex.EncodeToString(txID)
|
||||
}
|
||||
|
||||
@@ -229,7 +229,7 @@ func (s *Service) startDiscoveryAnnouncers() {
|
||||
announce := discovery.Announcement{
|
||||
ID: discovery.StableCryptoRailGatewayID(network.Name.String()),
|
||||
InstanceID: discovery.InstanceID(),
|
||||
Service: "CRYPTO_RAIL_GATEWAY",
|
||||
Service: mservice.TronGateway,
|
||||
Rail: discovery.RailCrypto,
|
||||
Operations: discovery.CryptoRailGatewayOperations(),
|
||||
Currencies: currencies,
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -27,7 +28,7 @@ type Client struct {
|
||||
}
|
||||
|
||||
// NewClient creates a new TRON gRPC client connected to the given endpoint.
|
||||
func NewClient(grpcURL string, timeout time.Duration, authToken string) (*Client, error) {
|
||||
func NewClient(grpcURL string, timeout time.Duration, authToken string, forceIPv4 bool) (*Client, error) {
|
||||
if grpcURL == "" {
|
||||
return nil, merrors.InvalidArgument("tronclient: grpc url is required")
|
||||
}
|
||||
@@ -50,6 +51,13 @@ func NewClient(grpcURL string, timeout time.Duration, authToken string) (*Client
|
||||
}
|
||||
|
||||
opts := []grpc.DialOption{transportCreds}
|
||||
if forceIPv4 {
|
||||
host, port, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return nil, merrors.InvalidArgument("tronclient: invalid grpc address: " + address)
|
||||
}
|
||||
opts = append(opts, grpc.WithContextDialer(newForceIPv4Dialer(host, port)))
|
||||
}
|
||||
if token := strings.TrimSpace(authToken); token != "" {
|
||||
opts = append(opts,
|
||||
grpc.WithUnaryInterceptor(grpcTokenUnaryInterceptor(token)),
|
||||
@@ -67,30 +75,62 @@ func NewClient(grpcURL string, timeout time.Duration, authToken string) (*Client
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newForceIPv4Dialer(host, port string) func(context.Context, string) (net.Conn, error) {
|
||||
return func(ctx context.Context, _ string) (net.Conn, error) {
|
||||
ips, err := net.DefaultResolver.LookupIP(ctx, "ip4", host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(ips) == 0 {
|
||||
return nil, merrors.Internal(fmt.Sprintf("no IPv4 address found for %s", host))
|
||||
}
|
||||
|
||||
var dialer net.Dialer
|
||||
var lastErr error
|
||||
for _, ip := range ips {
|
||||
target := net.JoinHostPort(ip.String(), port)
|
||||
conn, err := dialer.DialContext(ctx, "tcp4", target)
|
||||
if err == nil {
|
||||
return conn, nil
|
||||
}
|
||||
lastErr = err
|
||||
if ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if lastErr != nil {
|
||||
return nil, merrors.Internal(fmt.Sprintf("failed to dial any IPv4 address for %s: %v", host, lastErr))
|
||||
}
|
||||
return nil, merrors.Internal(fmt.Sprintf("failed to dial IPv4 address for %s", host))
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeGRPCAddress(grpcURL string) (string, bool, error) {
|
||||
target := strings.TrimSpace(grpcURL)
|
||||
useTLS := false
|
||||
if target == "" {
|
||||
return "", false, merrors.InvalidArgument("tronclient: grpc url is required")
|
||||
}
|
||||
if strings.Contains(target, "://") {
|
||||
u, err := url.Parse(target)
|
||||
if err != nil {
|
||||
return "", false, merrors.InvalidArgument("tronclient: invalid grpc url")
|
||||
}
|
||||
if u.Scheme == "https" || u.Scheme == "grpcs" {
|
||||
useTLS = true
|
||||
}
|
||||
host := strings.TrimSpace(u.Host)
|
||||
if host == "" {
|
||||
return "", false, merrors.InvalidArgument("tronclient: grpc url missing host")
|
||||
}
|
||||
if useTLS && u.Port() == "" {
|
||||
host = host + ":443"
|
||||
}
|
||||
return host, useTLS, nil
|
||||
|
||||
// Default to secure gRPC when no scheme is provided.
|
||||
if !strings.Contains(target, "://") {
|
||||
target = "grpcs://" + target
|
||||
}
|
||||
return target, useTLS, nil
|
||||
|
||||
u, err := url.Parse(target)
|
||||
if err != nil {
|
||||
return "", false, merrors.InvalidArgument("tronclient: invalid grpc url")
|
||||
}
|
||||
|
||||
useTLS := u.Scheme == "https" || u.Scheme == "grpcs"
|
||||
host := strings.TrimSpace(u.Host)
|
||||
if host == "" {
|
||||
return "", false, merrors.InvalidArgument("tronclient: grpc url missing host")
|
||||
}
|
||||
if useTLS && u.Port() == "" {
|
||||
host = host + ":443"
|
||||
}
|
||||
|
||||
return host, useTLS, nil
|
||||
}
|
||||
|
||||
func grpcTokenUnaryInterceptor(token string) grpc.UnaryClientInterceptor {
|
||||
|
||||
@@ -40,6 +40,7 @@ func Prepare(ctx context.Context, logger mlogger.Logger, networks []shared.Netwo
|
||||
name := network.Name.String()
|
||||
grpcURL := strings.TrimSpace(network.GRPCUrl)
|
||||
grpcToken := strings.TrimSpace(network.GRPCToken)
|
||||
forceIPv4 := network.GRPCForceIPv4
|
||||
|
||||
if !network.Name.IsValid() {
|
||||
continue
|
||||
@@ -56,9 +57,10 @@ func Prepare(ctx context.Context, logger mlogger.Logger, networks []shared.Netwo
|
||||
registry.logger.Info("Initializing TRON gRPC client",
|
||||
zap.String("network", name),
|
||||
zap.String("grpc_url", grpcURL),
|
||||
zap.Bool("force_ipv4", forceIPv4),
|
||||
)
|
||||
|
||||
client, err := NewClient(grpcURL, timeout, grpcToken)
|
||||
client, err := NewClient(grpcURL, timeout, grpcToken, forceIPv4)
|
||||
if err != nil {
|
||||
registry.Close()
|
||||
registry.logger.Error("Failed to initialize TRON gRPC client",
|
||||
|
||||
@@ -170,6 +170,7 @@ type Network struct {
|
||||
RPCURL string
|
||||
GRPCUrl string // Native TRON gRPC endpoint (for transactions)
|
||||
GRPCToken string // Optional auth token for TRON gRPC (x-token header)
|
||||
GRPCForceIPv4 bool // Force IPv4 sockets when dialing TRON gRPC endpoint.
|
||||
ChainID uint64
|
||||
NativeToken string
|
||||
TokenConfigs []TokenContract
|
||||
|
||||
@@ -105,9 +105,6 @@ func New(ctx context.Context, cfg Config, opts ...grpc.DialOption) (Client, erro
|
||||
return nil, merrors.InvalidArgument("ledger: address is required")
|
||||
}
|
||||
|
||||
dialCtx, cancel := context.WithTimeout(ctx, cfg.DialTimeout)
|
||||
defer cancel()
|
||||
|
||||
dialOpts := make([]grpc.DialOption, 0, len(opts)+1)
|
||||
dialOpts = append(dialOpts, opts...)
|
||||
|
||||
@@ -117,7 +114,7 @@ func New(ctx context.Context, cfg Config, opts ...grpc.DialOption) (Client, erro
|
||||
dialOpts = append(dialOpts, grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{})))
|
||||
}
|
||||
|
||||
conn, err := grpc.DialContext(dialCtx, cfg.Address, dialOpts...)
|
||||
conn, err := grpc.NewClient(cfg.Address, dialOpts...)
|
||||
if err != nil {
|
||||
return nil, merrors.InternalWrap(err, fmt.Sprintf("ledger: dial %s", cfg.Address))
|
||||
}
|
||||
|
||||
@@ -66,8 +66,7 @@ func (s *Service) postCreditResponder(_ context.Context, req *ledgerv1.PostCredi
|
||||
existingEntry, err := s.storage.JournalEntries().GetByIdempotencyKey(ctx, orgRef, req.IdempotencyKey)
|
||||
if err == nil && existingEntry != nil {
|
||||
recordDuplicateRequest(journalEntryTypeCredit)
|
||||
logger.Info("Duplicate credit request (idempotency)",
|
||||
zap.String("existingEntryID", existingEntry.GetID().Hex()))
|
||||
logger.Info("Duplicate credit request (idempotency)", mzap.StorableRef(existingEntry))
|
||||
return &ledgerv1.PostResponse{
|
||||
JournalEntryRef: existingEntry.GetID().Hex(),
|
||||
Version: existingEntry.Version,
|
||||
|
||||
@@ -64,8 +64,7 @@ func (s *Service) postDebitResponder(_ context.Context, req *ledgerv1.PostDebitR
|
||||
existingEntry, err := s.storage.JournalEntries().GetByIdempotencyKey(ctx, orgRef, req.IdempotencyKey)
|
||||
if err == nil && existingEntry != nil {
|
||||
recordDuplicateRequest(journalEntryTypeDebit)
|
||||
logger.Info("Duplicate debit request (idempotency)",
|
||||
zap.String("existingEntryID", existingEntry.GetID().Hex()))
|
||||
logger.Info("Duplicate debit request (idempotency)", mzap.StorableRef(existingEntry))
|
||||
return &ledgerv1.PostResponse{
|
||||
JournalEntryRef: existingEntry.GetID().Hex(),
|
||||
Version: existingEntry.Version,
|
||||
@@ -123,7 +122,7 @@ func (s *Service) postDebitResponder(_ context.Context, req *ledgerv1.PostDebitR
|
||||
if err == storage.ErrAccountNotFound {
|
||||
return nil, merrors.NoData(fmt.Sprintf("charges[%d]: account not found", i))
|
||||
}
|
||||
logger.Warn("Failed to get charge account", zap.Error(err), zap.String("chargeAccountRef", chargeAccountRef.Hex()))
|
||||
logger.Warn("Failed to get charge account", zap.Error(err), mzap.ObjRef("charge_account_ref", chargeAccountRef))
|
||||
return nil, merrors.Internal("failed to get charge account")
|
||||
}
|
||||
if err := validateAccountForOrg(chargeAccount, orgRef, charge.Money.Currency); err != nil {
|
||||
|
||||
@@ -62,7 +62,7 @@ func (s *Service) postExternalCreditResponder(_ context.Context, req *ledgerv1.P
|
||||
if err == nil && existingEntry != nil {
|
||||
recordDuplicateRequest(journalEntryTypeCredit)
|
||||
logger.Info("Duplicate external credit request (idempotency)",
|
||||
zap.String("existingEntryID", existingEntry.GetID().Hex()))
|
||||
mzap.StorableRef(existingEntry))
|
||||
return &ledgerv1.PostResponse{
|
||||
JournalEntryRef: existingEntry.GetID().Hex(),
|
||||
Version: existingEntry.Version,
|
||||
@@ -140,7 +140,7 @@ func (s *Service) postExternalCreditResponder(_ context.Context, req *ledgerv1.P
|
||||
if err == storage.ErrAccountNotFound {
|
||||
return nil, merrors.NoData(fmt.Sprintf("charges[%d]: account not found", i))
|
||||
}
|
||||
logger.Warn("Failed to get charge account", zap.Error(err), zap.String("chargeAccountRef", chargeAccountRef.Hex()))
|
||||
logger.Warn("Failed to get charge account", zap.Error(err), mzap.ObjRef("charge_account_ref", chargeAccountRef))
|
||||
return nil, merrors.Internal("failed to get charge account")
|
||||
}
|
||||
if err := validateAccountForOrg(chargeAccount, orgRef, charge.Money.Currency); err != nil {
|
||||
@@ -287,8 +287,7 @@ func (s *Service) postExternalDebitResponder(_ context.Context, req *ledgerv1.Po
|
||||
existingEntry, err := s.storage.JournalEntries().GetByIdempotencyKey(ctx, orgRef, req.IdempotencyKey)
|
||||
if err == nil && existingEntry != nil {
|
||||
recordDuplicateRequest(journalEntryTypeDebit)
|
||||
logger.Info("Duplicate external debit request (idempotency)",
|
||||
zap.String("existingEntryID", existingEntry.GetID().Hex()))
|
||||
logger.Info("Duplicate external debit request (idempotency)", mzap.StorableRef(existingEntry))
|
||||
return &ledgerv1.PostResponse{
|
||||
JournalEntryRef: existingEntry.GetID().Hex(),
|
||||
Version: existingEntry.Version,
|
||||
@@ -366,7 +365,7 @@ func (s *Service) postExternalDebitResponder(_ context.Context, req *ledgerv1.Po
|
||||
if err == storage.ErrAccountNotFound {
|
||||
return nil, merrors.NoData(fmt.Sprintf("charges[%d]: account not found", i))
|
||||
}
|
||||
logger.Warn("Failed to get charge account", zap.Error(err), zap.String("chargeAccountRef", chargeAccountRef.Hex()))
|
||||
logger.Warn("Failed to get charge account", zap.Error(err), mzap.ObjRef("charge_account_ref", chargeAccountRef))
|
||||
return nil, merrors.Internal("failed to get charge account")
|
||||
}
|
||||
if err := validateAccountForOrg(chargeAccount, orgRef, charge.Money.Currency); err != nil {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user