Compare commits

54 Commits

Author SHA1 Message Date
Arseni
75f3678b90 removed payments polling 2026-03-04 15:34:16 +03:00
4fa641f971 Merge pull request 'serial payouts' (#632) from mntx-627 into main
Some checks failed
ci/woodpecker/push/gateway_mntx Pipeline failed
Reviewed-on: #632
2026-03-04 09:33:07 +00:00
Stephan D
eb8b7b3402 serial payouts 2026-03-04 10:32:37 +01:00
3a8935f5f0 Merge pull request 'fixed rescheduling supporting callback error code processing' (#631) from mntx-627 into main
All checks were successful
ci/woodpecker/push/gateway_mntx Pipeline was successful
Reviewed-on: #631
2026-03-04 08:19:12 +00:00
Stephan D
d92be5eedc fixed rescheduling supporting callback error code processing 2026-03-04 09:18:15 +01:00
94406373e6 Merge pull request 'fixed quotation currency inference' (#630) from pq-626 into main
All checks were successful
ci/woodpecker/push/billing_documents Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/callbacks Pipeline was successful
ci/woodpecker/push/discovery Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/gateway_chain Pipeline was successful
ci/woodpecker/push/gateway_mntx Pipeline was successful
ci/woodpecker/push/gateway_tgsettle Pipeline was successful
ci/woodpecker/push/gateway_tron Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_methods Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/payments_quotation Pipeline was successful
Reviewed-on: #630
2026-03-04 04:13:55 +00:00
eda5bf19ad Merge pull request 'mntx throtling' (#629) from mntx-627 into main
All checks were successful
ci/woodpecker/push/gateway_mntx Pipeline was successful
Reviewed-on: #629
2026-03-04 04:04:53 +00:00
Stephan D
5629f5fcb2 mntx throtling 2026-03-04 05:04:34 +01:00
95f7698661 Merge pull request 'mntx gateway throttling' (#628) from mntx-627 into main
Some checks failed
ci/woodpecker/push/gateway_mntx Pipeline failed
Reviewed-on: #628
2026-03-04 04:03:32 +00:00
Stephan D
4e70873a94 mntx gateway throttling 2026-03-04 05:02:52 +01:00
Stephan D
de07b9a792 fixed quotation currency inference 2026-03-04 04:50:31 +01:00
9b794a3065 Merge pull request 'mntx-624' (#625) from mntx-624 into main
Some checks failed
ci/woodpecker/push/billing_documents Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/callbacks Pipeline was successful
ci/woodpecker/push/discovery Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline failed
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/gateway_chain Pipeline was successful
ci/woodpecker/push/gateway_mntx Pipeline was successful
ci/woodpecker/push/gateway_tgsettle Pipeline was successful
ci/woodpecker/push/gateway_tron Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_methods Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/payments_quotation Pipeline was successful
Reviewed-on: #625
2026-03-04 01:47:43 +00:00
Stephan D
56bf49aa03 fixed mntx payout sequence 2026-03-04 02:46:51 +01:00
Stephan D
8377b6b2af fixed operations idempotency 2026-03-04 02:27:12 +01:00
f06208348b Merge pull request 'fixed default to grpcs' (#623) from tron-622 into main
All checks were successful
ci/woodpecker/push/gateway_tron Pipeline was successful
Reviewed-on: #623
2026-03-04 00:31:21 +00:00
Stephan D
b4c09cfb3b fixed default to grpcs 2026-03-04 01:30:56 +01:00
00812fa2bd Merge pull request 'refactored deprecated code' (#621) from pkg-620 into main
Some checks failed
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/billing_documents Pipeline was successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/callbacks Pipeline was successful
ci/woodpecker/push/discovery Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline failed
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/gateway_chain Pipeline was successful
ci/woodpecker/push/gateway_mntx Pipeline was successful
ci/woodpecker/push/gateway_tgsettle Pipeline was successful
ci/woodpecker/push/gateway_tron Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_methods Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/payments_quotation Pipeline was successful
Reviewed-on: #621
2026-03-03 21:29:22 +00:00
Stephan D
ce5f90939f refactored deprecated code 2026-03-03 22:29:03 +01:00
1b40b173eb Merge pull request 'added ledger as source of funds for payouts' (#618) from SEND061 into main
Some checks failed
ci/woodpecker/push/frontend Pipeline failed
Reviewed-on: #618
2026-03-03 21:26:45 +00:00
cd8e8071a9 Merge pull request 'fixed address resolution' (#619) from tron-616 into main
All checks were successful
ci/woodpecker/push/gateway_tron Pipeline was successful
Reviewed-on: #619
2026-03-03 18:37:32 +00:00
Stephan D
f7a1027de7 fixed address resolution 2026-03-03 19:37:09 +01:00
c5b3dfbd7a Merge pull request 'fixed address resolution' (#617) from tron-616 into main
All checks were successful
ci/woodpecker/push/gateway_tron Pipeline was successful
Reviewed-on: #617
2026-03-03 18:13:58 +00:00
Stephan D
41cb826d26 fixed address resolution 2026-03-03 19:13:36 +01:00
Arseni
51c72a87ae added ledger as souec of funds for payouts 2026-03-03 21:03:30 +03:00
Stephan D
3f578353da fixed tron ip connection
All checks were successful
ci/woodpecker/push/gateway_tron Pipeline was successful
2026-03-03 18:32:26 +01:00
7cac494509 Merge pull request 'Fixed tron driver settings' (#613) from tron-612 into main
All checks were successful
ci/woodpecker/push/gateway_tron Pipeline was successful
Reviewed-on: #613
2026-03-03 16:51:11 +00:00
Stephan D
d8f0febc5e Fixed tron driver settings 2026-03-03 17:50:50 +01:00
34a8a5d057 Fixed mntx discovery
All checks were successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/gateway_mntx Pipeline was successful
ci/woodpecker/push/gateway_tgsettle Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/gateway_chain Pipeline was successful
ci/woodpecker/push/gateway_tron Pipeline was successful
ci/woodpecker/push/billing_documents Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/callbacks Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/discovery Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/payments_methods Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/payments_quotation Pipeline was successful
Fixed mntx discovery
2026-03-03 12:16:46 +00:00
Stephan D
83745bcd10 fixed mntx discovery 2026-03-03 13:15:42 +01:00
Stephan D
f9acb47ad7 mntx gateway lookup name fixed 2026-03-03 12:23:07 +01:00
b2cc3fe980 Merge pull request 'fixed failing tests' (#608) from po-607 into main
Some checks failed
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/gateway_chain Pipeline was successful
ci/woodpecker/push/gateway_mntx Pipeline was successful
ci/woodpecker/push/gateway_tgsettle Pipeline failed
ci/woodpecker/push/gateway_tron Pipeline failed
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_methods Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/payments_quotation Pipeline is pending
ci/woodpecker/push/billing_documents Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/callbacks Pipeline was successful
ci/woodpecker/push/discovery Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
Reviewed-on: #608
2026-03-03 10:53:28 +00:00
Stephan D
d28e8615a9 fixed failing tests 2026-03-03 11:53:04 +01:00
3d1157a5d3 Merge pull request 'improved logging in callbacks' (#606) from callbacks-604 into main
Some checks failed
ci/woodpecker/push/callbacks Pipeline was successful
ci/woodpecker/push/discovery Pipeline was successful
ci/woodpecker/push/billing_documents Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/gateway_chain Pipeline was successful
ci/woodpecker/push/gateway_tgsettle Pipeline was successful
ci/woodpecker/push/gateway_tron Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_methods Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline failed
ci/woodpecker/push/gateway_mntx Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/payments_quotation Pipeline was successful
Reviewed-on: #606
2026-03-03 00:08:01 +00:00
Stephan D
bae4cd6e35 improved logging in callbacks 2026-03-03 01:07:35 +01:00
bd79eb016a Merge pull request 'improved logging in callbacks' (#605) from callbacks-604 into main
Some checks failed
ci/woodpecker/push/callbacks Pipeline failed
Reviewed-on: #605
2026-03-03 00:07:03 +00:00
Stephan D
b10ec79fe0 improved logging in callbacks 2026-03-03 00:26:51 +01:00
4b57550c36 Merge pull request 'fixed front connection address' (#603) from front-600 into main
All checks were successful
ci/woodpecker/push/frontend Pipeline was successful
Reviewed-on: #603
2026-03-02 23:17:02 +00:00
Stephan D
0f0529c445 fixed front connection address 2026-03-03 00:16:37 +01:00
01c4108157 Merge pull request 'changed known policies enum' (#602) from front-600 into main
All checks were successful
ci/woodpecker/push/frontend Pipeline was successful
Reviewed-on: #602
2026-03-02 22:58:09 +00:00
Stephan D
3c6cffdf33 changed known policies enum 2026-03-02 23:57:27 +01:00
82bab11a8f Merge pull request 'changed known policies enum' (#601) from front-600 into main
Some checks failed
ci/woodpecker/push/frontend Pipeline failed
Reviewed-on: #601
2026-03-02 22:50:51 +00:00
Stephan D
2f77d9d972 changed known policies enum 2026-03-02 23:48:59 +01:00
7559d4d09b Merge pull request 'changed color theme to be black and added the ability to enter the amount in the recipient’s currency' (#597) from SEND060 into main
All checks were successful
ci/woodpecker/push/frontend Pipeline was successful
Reviewed-on: #597
2026-03-02 17:47:56 +00:00
a1e739ba52 Merge pull request 'Callbacks service docs updated' (#598) from callbacks-596 into main
All checks were successful
ci/woodpecker/push/billing_documents Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/callbacks Pipeline was successful
ci/woodpecker/push/discovery Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/gateway_mntx Pipeline was successful
ci/woodpecker/push/gateway_tgsettle Pipeline was successful
ci/woodpecker/push/gateway_tron Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/gateway_chain Pipeline was successful
ci/woodpecker/push/payments_methods Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_quotation Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #598
2026-03-02 15:28:26 +00:00
Stephan D
2be76aa519 Callbacks service docs updated 2026-03-02 16:27:33 +01:00
Arseni
6bb3ab5063 changed color theme to be black and added the ability to enter the amount in the recipient’s currency 2026-03-02 17:41:41 +03:00
17e08ff26f Merge pull request 'added service reannounce' (#595) from discovery-593 into main
All checks were successful
ci/woodpecker/push/billing_documents Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/callbacks Pipeline was successful
ci/woodpecker/push/discovery Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/gateway_chain Pipeline was successful
ci/woodpecker/push/gateway_mntx Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/gateway_tron Pipeline was successful
ci/woodpecker/push/gateway_tgsettle Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_methods Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/payments_quotation Pipeline was successful
Reviewed-on: #595
2026-03-01 13:47:10 +00:00
Stephan D
e5ba048c73 added service reannounce 2026-03-01 14:46:42 +01:00
ddd5e36275 Merge pull request 'added service reannounce' (#594) from discovery-593 into main
Some checks failed
ci/woodpecker/push/billing_documents Pipeline failed
ci/woodpecker/push/bff Pipeline failed
ci/woodpecker/push/billing_fees Pipeline failed
ci/woodpecker/push/discovery Pipeline failed
ci/woodpecker/push/fx_oracle Pipeline failed
ci/woodpecker/push/gateway_chain Pipeline failed
ci/woodpecker/push/gateway_tgsettle Pipeline is pending
ci/woodpecker/push/gateway_tron Pipeline is pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_methods Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/payments_quotation Pipeline is pending
ci/woodpecker/push/callbacks Pipeline failed
ci/woodpecker/push/fx_ingestor Pipeline failed
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/gateway_mntx Pipeline failed
Reviewed-on: #594
2026-03-01 13:02:46 +00:00
Stephan D
ce23de94ce added service reannounce 2026-03-01 14:02:05 +01:00
1005201bb7 Merge pull request 'fixed client id' (#592) from bff-591 into main
Some checks failed
ci/woodpecker/push/discovery Pipeline is pending
ci/woodpecker/push/frontend Pipeline is pending
ci/woodpecker/push/fx_ingestor Pipeline is pending
ci/woodpecker/push/fx_oracle Pipeline is pending
ci/woodpecker/push/gateway_chain Pipeline is pending
ci/woodpecker/push/gateway_mntx Pipeline is pending
ci/woodpecker/push/gateway_tgsettle Pipeline is pending
ci/woodpecker/push/gateway_tron Pipeline is pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_methods Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/payments_quotation Pipeline is pending
ci/woodpecker/push/billing_documents Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/billing_fees Pipeline failed
ci/woodpecker/push/callbacks Pipeline failed
Reviewed-on: #592
2026-03-01 12:40:31 +00:00
Stephan D
38077c1ed8 fixed client id 2026-03-01 13:40:02 +01:00
d0368f5a00 Merge pull request 'bff for callbacks' (#590) from bff-589 into main
All checks were successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/billing_documents Pipeline was successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/callbacks Pipeline was successful
ci/woodpecker/push/discovery Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/gateway_chain Pipeline was successful
ci/woodpecker/push/gateway_mntx Pipeline was successful
ci/woodpecker/push/gateway_tgsettle Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_methods Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/payments_quotation Pipeline was successful
ci/woodpecker/push/gateway_tron Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
Reviewed-on: #590
2026-03-01 01:04:50 +00:00
Stephan D
86eab3bb70 bff for callbacks 2026-03-01 02:04:15 +01:00
316 changed files with 7961 additions and 2145 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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"`
}

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -44,6 +44,7 @@ mcards:
request_timeout_seconds: 15
status_success: "success"
status_processing: "processing"
strict_operation_mode: false
gateway:
id: "mcards"

View File

@@ -44,6 +44,7 @@ mcards:
request_timeout_seconds: 15
status_success: "success"
status_processing: "processing"
strict_operation_mode: true
gateway:
id: "mcards"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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