81 Commits

Author SHA1 Message Date
318255405b Merge pull request 'Migration to TRON chain' (#130) from tron-130 into main
Some checks are pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/mntx_gateway Pipeline is pending
ci/woodpecker/push/nats Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/frontend Pipeline is running
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline is running
Reviewed-on: #130
2025-12-23 14:42:30 +00:00
Stephan D
19d4ee1d33 Migration to TRON chain 2025-12-23 15:42:07 +01:00
bc6a56c129 Merge pull request 'Implemented cooldown before User is able to resend confirmation code for 2fa' (#128) from SEND012 into main
All checks were successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db 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/ledger Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #128
2025-12-23 12:56:07 +00:00
Arseni
ec54579921 Implemented cooldown before User is able to resend confirmation code for 2fa 2025-12-23 14:56:47 +03:00
1ed76f7243 Merge pull request 'Fixed tokens revocation' (#127) from devid-122 into main
Some checks are pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/mntx_gateway Pipeline is pending
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline is running
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/frontend Pipeline is running
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #127
2025-12-22 20:25:03 +00:00
Stephan D
6527d183ec Fixed tokens revokation 2025-12-22 21:22:51 +01:00
41b0dec460 Merge pull request 'Fixes for Settings Page' (#123) from SEND011 into main
All checks were successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway 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/ledger Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #123
Reviewed-by: tech <tech.sendico@proton.me>
2025-12-22 19:26:44 +00:00
Arseni
d26ba84094 Moved the AccountName widgets to pull providers from context 2025-12-22 21:38:26 +03:00
Arseni
4073c8819c Fixed imports 2025-12-22 21:12:21 +03:00
Arseni
47ada0691c Fixes for Settings Page 2025-12-22 21:09:58 +03:00
97c67670e5 Merge pull request 'added fix for active indexed tokens + improved data structure for wallet description' (#122) from quotes-118 into main
All checks were successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway 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/ledger Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #122
2025-12-22 17:30:48 +00:00
Stephan D
dfad7fb335 added fix for active indexed tokens + improved data structure for wallet description 2025-12-22 18:30:15 +01:00
41abf723e6 Merge pull request 'multiquote service' (#117) from quotes-118 into main
All checks were successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/chain_gateway 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/nats Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #117
2025-12-17 20:56:28 +00:00
Stephan D
2d6586430f multiquote service 2025-12-17 21:56:07 +01:00
d649748f6f Merge pull request 'server endpoint' (#115) from quotes-115 into main
All checks were successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
Reviewed-on: #115
2025-12-17 17:15:40 +00:00
Stephan D
61177a4e30 server endpoint 2025-12-17 18:15:02 +01:00
c7b9b70d57 Merge pull request 'multiple quotes payment' (#114) from quotes-114 into main
All checks were successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway 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/ledger Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #114
2025-12-17 15:53:29 +00:00
Stephan D
5030453807 multiple quotes payment 2025-12-17 16:53:03 +01:00
5565081b69 Merge pull request 'PostHog last fixes hopefully' (#109) from SEND006 into main
All checks were successful
ci/woodpecker/push/chain_gateway 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/nats Pipeline was successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
Reviewed-on: #109
2025-12-17 12:22:33 +00:00
b216aa68b7 Merge pull request 'Empty state for Recipient Address Book' (#110) from SEND007 into main
Reviewed-on: #110
2025-12-17 11:48:07 +00:00
7ac1c519e3 Merge pull request 'Made clear massages for errors in recipient registration' (#111) from SEND008 into main
Some checks are pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/mntx_gateway Pipeline is pending
ci/woodpecker/push/nats Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline is running
ci/woodpecker/push/frontend Pipeline is running
ci/woodpecker/push/chain_gateway Pipeline was successful
Reviewed-on: #111
2025-12-17 11:13:16 +00:00
076b0c6434 Merge pull request 'Wallet update for correct name and symbol appearance' (#112) from SEND009 into main
Some checks failed
ci/woodpecker/push/bff Pipeline failed
ci/woodpecker/push/chain_gateway Pipeline is pending
ci/woodpecker/push/db 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/ledger Pipeline is pending
ci/woodpecker/push/mntx_gateway Pipeline is pending
ci/woodpecker/push/nats Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/billing_fees Pipeline failed
Reviewed-on: #112
2025-12-17 11:12:26 +00:00
Arseni
9a90e6a03b Wallet update for correct name and symbol appearance 2025-12-16 19:37:28 +03:00
Arseni
5218632c00 Made clear massages for errors in recipient registration 2025-12-16 18:42:57 +03:00
Arseni
a2c05745ad A 2025-12-16 18:21:49 +03:00
Arseni
82b2f88122 Changed the spelling of the word adress) 2025-12-12 19:39:36 +03:00
Arseni
28d74d058b Empty state for Recipient Adress Book 2025-12-12 19:29:10 +03:00
Arseni
6ee146b95a PostHog last fixes hopefully 2025-12-12 16:39:18 +03:00
67b52af150 Merge pull request 'fixed dropping of settlement mode' (#108) from settlement-106 into main
Some checks failed
ci/woodpecker/push/fx_oracle Pipeline is pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/mntx_gateway Pipeline is pending
ci/woodpecker/push/nats Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline failed
ci/woodpecker/push/frontend Pipeline failed
Reviewed-on: #108
2025-12-12 13:19:29 +00:00
Stephan D
058a3fefaf fixed dropping of settlement mode 2025-12-12 14:19:09 +01:00
c8a97d940c Merge pull request 'quotation rate display' (#105) from fees-104 into main
Some checks failed
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/nats Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
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/mntx_gateway Pipeline failed
ci/woodpecker/push/ledger Pipeline failed
Reviewed-on: #105
2025-12-12 12:46:21 +00:00
Stephan D
00045c1e65 quotation rate display 2025-12-12 13:45:58 +01:00
d64d7dab58 Merge pull request 'fixed quotation calculation logic' (#103) from fees-102 into main
Some checks failed
ci/woodpecker/push/chain_gateway Pipeline is pending
ci/woodpecker/push/db 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/ledger Pipeline is pending
ci/woodpecker/push/mntx_gateway Pipeline is pending
ci/woodpecker/push/nats Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/billing_fees Pipeline failed
ci/woodpecker/push/bff Pipeline failed
Reviewed-on: #103
2025-12-12 12:42:13 +00:00
Stephan D
4746a00eee fixed quotation calculation logic 2025-12-12 13:41:37 +01:00
3f8399d647 Merge pull request 'fixed fee polarity' (#101) from currency-100 into main
Some checks failed
ci/woodpecker/push/fx_oracle Pipeline is pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/mntx_gateway Pipeline is pending
ci/woodpecker/push/nats Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline failed
ci/woodpecker/push/frontend Pipeline failed
Reviewed-on: #101
2025-12-12 12:21:19 +00:00
Stephan D
028b29fe08 fixed fee polarity 2025-12-12 13:21:00 +01:00
cb3f59a9d5 Merge pull request 'fixed duplicated ISO codes parsing' (#99) from currency-99 into main
All checks were successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway 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/ledger Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #99
2025-12-12 10:42:07 +00:00
Stephan D
2b8d02d95c fixed duplicated ISO codes parsing 2025-12-12 11:41:45 +01:00
d90d8cda11 Merge pull request 'fx/ingestor currencies map fixed' (#98) from currencies-97 into main
Some checks failed
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway 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/ledger Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline failed
ci/woodpecker/push/notification Pipeline failed
Reviewed-on: #98
2025-12-12 10:05:52 +00:00
Stephan D
17333df7af fx/ingestor currencies map fixed 2025-12-12 11:05:12 +01:00
681d53e856 Merge pull request 'Fixed http client' (#96) from client-95 into main
All checks were successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway 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/ledger Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #96
2025-12-12 00:53:59 +00:00
Stephan D
dc608fd257 Fixed http client 2025-12-12 01:53:34 +01:00
cd2efdc2f3 Merge pull request 'improved fx/ingestor logging' (#93) from logging-92 into main
Some checks failed
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway 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/payments_orchestrator Pipeline failed
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
Reviewed-on: #93
2025-12-12 00:07:25 +00:00
Stephan D
fd47867101 improved fx/ingestor logging 2025-12-12 01:07:03 +01:00
2ca1a6956c Merge pull request 'fx/oracle logging' (#92) from logging-90 into main
Some checks failed
ci/woodpecker/push/chain_gateway Pipeline is pending
ci/woodpecker/push/db 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/ledger Pipeline is pending
ci/woodpecker/push/mntx_gateway Pipeline is pending
ci/woodpecker/push/nats Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/bff Pipeline failed
ci/woodpecker/push/billing_fees Pipeline failed
Reviewed-on: #92
2025-12-12 00:05:13 +00:00
Stephan D
a5ad4f4c3c fx/oracle logging 2025-12-12 00:44:04 +01:00
8b202e0c60 Merge pull request 'fixed currency validation logic' (#89) from currency-88 into main
All checks were successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway 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/ledger Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #89
2025-12-11 22:55:50 +00:00
Stephan D
4626d0a1a7 fixed currency validation logic 2025-12-11 23:55:04 +01:00
da72121109 Merge pull request 'ledger account reference removed' (#87) from fees-86 into main
Some checks failed
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/mntx_gateway Pipeline is pending
ci/woodpecker/push/nats Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline failed
ci/woodpecker/push/frontend Pipeline failed
Reviewed-on: #87
2025-12-11 22:32:26 +00:00
Stephan D
5bebadf17c ledger account reference removed 2025-12-11 23:30:42 +01:00
1bab0b14ef Merge pull request 'fixed currency pair validation' (#85) from currency-84 into main
Some checks failed
ci/woodpecker/push/chain_gateway Pipeline is pending
ci/woodpecker/push/db 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/ledger Pipeline is pending
ci/woodpecker/push/mntx_gateway Pipeline is pending
ci/woodpecker/push/nats Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/billing_fees Pipeline failed
ci/woodpecker/push/bff Pipeline failed
Reviewed-on: #85
2025-12-11 22:27:42 +00:00
Stephan D
39f323d050 fixed currency pair validation 2025-12-11 23:27:15 +01:00
7cd9e14618 Merge pull request 'logging-84' (#83) from logging-84 into main
All checks were successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/chain_gateway 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/ledger Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #83
2025-12-11 21:37:04 +00:00
Stephan D
b77d2c16ab improved logging 2025-12-11 22:25:51 +01:00
Stephan D
324f150950 improved logging 2025-12-11 22:25:04 +01:00
dd6bcf843c Merge pull request 'fixed payment orchestrator address' (#81) from connectivity-81 into main
Some checks failed
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway 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/ledger Pipeline was successful
ci/woodpecker/push/nats Pipeline failed
ci/woodpecker/push/mntx_gateway Pipeline failed
ci/woodpecker/push/notification Pipeline failed
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #81
2025-12-11 20:53:04 +00:00
Stephan D
874cc4971b fixed payment orchestrator address 2025-12-11 21:52:37 +01:00
bfe4695b2d Merge pull request 'config fix' (#80) from discovery-79 into main
Some checks failed
ci/woodpecker/push/fx_oracle Pipeline is pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/mntx_gateway Pipeline is pending
ci/woodpecker/push/nats Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline failed
ci/woodpecker/push/frontend Pipeline failed
Reviewed-on: #80
2025-12-11 20:39:07 +00:00
Stephan D
99161c8e7d config fix 2025-12-11 21:38:32 +01:00
6901791dd2 Merge pull request 'default currency resolver' (#78) from currency-76 into main
Some checks failed
ci/woodpecker/push/fx_oracle Pipeline is pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/mntx_gateway Pipeline is pending
ci/woodpecker/push/nats Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline failed
ci/woodpecker/push/frontend Pipeline failed
Reviewed-on: #78
2025-12-11 20:24:10 +00:00
Stephan D
acb3d14b47 default currency resolver 2025-12-11 21:23:35 +01:00
aa5f7e271e Merge pull request 'fix currencies validation' (#76) from currencies-75 into main
Some checks failed
ci/woodpecker/push/fx_oracle Pipeline is pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/mntx_gateway Pipeline is pending
ci/woodpecker/push/nats Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline failed
ci/woodpecker/push/frontend Pipeline failed
Reviewed-on: #76
2025-12-11 20:06:22 +00:00
Stephan D
0a01995f53 fix currencies validation 2025-12-11 21:05:43 +01:00
97f71d125e Merge pull request 'removed deprecation warnings' (#71) from deprecation-70 into main
All checks were successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway 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/ledger Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
Reviewed-on: #71
2025-12-11 10:23:12 +00:00
Stephan D
8db2f3926c deprecation fixed 2025-12-11 11:22:51 +01:00
Stephan D
2b68b59eca removed deprecation warnings 2025-12-11 11:11:54 +01:00
d07e64fc4f Merge pull request 'fix fx/oracle compilation' (#68) from bug-66 into main
Some checks failed
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway 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/ledger Pipeline was successful
ci/woodpecker/push/nats Pipeline failed
ci/woodpecker/push/mntx_gateway Pipeline failed
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #68
2025-12-11 09:36:50 +00:00
Stephan D
8e40e6247b fix fx/oracle compilation 2025-12-11 10:36:31 +01:00
779cb0ead9 Merge pull request 'fix' (#65) from bug-64 into main
Some checks failed
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline failed
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #65
2025-12-11 00:45:07 +00:00
Stephan D
2e0057f839 fix 2025-12-11 01:44:40 +01:00
25080ae168 Merge pull request 'fix' (#63) from bug-62 into main
Some checks failed
ci/woodpecker/push/bff Pipeline failed
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline is pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/mntx_gateway Pipeline is pending
ci/woodpecker/push/nats Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/fx_ingestor Pipeline failed
ci/woodpecker/push/frontend Pipeline failed
Reviewed-on: #63
2025-12-11 00:31:19 +00:00
Stephan D
e6b001dc61 fix 2025-12-11 01:30:28 +01:00
97d1470515 Merge pull request '+ quotation provider' (#60) from quote-front-59 into main
Some checks failed
ci/woodpecker/push/fx_oracle Pipeline is pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/mntx_gateway Pipeline is pending
ci/woodpecker/push/nats Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline failed
ci/woodpecker/push/frontend Pipeline failed
Reviewed-on: #60
2025-12-11 00:13:35 +00:00
Stephan D
a4481fb63d + quotation provider 2025-12-11 01:13:13 +01:00
bdf766075e Merge pull request 'payment rails' (#58) from payment-service-52 into main
Some checks failed
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline failed
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
Reviewed-on: #58
2025-12-10 17:50:38 +00:00
Stephan D
47899e25d4 payment rails 2025-12-10 18:40:55 +01:00
4ec934c96b Merge pull request 'fixed CORS wildcard' (#55) from CORS-#54 into main
Some checks failed
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/frontend Pipeline failed
ci/woodpecker/push/fx_ingestor Pipeline failed
ci/woodpecker/push/payments_orchestrator Pipeline failed
ci/woodpecker/push/fx_oracle Pipeline failed
ci/woodpecker/push/ledger Pipeline failed
ci/woodpecker/push/mntx_gateway Pipeline failed
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/notification Pipeline failed
ci/woodpecker/push/chain_gateway Pipeline was successful
Reviewed-on: #55
2025-12-09 19:00:01 +00:00
Stephan D
19df740550 fixed CORS wildcard 2025-12-09 19:59:33 +01:00
1079ad7d0a Merge pull request 'Organizations now load only once' (#38) from SEND002 into main
Some checks failed
ci/woodpecker/push/db 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/ledger Pipeline is pending
ci/woodpecker/push/mntx_gateway Pipeline is pending
ci/woodpecker/push/nats Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline failed
ci/woodpecker/push/bff Pipeline failed
Reviewed-on: #38
2025-12-09 18:53:13 +00:00
Arseni
660f689a7a Current org now sets after list gets to the state of the provider 2025-12-09 16:15:36 +03:00
Arseni
8115abb569 Organizations now load only once 2025-12-08 19:10:33 +03:00
304 changed files with 9942 additions and 2277 deletions

4
.gitignore vendored
View File

@@ -8,4 +8,6 @@ devtools_options.yaml
untranslated.txt untranslated.txt
generate_protos.sh generate_protos.sh
update_dep.sh update_dep.sh
.vscode/ .vscode/
.gocache/
.cache/

View File

@@ -11,7 +11,7 @@ require (
github.com/tech/sendico/pkg v0.1.0 github.com/tech/sendico/pkg v0.1.0
go.mongodb.org/mongo-driver v1.17.6 go.mongodb.org/mongo-driver v1.17.6
go.uber.org/zap v1.27.1 go.uber.org/zap v1.27.1
google.golang.org/grpc v1.77.0 google.golang.org/grpc v1.78.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
@@ -31,7 +31,7 @@ require (
github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/montanaflynn/stats v0.7.1 // indirect github.com/montanaflynn/stats v0.7.1 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nats-io/nats.go v1.47.0 // indirect github.com/nats-io/nats.go v1.48.0 // indirect
github.com/nats-io/nkeys v0.4.12 // indirect github.com/nats-io/nkeys v0.4.12 // indirect
github.com/nats-io/nuid v1.0.1 // indirect github.com/nats-io/nuid v1.0.1 // indirect
github.com/prometheus/client_golang v1.23.2 github.com/prometheus/client_golang v1.23.2
@@ -49,6 +49,6 @@ require (
golang.org/x/sync v0.19.0 // indirect golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect golang.org/x/text v0.32.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
google.golang.org/protobuf v1.36.10 google.golang.org/protobuf v1.36.11
) )

View File

@@ -95,8 +95,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nats-io/nats.go v1.47.0 h1:YQdADw6J/UfGUd2Oy6tn4Hq6YHxCaJrVKayxxFqYrgM= github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
github.com/nats-io/nats.go v1.47.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc= github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg= github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
@@ -212,12 +212,12 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

View File

@@ -90,10 +90,6 @@ func (c *quoteCalculator) Compute(ctx context.Context, plan *model.FeePlan, inte
} }
ledgerAccountRef := strings.TrimSpace(rule.LedgerAccountRef) ledgerAccountRef := strings.TrimSpace(rule.LedgerAccountRef)
if ledgerAccountRef == "" {
c.logger.Warn("fee rule missing ledger account reference", zap.String("rule_id", rule.RuleID))
continue
}
amount, scale, calcErr := c.calculateRuleAmount(baseAmount, baseScale, rule) amount, scale, calcErr := c.calculateRuleAmount(baseAmount, baseScale, rule)
if calcErr != nil { if calcErr != nil {
@@ -113,7 +109,8 @@ func (c *quoteCalculator) Compute(ctx context.Context, plan *model.FeePlan, inte
entrySide := mapEntrySide(rule.EntrySide) entrySide := mapEntrySide(rule.EntrySide)
if entrySide == accountingv1.EntrySide_ENTRY_SIDE_UNSPECIFIED { if entrySide == accountingv1.EntrySide_ENTRY_SIDE_UNSPECIFIED {
entrySide = accountingv1.EntrySide_ENTRY_SIDE_CREDIT // Default fees to debit (i.e. charge the customer) when entry side is not specified.
entrySide = accountingv1.EntrySide_ENTRY_SIDE_DEBIT
} }
meta := map[string]string{ meta := map[string]string{

View File

@@ -38,23 +38,23 @@ func New(plans storage.PlansStore, logger *zap.Logger) *feeResolver {
} }
} }
func (r *feeResolver) ResolveFeeRule(ctx context.Context, orgID *primitive.ObjectID, trigger model.Trigger, at time.Time, attrs map[string]string) (*model.FeePlan, *model.FeeRule, error) { func (r *feeResolver) ResolveFeeRule(ctx context.Context, orgRef *primitive.ObjectID, trigger model.Trigger, at time.Time, attrs map[string]string) (*model.FeePlan, *model.FeeRule, error) {
if r.plans == nil { if r.plans == nil {
return nil, nil, merrors.InvalidArgument("fees: plans store is required") return nil, nil, merrors.InvalidArgument("fees: plans store is required")
} }
// Try org-specific first if provided. // Try org-specific first if provided.
if orgID != nil && !orgID.IsZero() { if orgRef != nil && !orgRef.IsZero() {
if plan, err := r.getOrgPlan(ctx, *orgID, at); err == nil { if plan, err := r.getOrgPlan(ctx, *orgRef, at); err == nil {
if rule, selErr := selectRule(plan, trigger, at, attrs); selErr == nil { if rule, selErr := selectRule(plan, trigger, at, attrs); selErr == nil {
return plan, rule, nil return plan, rule, nil
} else if !errors.Is(selErr, ErrNoFeeRuleFound) { } else if !errors.Is(selErr, ErrNoFeeRuleFound) {
r.logger.Warn("failed selecting rule for org plan", zap.Error(selErr), zap.String("org_ref", orgID.Hex())) r.logger.Warn("failed selecting rule for org plan", zap.Error(selErr), zap.String("org_ref", orgRef.Hex()))
return nil, nil, selErr return nil, nil, selErr
} }
r.logger.Debug("no matching rule in org plan; falling back to global", zap.String("org_ref", orgID.Hex())) r.logger.Debug("no matching rule in org plan; falling back to global", zap.String("org_ref", orgRef.Hex()))
} else if !errors.Is(err, storage.ErrFeePlanNotFound) { } else if !errors.Is(err, storage.ErrFeePlanNotFound) {
r.logger.Warn("failed resolving org fee plan", zap.Error(err), zap.String("org_ref", orgID.Hex())) r.logger.Warn("failed resolving org fee plan", zap.Error(err), zap.String("org_ref", orgRef.Hex()))
return nil, nil, err return nil, nil, err
} }
} }

View File

@@ -32,8 +32,8 @@ func TestResolver_GlobalFallbackWhenOrgMissing(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("expected fallback to global, got error: %v", err) t.Fatalf("expected fallback to global, got error: %v", err)
} }
if !plan.GetOrganizationRef().IsZero() { if plan.OrganizationRef != nil && !plan.OrganizationRef.IsZero() {
t.Fatalf("expected global plan, got orgRef %s", plan.GetOrganizationRef().Hex()) t.Fatalf("expected global plan, got orgRef %s", plan.OrganizationRef.Hex())
} }
if rule.RuleID != "global_capture" { if rule.RuleID != "global_capture" {
t.Fatalf("unexpected rule selected: %s", rule.RuleID) t.Fatalf("unexpected rule selected: %s", rule.RuleID)
@@ -59,8 +59,7 @@ func TestResolver_OrgOverridesGlobal(t *testing.T) {
{RuleID: "org_capture", Trigger: model.TriggerCapture, Priority: 10, Percentage: "0.03", EffectiveFrom: now.Add(-time.Hour)}, {RuleID: "org_capture", Trigger: model.TriggerCapture, Priority: 10, Percentage: "0.03", EffectiveFrom: now.Add(-time.Hour)},
}, },
} }
orgPlan.SetOrganizationRef(org) orgPlan.OrganizationRef = &org
store := &memoryPlansStore{plans: []*model.FeePlan{globalPlan, orgPlan}} store := &memoryPlansStore{plans: []*model.FeePlan{globalPlan, orgPlan}}
resolver := New(store, zap.NewNop()) resolver := New(store, zap.NewNop())
@@ -95,7 +94,7 @@ func TestResolver_SelectsHighestPriority(t *testing.T) {
{RuleID: "high", Trigger: model.TriggerCapture, Priority: 200, Percentage: "0.03", EffectiveFrom: now.Add(-time.Hour)}, {RuleID: "high", Trigger: model.TriggerCapture, Priority: 200, Percentage: "0.03", EffectiveFrom: now.Add(-time.Hour)},
}, },
} }
plan.SetOrganizationRef(org) plan.OrganizationRef = &org
store := &memoryPlansStore{plans: []*model.FeePlan{plan}} store := &memoryPlansStore{plans: []*model.FeePlan{plan}}
resolver := New(store, zap.NewNop()) resolver := New(store, zap.NewNop())
@@ -136,7 +135,7 @@ func TestResolver_EffectiveDateFiltering(t *testing.T) {
{RuleID: "expired", Trigger: model.TriggerCapture, Priority: 100, Percentage: "0.05", EffectiveFrom: past, EffectiveTo: &past}, {RuleID: "expired", Trigger: model.TriggerCapture, Priority: 100, Percentage: "0.05", EffectiveFrom: past, EffectiveTo: &past},
}, },
} }
orgPlan.SetOrganizationRef(org) orgPlan.OrganizationRef = &org
globalPlan := &model.FeePlan{ globalPlan := &model.FeePlan{
Active: true, Active: true,
@@ -221,7 +220,7 @@ func TestResolver_MultipleActivePlansConflict(t *testing.T) {
{RuleID: "r1", Trigger: model.TriggerCapture, Priority: 10, Percentage: "0.05", EffectiveFrom: now.Add(-time.Hour)}, {RuleID: "r1", Trigger: model.TriggerCapture, Priority: 10, Percentage: "0.05", EffectiveFrom: now.Add(-time.Hour)},
}, },
} }
p1.SetOrganizationRef(org) p1.OrganizationRef = &org
p2 := &model.FeePlan{ p2 := &model.FeePlan{
Active: true, Active: true,
EffectiveFrom: now.Add(-30 * time.Minute), EffectiveFrom: now.Add(-30 * time.Minute),
@@ -229,7 +228,7 @@ func TestResolver_MultipleActivePlansConflict(t *testing.T) {
{RuleID: "r2", Trigger: model.TriggerCapture, Priority: 20, Percentage: "0.03", EffectiveFrom: now.Add(-time.Hour)}, {RuleID: "r2", Trigger: model.TriggerCapture, Priority: 20, Percentage: "0.03", EffectiveFrom: now.Add(-time.Hour)},
}, },
} }
p2.SetOrganizationRef(org) p2.OrganizationRef = &org
store := &memoryPlansStore{plans: []*model.FeePlan{p1, p2}} store := &memoryPlansStore{plans: []*model.FeePlan{p1, p2}}
resolver := New(store, zap.NewNop()) resolver := New(store, zap.NewNop())
@@ -263,7 +262,7 @@ func (m *memoryPlansStore) GetActivePlan(ctx context.Context, orgRef primitive.O
func (m *memoryPlansStore) FindActiveOrgPlan(_ context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error) { func (m *memoryPlansStore) FindActiveOrgPlan(_ context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error) {
var matches []*model.FeePlan var matches []*model.FeePlan
for _, plan := range m.plans { for _, plan := range m.plans {
if plan == nil || plan.GetOrganizationRef() != orgRef { if plan == nil || plan.OrganizationRef == nil || plan.OrganizationRef.IsZero() || (*plan.OrganizationRef != orgRef) {
continue continue
} }
if !plan.Active { if !plan.Active {
@@ -289,7 +288,7 @@ func (m *memoryPlansStore) FindActiveOrgPlan(_ context.Context, orgRef primitive
func (m *memoryPlansStore) FindActiveGlobalPlan(_ context.Context, at time.Time) (*model.FeePlan, error) { func (m *memoryPlansStore) FindActiveGlobalPlan(_ context.Context, at time.Time) (*model.FeePlan, error) {
var matches []*model.FeePlan var matches []*model.FeePlan
for _, plan := range m.plans { for _, plan := range m.plans {
if plan == nil || !plan.GetOrganizationRef().IsZero() { if plan == nil || ((plan.OrganizationRef != nil) && !plan.OrganizationRef.IsZero()) {
continue continue
} }
if !plan.Active { if !plan.Active {

View File

@@ -0,0 +1,88 @@
package fees
import (
"strings"
"time"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
"go.uber.org/zap"
)
func requestLogFields(meta *feesv1.RequestMeta, intent *feesv1.Intent) []zap.Field {
fields := logFieldsFromRequestMeta(meta)
fields = append(fields, logFieldsFromIntent(intent)...)
return fields
}
func logFieldsFromRequestMeta(meta *feesv1.RequestMeta) []zap.Field {
if meta == nil {
return nil
}
fields := make([]zap.Field, 0, 4)
if org := strings.TrimSpace(meta.GetOrganizationRef()); org != "" {
fields = append(fields, zap.String("organization_ref", org))
}
fields = append(fields, logFieldsFromTrace(meta.GetTrace())...)
return fields
}
func logFieldsFromIntent(intent *feesv1.Intent) []zap.Field {
if intent == nil {
return nil
}
fields := make([]zap.Field, 0, 5)
if trigger := intent.GetTrigger(); trigger != feesv1.Trigger_TRIGGER_UNSPECIFIED {
fields = append(fields, zap.String("trigger", trigger.String()))
}
if base := intent.GetBaseAmount(); base != nil {
if amount := strings.TrimSpace(base.GetAmount()); amount != "" {
fields = append(fields, zap.String("base_amount", amount))
}
if currency := strings.TrimSpace(base.GetCurrency()); currency != "" {
fields = append(fields, zap.String("base_currency", currency))
}
}
if booked := intent.GetBookedAt(); booked != nil && booked.IsValid() {
fields = append(fields, zap.Time("booked_at", booked.AsTime()))
}
if attrs := intent.GetAttributes(); len(attrs) > 0 {
fields = append(fields, zap.Int("attributes_count", len(attrs)))
}
return fields
}
func logFieldsFromTrace(trace *tracev1.TraceContext) []zap.Field {
if trace == nil {
return nil
}
fields := make([]zap.Field, 0, 3)
if reqRef := strings.TrimSpace(trace.GetRequestRef()); reqRef != "" {
fields = append(fields, zap.String("request_ref", reqRef))
}
if idem := strings.TrimSpace(trace.GetIdempotencyKey()); idem != "" {
fields = append(fields, zap.String("idempotency_key", idem))
}
if traceRef := strings.TrimSpace(trace.GetTraceRef()); traceRef != "" {
fields = append(fields, zap.String("trace_ref", traceRef))
}
return fields
}
func logFieldsFromTokenPayload(payload *feeQuoteTokenPayload) []zap.Field {
if payload == nil {
return nil
}
fields := make([]zap.Field, 0, 6)
if org := strings.TrimSpace(payload.OrganizationRef); org != "" {
fields = append(fields, zap.String("organization_ref", org))
}
if payload.ExpiresAtUnixMs > 0 {
fields = append(fields,
zap.Int64("expires_at_unix_ms", payload.ExpiresAtUnixMs),
zap.Time("expires_at", time.UnixMilli(payload.ExpiresAtUnixMs)))
}
fields = append(fields, logFieldsFromIntent(payload.Intent)...)
fields = append(fields, logFieldsFromTrace(payload.Trace)...)
return fields
}

View File

@@ -72,26 +72,57 @@ func (s *Service) Register(router routers.GRPC) error {
} }
func (s *Service) QuoteFees(ctx context.Context, req *feesv1.QuoteFeesRequest) (resp *feesv1.QuoteFeesResponse, err error) { func (s *Service) QuoteFees(ctx context.Context, req *feesv1.QuoteFeesRequest) (resp *feesv1.QuoteFeesResponse, err error) {
var (
meta *feesv1.RequestMeta
intent *feesv1.Intent
)
if req != nil {
meta = req.GetMeta()
intent = req.GetIntent()
}
logger := s.logger.With(requestLogFields(meta, intent)...)
start := s.clock.Now() start := s.clock.Now()
trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED
if req != nil && req.GetIntent() != nil { if intent != nil {
trigger = req.GetIntent().GetTrigger() trigger = intent.GetTrigger()
} }
var fxUsed bool var fxUsed bool
defer func() { defer func() {
statusLabel := statusFromError(err) statusLabel := statusFromError(err)
linesCount := 0
appliedCount := 0
if err == nil && resp != nil { if err == nil && resp != nil {
fxUsed = resp.GetFxUsed() != nil fxUsed = resp.GetFxUsed() != nil
linesCount = len(resp.GetLines())
appliedCount = len(resp.GetApplied())
} }
observeMetrics("quote", trigger, statusLabel, fxUsed, time.Since(start)) observeMetrics("quote", trigger, statusLabel, fxUsed, time.Since(start))
logFields := []zap.Field{
zap.String("status", statusLabel),
zap.Duration("duration", time.Since(start)),
zap.Bool("fx_used", fxUsed),
zap.String("trigger", trigger.String()),
zap.Int("lines", linesCount),
zap.Int("applied_rules", appliedCount),
}
if err != nil {
logger.Warn("QuoteFees finished", append(logFields, zap.Error(err))...)
return
}
logger.Info("QuoteFees finished", logFields...)
}() }()
logger.Debug("QuoteFees request received")
if err = s.validateQuoteRequest(req); err != nil { if err = s.validateQuoteRequest(req); err != nil {
return nil, err return nil, err
} }
orgRef, parseErr := primitive.ObjectIDFromHex(req.GetMeta().GetOrganizationRef()) orgRef, parseErr := primitive.ObjectIDFromHex(req.GetMeta().GetOrganizationRef())
if parseErr != nil { if parseErr != nil {
logger.Warn("QuoteFees invalid organization_ref", zap.Error(parseErr))
err = status.Error(codes.InvalidArgument, "invalid organization_ref") err = status.Error(codes.InvalidArgument, "invalid organization_ref")
return nil, err return nil, err
} }
@@ -112,20 +143,59 @@ func (s *Service) QuoteFees(ctx context.Context, req *feesv1.QuoteFeesRequest) (
} }
func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFeesRequest) (resp *feesv1.PrecomputeFeesResponse, err error) { func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFeesRequest) (resp *feesv1.PrecomputeFeesResponse, err error) {
var (
meta *feesv1.RequestMeta
intent *feesv1.Intent
)
if req != nil {
meta = req.GetMeta()
intent = req.GetIntent()
}
logger := s.logger.With(requestLogFields(meta, intent)...)
start := s.clock.Now() start := s.clock.Now()
trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED
if req != nil && req.GetIntent() != nil { if intent != nil {
trigger = req.GetIntent().GetTrigger() trigger = intent.GetTrigger()
} }
var fxUsed bool var (
fxUsed bool
expiresAt time.Time
)
defer func() { defer func() {
statusLabel := statusFromError(err) statusLabel := statusFromError(err)
linesCount := 0
appliedCount := 0
if err == nil && resp != nil { if err == nil && resp != nil {
fxUsed = resp.GetFxUsed() != nil fxUsed = resp.GetFxUsed() != nil
linesCount = len(resp.GetLines())
appliedCount = len(resp.GetApplied())
if ts := resp.GetExpiresAt(); ts != nil {
expiresAt = ts.AsTime()
}
} }
observeMetrics("precompute", trigger, statusLabel, fxUsed, time.Since(start)) observeMetrics("precompute", trigger, statusLabel, fxUsed, time.Since(start))
logFields := []zap.Field{
zap.String("status", statusLabel),
zap.Duration("duration", time.Since(start)),
zap.Bool("fx_used", fxUsed),
zap.String("trigger", trigger.String()),
zap.Int("lines", linesCount),
zap.Int("applied_rules", appliedCount),
}
if !expiresAt.IsZero() {
logFields = append(logFields, zap.Time("expires_at", expiresAt))
}
if err != nil {
logger.Warn("PrecomputeFees finished", append(logFields, zap.Error(err))...)
return
}
logger.Info("PrecomputeFees finished", logFields...)
}() }()
logger.Debug("PrecomputeFees request received")
if err = s.validatePrecomputeRequest(req); err != nil { if err = s.validatePrecomputeRequest(req); err != nil {
return nil, err return nil, err
} }
@@ -134,6 +204,7 @@ func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFees
orgRef, parseErr := primitive.ObjectIDFromHex(req.GetMeta().GetOrganizationRef()) orgRef, parseErr := primitive.ObjectIDFromHex(req.GetMeta().GetOrganizationRef())
if parseErr != nil { if parseErr != nil {
logger.Warn("PrecomputeFees invalid organization_ref", zap.Error(parseErr))
err = status.Error(codes.InvalidArgument, "invalid organization_ref") err = status.Error(codes.InvalidArgument, "invalid organization_ref")
return nil, err return nil, err
} }
@@ -148,7 +219,7 @@ func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFees
if ttl <= 0 { if ttl <= 0 {
ttl = 60000 ttl = 60000
} }
expiresAt := now.Add(time.Duration(ttl) * time.Millisecond) expiresAt = now.Add(time.Duration(ttl) * time.Millisecond)
payload := feeQuoteTokenPayload{ payload := feeQuoteTokenPayload{
OrganizationRef: req.GetMeta().GetOrganizationRef(), OrganizationRef: req.GetMeta().GetOrganizationRef(),
@@ -159,7 +230,7 @@ func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFees
var token string var token string
if token, err = encodeTokenPayload(payload); err != nil { if token, err = encodeTokenPayload(payload); err != nil {
s.logger.Warn("failed to encode fee quote token", zap.Error(err)) logger.Warn("failed to encode fee quote token", zap.Error(err))
err = status.Error(codes.Internal, "failed to encode fee quote token") err = status.Error(codes.Internal, "failed to encode fee quote token")
return nil, err return nil, err
} }
@@ -176,9 +247,18 @@ func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFees
} }
func (s *Service) ValidateFeeToken(ctx context.Context, req *feesv1.ValidateFeeTokenRequest) (resp *feesv1.ValidateFeeTokenResponse, err error) { func (s *Service) ValidateFeeToken(ctx context.Context, req *feesv1.ValidateFeeTokenRequest) (resp *feesv1.ValidateFeeTokenResponse, err error) {
tokenLen := 0
if req != nil {
tokenLen = len(strings.TrimSpace(req.GetFeeQuoteToken()))
}
logger := s.logger.With(zap.Int("token_length", tokenLen))
start := s.clock.Now() start := s.clock.Now()
trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED
var fxUsed bool var (
fxUsed bool
resultReason string
)
defer func() { defer func() {
statusLabel := statusFromError(err) statusLabel := statusFromError(err)
if err == nil && resp != nil { if err == nil && resp != nil {
@@ -191,9 +271,28 @@ func (s *Service) ValidateFeeToken(ctx context.Context, req *feesv1.ValidateFeeT
} }
} }
observeMetrics("validate", trigger, statusLabel, fxUsed, time.Since(start)) observeMetrics("validate", trigger, statusLabel, fxUsed, time.Since(start))
logFields := []zap.Field{
zap.String("status", statusLabel),
zap.Duration("duration", time.Since(start)),
zap.Bool("fx_used", fxUsed),
zap.String("trigger", trigger.String()),
zap.Bool("valid", resp != nil && resp.GetValid()),
}
if resultReason != "" {
logFields = append(logFields, zap.String("reason", resultReason))
}
if err != nil {
logger.Warn("ValidateFeeToken finished", append(logFields, zap.Error(err))...)
return
}
logger.Info("ValidateFeeToken finished", logFields...)
}() }()
logger.Debug("ValidateFeeToken request received")
if req == nil || strings.TrimSpace(req.GetFeeQuoteToken()) == "" { if req == nil || strings.TrimSpace(req.GetFeeQuoteToken()) == "" {
resultReason = "missing_token"
err = status.Error(codes.InvalidArgument, "fee_quote_token is required") err = status.Error(codes.InvalidArgument, "fee_quote_token is required")
return nil, err return nil, err
} }
@@ -202,21 +301,29 @@ func (s *Service) ValidateFeeToken(ctx context.Context, req *feesv1.ValidateFeeT
payload, decodeErr := decodeTokenPayload(req.GetFeeQuoteToken()) payload, decodeErr := decodeTokenPayload(req.GetFeeQuoteToken())
if decodeErr != nil { if decodeErr != nil {
s.logger.Warn("failed to decode fee quote token", zap.Error(decodeErr)) resultReason = "invalid_token"
logger.Warn("failed to decode fee quote token", zap.Error(decodeErr))
resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "invalid_token"} resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "invalid_token"}
return resp, nil return resp, nil
} }
trigger = payload.Intent.GetTrigger() logger = logger.With(logFieldsFromTokenPayload(&payload)...)
if payload.Intent != nil {
trigger = payload.Intent.GetTrigger()
}
if now.UnixMilli() > payload.ExpiresAtUnixMs { if now.UnixMilli() > payload.ExpiresAtUnixMs {
resultReason = "expired"
logger.Info("fee quote token expired")
resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "expired"} resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "expired"}
return resp, nil return resp, nil
} }
orgRef, parseErr := primitive.ObjectIDFromHex(payload.OrganizationRef) orgRef, parseErr := primitive.ObjectIDFromHex(payload.OrganizationRef)
if parseErr != nil { if parseErr != nil {
s.logger.Warn("token contained invalid organization reference", zap.Error(parseErr)) resultReason = "invalid_token"
logger.Warn("token contained invalid organization reference", zap.Error(parseErr))
resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "invalid_token"} resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "invalid_token"}
return resp, nil return resp, nil
} }
@@ -280,6 +387,16 @@ func (s *Service) computeQuoteWithTime(ctx context.Context, orgRef primitive.Obj
bookedAt = intent.GetBookedAt().AsTime() bookedAt = intent.GetBookedAt().AsTime()
} }
logFields := []zap.Field{
zap.Time("booked_at_used", bookedAt),
}
if !orgRef.IsZero() {
logFields = append(logFields, zap.String("organization_ref", orgRef.Hex()))
}
logFields = append(logFields, logFieldsFromIntent(intent)...)
logFields = append(logFields, logFieldsFromTrace(trace)...)
logger := s.logger.With(logFields...)
var orgPtr *primitive.ObjectID var orgPtr *primitive.ObjectID
if !orgRef.IsZero() { if !orgRef.IsZero() {
orgPtr = &orgRef orgPtr = &orgRef
@@ -288,7 +405,7 @@ func (s *Service) computeQuoteWithTime(ctx context.Context, orgRef primitive.Obj
plan, rule, err := s.resolver.ResolveFeeRule(ctx, orgPtr, convertTrigger(intent.GetTrigger()), bookedAt, intent.GetAttributes()) plan, rule, err := s.resolver.ResolveFeeRule(ctx, orgPtr, convertTrigger(intent.GetTrigger()), bookedAt, intent.GetAttributes())
if err != nil { if err != nil {
switch { switch {
case errors.Is(err, merrors.ErrNoData): case errors.Is(err, merrors.ErrNoData):
return nil, nil, nil, status.Error(codes.NotFound, "fee rule not found") return nil, nil, nil, status.Error(codes.NotFound, "fee rule not found")
case errors.Is(err, merrors.ErrDataConflict): case errors.Is(err, merrors.ErrDataConflict):
return nil, nil, nil, status.Error(codes.FailedPrecondition, "conflicting fee rules") return nil, nil, nil, status.Error(codes.FailedPrecondition, "conflicting fee rules")
@@ -297,7 +414,7 @@ func (s *Service) computeQuoteWithTime(ctx context.Context, orgRef primitive.Obj
case errors.Is(err, storage.ErrFeePlanNotFound): case errors.Is(err, storage.ErrFeePlanNotFound):
return nil, nil, nil, status.Error(codes.NotFound, "fee plan not found") return nil, nil, nil, status.Error(codes.NotFound, "fee plan not found")
default: default:
s.logger.Warn("failed to resolve fee rule", zap.Error(err)) logger.Warn("failed to resolve fee rule", zap.Error(err))
return nil, nil, nil, status.Error(codes.Internal, "failed to resolve fee rule") return nil, nil, nil, status.Error(codes.Internal, "failed to resolve fee rule")
} }
} }
@@ -313,7 +430,7 @@ func (s *Service) computeQuoteWithTime(ctx context.Context, orgRef primitive.Obj
if errors.Is(calcErr, merrors.ErrInvalidArg) { if errors.Is(calcErr, merrors.ErrInvalidArg) {
return nil, nil, nil, status.Error(codes.InvalidArgument, calcErr.Error()) return nil, nil, nil, status.Error(codes.InvalidArgument, calcErr.Error())
} }
s.logger.Warn("failed to compute fee quote", zap.Error(calcErr)) logger.Warn("failed to compute fee quote", zap.Error(calcErr))
return nil, nil, nil, status.Error(codes.Internal, "failed to compute fee quote") return nil, nil, nil, status.Error(codes.Internal, "failed to compute fee quote")
} }

View File

@@ -49,7 +49,7 @@ func TestQuoteFees_ComputesDerivedLines(t *testing.T) {
}, },
} }
plan.SetID(primitive.NewObjectID()) plan.SetID(primitive.NewObjectID())
plan.SetOrganizationRef(orgRef) plan.OrganizationRef = &orgRef
service := NewService( service := NewService(
zap.NewNop(), zap.NewNop(),
@@ -163,7 +163,7 @@ func TestQuoteFees_FiltersByAttributesAndDates(t *testing.T) {
}, },
} }
plan.SetID(primitive.NewObjectID()) plan.SetID(primitive.NewObjectID())
plan.SetOrganizationRef(orgRef) plan.OrganizationRef = &orgRef
service := NewService( service := NewService(
zap.NewNop(), zap.NewNop(),
@@ -224,7 +224,7 @@ func TestQuoteFees_RoundingDown(t *testing.T) {
}, },
} }
plan.SetID(primitive.NewObjectID()) plan.SetID(primitive.NewObjectID())
plan.SetOrganizationRef(orgRef) plan.OrganizationRef = &orgRef
service := NewService( service := NewService(
zap.NewNop(), zap.NewNop(),
@@ -277,7 +277,7 @@ func TestQuoteFees_UsesInjectedCalculator(t *testing.T) {
}, },
} }
plan.SetID(primitive.NewObjectID()) plan.SetID(primitive.NewObjectID())
plan.SetOrganizationRef(orgRef) plan.OrganizationRef = &orgRef
result := &types.CalculationResult{ result := &types.CalculationResult{
Lines: []*feesv1.DerivedPostingLine{ Lines: []*feesv1.DerivedPostingLine{
@@ -353,7 +353,7 @@ func TestQuoteFees_PopulatesFxUsed(t *testing.T) {
}, },
} }
plan.SetID(primitive.NewObjectID()) plan.SetID(primitive.NewObjectID())
plan.SetOrganizationRef(orgRef) plan.OrganizationRef = &orgRef
fakeOracle := &oracleclient.Fake{ fakeOracle := &oracleclient.Fake{
LatestRateFn: func(ctx context.Context, req oracleclient.LatestRateParams) (*oracleclient.RateSnapshot, error) { LatestRateFn: func(ctx context.Context, req oracleclient.LatestRateParams) (*oracleclient.RateSnapshot, error) {
@@ -452,7 +452,7 @@ func (s *stubPlansStore) FindActiveOrgPlan(_ context.Context, orgRef primitive.O
if s.plan == nil { if s.plan == nil {
return nil, storage.ErrFeePlanNotFound return nil, storage.ErrFeePlanNotFound
} }
if s.plan.GetOrganizationRef() != orgRef { if (s.plan.OrganizationRef != nil) && (*s.plan.OrganizationRef != orgRef) {
return nil, storage.ErrFeePlanNotFound return nil, storage.ErrFeePlanNotFound
} }
if !s.plan.Active { if !s.plan.Active {

View File

@@ -5,6 +5,7 @@ import (
"github.com/tech/sendico/pkg/db/storable" "github.com/tech/sendico/pkg/db/storable"
"github.com/tech/sendico/pkg/model" "github.com/tech/sendico/pkg/model"
"go.mongodb.org/mongo-driver/bson/primitive"
) )
const ( const (
@@ -25,14 +26,14 @@ const (
// FeePlan describes a collection of fee rules for an organisation. // FeePlan describes a collection of fee rules for an organisation.
type FeePlan struct { type FeePlan struct {
storable.Base `bson:",inline" json:",inline"` storable.Base `bson:",inline" json:",inline"`
model.OrganizationBoundBase `bson:",inline" json:",inline"` model.Describable `bson:",inline" json:",inline"`
model.Describable `bson:",inline" json:",inline"` OrganizationRef *primitive.ObjectID `bson:"organizationRef,omitempty" json:"organizationRef,omitempty"`
Active bool `bson:"active" json:"active"` Active bool `bson:"active" json:"active"`
EffectiveFrom time.Time `bson:"effectiveFrom" json:"effectiveFrom"` EffectiveFrom time.Time `bson:"effectiveFrom" json:"effectiveFrom"`
EffectiveTo *time.Time `bson:"effectiveTo,omitempty" json:"effectiveTo,omitempty"` EffectiveTo *time.Time `bson:"effectiveTo,omitempty" json:"effectiveTo,omitempty"`
Rules []FeeRule `bson:"rules,omitempty" json:"rules,omitempty"` Rules []FeeRule `bson:"rules,omitempty" json:"rules,omitempty"`
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"` Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
} }
// Collection implements storable.Storable. // Collection implements storable.Storable.

View File

@@ -11,12 +11,18 @@ market:
- driver: CBR - driver: CBR
settings: settings:
base_url: "https://www.cbr.ru" base_url: "https://www.cbr.ru"
user_agent: "Mozilla/5.0 (compatible; SendicoFX/1.0; +https://app.sendico.io)"
accept_header: "application/xml,text/xml;q=0.9,*/*;q=0.8"
pairs: pairs:
BINANCE: BINANCE:
- base: "USDT" - base: "USDT"
quote: "EUR" quote: "EUR"
symbol: "EURUSDT" symbol: "EURUSDT"
invert: true invert: true
- base: "USD"
quote: "USDT"
symbol: "USDTUSD"
invert: true
- base: "UAH" - base: "UAH"
quote: "USDT" quote: "USDT"
symbol: "USDTUAH" symbol: "USDTUAH"

View File

@@ -32,7 +32,7 @@ require (
github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/montanaflynn/stats v0.7.1 // indirect github.com/montanaflynn/stats v0.7.1 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nats-io/nats.go v1.47.0 // indirect github.com/nats-io/nats.go v1.48.0 // indirect
github.com/nats-io/nkeys v0.4.12 // indirect github.com/nats-io/nkeys v0.4.12 // indirect
github.com/nats-io/nuid v1.0.1 // indirect github.com/nats-io/nuid v1.0.1 // indirect
github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect
@@ -49,7 +49,7 @@ require (
golang.org/x/sync v0.19.0 // indirect golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect golang.org/x/text v0.32.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
google.golang.org/grpc v1.77.0 // indirect google.golang.org/grpc v1.78.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect google.golang.org/protobuf v1.36.11 // indirect
) )

View File

@@ -95,8 +95,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nats-io/nats.go v1.47.0 h1:YQdADw6J/UfGUd2Oy6tn4Hq6YHxCaJrVKayxxFqYrgM= github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
github.com/nats-io/nats.go v1.47.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc= github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg= github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
@@ -212,12 +212,12 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

View File

@@ -85,6 +85,7 @@ func (s *Service) executePoll(ctx context.Context) error {
func (s *Service) pollOnce(ctx context.Context) error { func (s *Service) pollOnce(ctx context.Context) error {
var firstErr error var firstErr error
failures := 0
for _, pair := range s.pairs { for _, pair := range s.pairs {
start := time.Now() start := time.Now()
err := s.upsertPair(ctx, pair) err := s.upsertPair(ctx, pair)
@@ -96,14 +97,24 @@ func (s *Service) pollOnce(ctx context.Context) error {
if firstErr == nil { if firstErr == nil {
firstErr = err firstErr = err
} }
failures++
s.logger.Warn("Failed to ingest pair", s.logger.Warn("Failed to ingest pair",
zap.String("symbol", pair.Symbol), zap.String("symbol", pair.Symbol),
zap.String("source", pair.Source.String()), zap.String("source", pair.Source.String()),
zap.String("provider", pair.Provider),
zap.String("base", pair.Base),
zap.String("quote", pair.Quote),
zap.Bool("invert", pair.Invert),
zap.Duration("elapsed", elapsed), zap.Duration("elapsed", elapsed),
zap.Error(err), zap.Error(err),
) )
} }
} }
if failures > 0 {
s.logger.Warn("Ingestion poll completed with failures", zap.Int("failures", failures), zap.Int("total", len(s.pairs)))
} else {
s.logger.Info("Ingestion poll completed", zap.Int("total", len(s.pairs)))
}
return firstErr return firstErr
} }
@@ -115,7 +126,7 @@ func (s *Service) upsertPair(ctx context.Context, pair config.Pair) error {
ticker, err := connector.FetchTicker(ctx, pair.Symbol) ticker, err := connector.FetchTicker(ctx, pair.Symbol)
if err != nil { if err != nil {
return merrors.InternalWrap(err, "fetch ticker") return merrors.InternalWrap(err, "fetch ticker: "+pair.Symbol)
} }
bid, err := parseDecimal(ticker.BidPrice) bid, err := parseDecimal(ticker.BidPrice)
@@ -172,9 +183,14 @@ func (s *Service) upsertPair(ctx context.Context, pair config.Pair) error {
s.logger.Debug("Snapshot ingested", s.logger.Debug("Snapshot ingested",
zap.String("pair", pair.Base+"/"+pair.Quote), zap.String("pair", pair.Base+"/"+pair.Quote),
zap.String("provider", pair.Provider), zap.String("provider", pair.Provider),
zap.String("source", pair.Source.String()),
zap.String("provider_ref", snapshot.ProviderRef),
zap.String("bid", snapshot.Bid), zap.String("bid", snapshot.Bid),
zap.String("ask", snapshot.Ask), zap.String("ask", snapshot.Ask),
zap.String("mid", snapshot.Mid), zap.String("mid", snapshot.Mid),
zap.String("spread_bps", snapshot.SpreadBps),
zap.Int64("asof_unix_ms", snapshot.AsOfUnixMs),
zap.String("rate_ref", snapshot.RateRef),
) )
return nil return nil

View File

@@ -23,7 +23,7 @@ import (
type cbrConnector struct { type cbrConnector struct {
id mmodel.Driver id mmodel.Driver
provider string provider string
client *http.Client http *httpClient
base string base string
dailyPath string dailyPath string
directoryPath string directoryPath string
@@ -60,6 +60,8 @@ func NewConnector(logger mlogger.Logger, settings model.SettingsT) (mmodel.Conne
directoryPath := defaultDirectoryPath directoryPath := defaultDirectoryPath
dailyPath := defaultDailyPath dailyPath := defaultDailyPath
dynamicPath := defaultDynamicPath dynamicPath := defaultDynamicPath
userAgent := defaultUserAgent
acceptHeader := defaultAccept
if settings != nil { if settings != nil {
if value, ok := settings["base_url"].(string); ok && strings.TrimSpace(value) != "" { if value, ok := settings["base_url"].(string); ok && strings.TrimSpace(value) != "" {
@@ -77,6 +79,12 @@ func NewConnector(logger mlogger.Logger, settings model.SettingsT) (mmodel.Conne
if value, ok := settings["dynamic_path"].(string); ok && strings.TrimSpace(value) != "" { if value, ok := settings["dynamic_path"].(string); ok && strings.TrimSpace(value) != "" {
dynamicPath = strings.TrimSpace(value) dynamicPath = strings.TrimSpace(value)
} }
if value, ok := settings["user_agent"].(string); ok && strings.TrimSpace(value) != "" {
userAgent = strings.TrimSpace(value)
}
if value, ok := settings["accept_header"].(string); ok && strings.TrimSpace(value) != "" {
acceptHeader = strings.TrimSpace(value)
}
dialTimeout = common.DurationSetting(settings, "dial_timeout_seconds", dialTimeout) dialTimeout = common.DurationSetting(settings, "dial_timeout_seconds", dialTimeout)
dialKeepAlive = common.DurationSetting(settings, "dial_keep_alive_seconds", dialKeepAlive) dialKeepAlive = common.DurationSetting(settings, "dial_keep_alive_seconds", dialKeepAlive)
tlsHandshakeTimeout = common.DurationSetting(settings, "tls_handshake_timeout_seconds", tlsHandshakeTimeout) tlsHandshakeTimeout = common.DurationSetting(settings, "tls_handshake_timeout_seconds", tlsHandshakeTimeout)
@@ -99,13 +107,24 @@ func NewConnector(logger mlogger.Logger, settings model.SettingsT) (mmodel.Conne
transport = customTransport transport = customTransport
} }
client := &http.Client{
Timeout: requestTimeout,
Transport: transport,
}
referer := parsed.String()
connector := &cbrConnector{ connector := &cbrConnector{
id: mmodel.DriverCBR, id: mmodel.DriverCBR,
provider: provider, provider: provider,
client: &http.Client{ http: newHTTPClient(
Timeout: requestTimeout, logger,
Transport: transport, client,
}, httpClientOptions{
userAgent: userAgent,
accept: acceptHeader,
referer: referer,
},
),
base: strings.TrimRight(parsed.String(), "/"), base: strings.TrimRight(parsed.String(), "/"),
dailyPath: dailyPath, dailyPath: dailyPath,
directoryPath: directoryPath, directoryPath: directoryPath,
@@ -161,20 +180,32 @@ func (c *cbrConnector) refreshDirectory() error {
return err return err
} }
req, err := http.NewRequest(http.MethodGet, endpoint, nil) req, err := c.http.NewRequest(context.Background(), http.MethodGet, endpoint)
if err != nil { if err != nil {
return merrors.InternalWrap(err, "cbr: build directory request") return merrors.InternalWrap(err, "cbr: build directory request")
} }
resp, err := c.client.Do(req) resp, err := c.http.Do(req)
if err != nil { if err != nil {
c.logger.Warn("CBR directory request failed", zap.Error(err)) c.logger.Warn(
"CBR directory request failed",
zap.Error(err),
zap.String("endpoint", endpoint),
zap.String("referer", c.http.headerValue("Referer")),
zap.String("user_agent", c.http.headerValue("User-Agent")),
)
return merrors.InternalWrap(err, "cbr: directory request failed") return merrors.InternalWrap(err, "cbr: directory request failed")
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
c.logger.Warn("CBR directory returned non-OK status", zap.Int("status", resp.StatusCode)) c.logger.Warn(
"CBR directory returned non-OK status",
zap.Int("status", resp.StatusCode),
zap.String("endpoint", endpoint),
zap.String("referer", c.http.headerValue("Referer")),
zap.String("user_agent", c.http.headerValue("User-Agent")),
)
return merrors.Internal("cbr: unexpected status " + strconv.Itoa(resp.StatusCode)) return merrors.Internal("cbr: unexpected status " + strconv.Itoa(resp.StatusCode))
} }
@@ -183,12 +214,13 @@ func (c *cbrConnector) refreshDirectory() error {
var directory valuteDirectory var directory valuteDirectory
if err := decoder.Decode(&directory); err != nil { if err := decoder.Decode(&directory); err != nil {
c.logger.Warn("CBR directory decode failed", zap.Error(err)) c.logger.Warn("CBR directory decode failed", zap.Error(err), zap.String("endpoint", endpoint))
return merrors.InternalWrap(err, "cbr: decode directory") return merrors.InternalWrap(err, "cbr: decode directory")
} }
mapping, err := buildValuteMapping(directory.Items) mapping, err := buildValuteMapping(c.logger.Named("mapper"), directory.Items)
if err != nil { if err != nil {
c.logger.Warn("Failed to build currencies mapping", zap.Error(err), zap.String("endpoint", endpoint))
return err return err
} }
@@ -200,23 +232,32 @@ func (c *cbrConnector) refreshDirectory() error {
func (c *cbrConnector) fetchDailyRate(ctx context.Context, valute valuteInfo) (string, error) { func (c *cbrConnector) fetchDailyRate(ctx context.Context, valute valuteInfo) (string, error) {
endpoint, err := c.buildURL(c.dailyPath, nil) endpoint, err := c.buildURL(c.dailyPath, nil)
if err != nil { if err != nil {
c.logger.Warn("Failed to build daily fetch URL", zap.Error(err), zap.String("path", c.dailyPath))
return "", err return "", err
} }
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) req, err := c.http.NewRequest(ctx, http.MethodGet, endpoint)
if err != nil { if err != nil {
c.logger.Warn("Failed to request daily rate", zap.Error(err), zap.String("endpoint", endpoint))
return "", merrors.InternalWrap(err, "cbr: build daily request") return "", merrors.InternalWrap(err, "cbr: build daily request")
} }
resp, err := c.client.Do(req) resp, err := c.http.Do(req)
if err != nil { if err != nil {
c.logger.Warn("CBR daily request failed", zap.String("currency", valute.ISOCharCode), zap.Error(err)) c.logger.Warn("CBR daily request failed",
zap.Error(err), zap.String("currency", valute.ISOCharCode),
zap.String("endpoint", endpoint), zap.String("referer", c.http.headerValue("Referer")),
zap.String("user_agent", c.http.headerValue("User-Agent")),
)
return "", merrors.InternalWrap(err, "cbr: daily request failed") return "", merrors.InternalWrap(err, "cbr: daily request failed")
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
c.logger.Warn("CBR daily returned non-OK status", zap.Int("status", resp.StatusCode)) c.logger.Warn(
"CBR daily returned non-OK status", zap.Int("status", resp.StatusCode),
zap.String("currency", valute.ISOCharCode), zap.String("endpoint", endpoint),
)
return "", merrors.Internal("cbr: unexpected status " + strconv.Itoa(resp.StatusCode)) return "", merrors.Internal("cbr: unexpected status " + strconv.Itoa(resp.StatusCode))
} }
@@ -225,7 +266,9 @@ func (c *cbrConnector) fetchDailyRate(ctx context.Context, valute valuteInfo) (s
var payload dailyRates var payload dailyRates
if err := decoder.Decode(&payload); err != nil { if err := decoder.Decode(&payload); err != nil {
c.logger.Warn("CBR daily decode failed", zap.Error(err)) c.logger.Warn("CBR daily decode failed", zap.Error(err),
zap.String("currency", valute.ISOCharCode), zap.String("endpoint", endpoint),
)
return "", merrors.InternalWrap(err, "cbr: decode daily response") return "", merrors.InternalWrap(err, "cbr: decode daily response")
} }
@@ -247,25 +290,40 @@ func (c *cbrConnector) fetchHistoricalRate(ctx context.Context, valute valuteInf
"date_req2": date.Format("02/01/2006"), "date_req2": date.Format("02/01/2006"),
"VAL_NM_RQ": valute.ID, "VAL_NM_RQ": valute.ID,
} }
dateStr := date.Format("2006-01-02")
endpoint, err := c.buildURL(c.dynamicPath, query) endpoint, err := c.buildURL(c.dynamicPath, query)
if err != nil { if err != nil {
return "", err return "", err
} }
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) req, err := c.http.NewRequest(ctx, http.MethodGet, endpoint)
if err != nil { if err != nil {
return "", merrors.InternalWrap(err, "cbr: build historical request") return "", merrors.InternalWrap(err, "cbr: build historical request")
} }
resp, err := c.client.Do(req) resp, err := c.http.Do(req)
if err != nil { if err != nil {
c.logger.Warn("CBR historical request failed", zap.String("currency", valute.ISOCharCode), zap.Error(err)) c.logger.Warn(
"CBR historical request failed",
zap.String("currency", valute.ISOCharCode),
zap.String("date", dateStr),
zap.String("endpoint", endpoint),
zap.String("referer", c.http.headerValue("Referer")),
zap.String("user_agent", c.http.headerValue("User-Agent")),
zap.Error(err),
)
return "", merrors.InternalWrap(err, "cbr: historical request failed") return "", merrors.InternalWrap(err, "cbr: historical request failed")
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
c.logger.Warn("CBR historical returned non-OK status", zap.Int("status", resp.StatusCode)) c.logger.Warn(
"CBR historical returned non-OK status",
zap.Int("status", resp.StatusCode),
zap.String("currency", valute.ISOCharCode),
zap.String("date", dateStr),
zap.String("endpoint", endpoint),
)
return "", merrors.Internal("cbr: unexpected status " + strconv.Itoa(resp.StatusCode)) return "", merrors.Internal("cbr: unexpected status " + strconv.Itoa(resp.StatusCode))
} }
@@ -274,7 +332,13 @@ func (c *cbrConnector) fetchHistoricalRate(ctx context.Context, valute valuteInf
var payload dynamicRates var payload dynamicRates
if err := decoder.Decode(&payload); err != nil { if err := decoder.Decode(&payload); err != nil {
c.logger.Warn("CBR historical decode failed", zap.Error(err)) c.logger.Warn(
"CBR historical decode failed",
zap.String("currency", valute.ISOCharCode),
zap.String("date", dateStr),
zap.String("endpoint", endpoint),
zap.Error(err),
)
return "", merrors.InternalWrap(err, "cbr: decode historical response") return "", merrors.InternalWrap(err, "cbr: decode historical response")
} }
@@ -337,7 +401,7 @@ type valuteMapping struct {
byID map[string]valuteInfo byID map[string]valuteInfo
} }
func buildValuteMapping(items []valuteItem) (*valuteMapping, error) { func buildValuteMapping(logger *zap.Logger, items []valuteItem) (*valuteMapping, error) {
byISO := make(map[string]valuteInfo, len(items)) byISO := make(map[string]valuteInfo, len(items))
byID := make(map[string]valuteInfo, len(items)) byID := make(map[string]valuteInfo, len(items))
byNum := make(map[string]string, len(items)) byNum := make(map[string]string, len(items))
@@ -348,12 +412,19 @@ func buildValuteMapping(items []valuteItem) (*valuteMapping, error) {
isoNum := strings.TrimSpace(item.ISONum) isoNum := strings.TrimSpace(item.ISONum)
name := strings.TrimSpace(item.Name) name := strings.TrimSpace(item.Name)
engName := strings.TrimSpace(item.EngName) engName := strings.TrimSpace(item.EngName)
nominal, err := parseNominal(item.NominalStr) nominal, err := parseNominal(item.NominalStr)
if err != nil { if err != nil {
return nil, merrors.InvalidDataType("cbr: parse directory nominal: " + err.Error()) return nil, merrors.InvalidDataType("cbr: parse directory nominal: " + err.Error())
} }
if id == "" || isoChar == "" { if id == "" || isoChar == "" {
return nil, merrors.InvalidDataType("cbr: directory contains entry with empty id or iso code") logger.Info("Skipping invalid currency entry",
zap.String("id", id),
zap.String("iso_char", isoChar),
zap.String("name", name),
)
continue
} }
info := valuteInfo{ info := valuteInfo{
@@ -365,12 +436,76 @@ func buildValuteMapping(items []valuteItem) (*valuteMapping, error) {
Nominal: nominal, Nominal: nominal,
} }
if existing, ok := byISO[isoChar]; ok && existing.ID != id { // Handle duplicate ISO char codes (e.g. DEM with different IDs / nominals).
return nil, merrors.InvalidDataType("cbr: duplicate ISO code " + isoChar) if existing, ok := byISO[isoChar]; ok {
// Same ISO + same ID: duplicate entry, just ignore.
if existing.ID == id {
logger.Debug("Duplicate directory entry for same ISO and ID, ignoring",
zap.String("iso_code", isoChar),
zap.String("id", id),
)
continue
}
// Different IDs but same ISO char.
// Choose canonical entry:
// 1) Prefer nominal == 1
// 2) Otherwise prefer smaller nominal
keepExisting := true
if existing.Nominal != 1 && info.Nominal == 1 {
keepExisting = false
} else if existing.Nominal == 1 && info.Nominal != 1 {
keepExisting = true
} else if info.Nominal < existing.Nominal {
keepExisting = false
}
if keepExisting {
logger.Warn("Ignoring duplicate ISO currency entry with less preferred nominal",
zap.String("iso_code", isoChar),
zap.String("existing_id", existing.ID),
zap.Int64("existing_nominal", existing.Nominal),
zap.String("new_id", info.ID),
zap.Int64("new_nominal", info.Nominal),
)
// We keep the old one, just skip the new.
continue
}
// Replace existing mapping with the new, more canonical one.
logger.Warn("Replacing currency mapping due to more canonical nominal",
zap.String("iso_code", isoChar),
zap.String("old_id", existing.ID),
zap.Int64("old_nominal", existing.Nominal),
zap.String("new_id", info.ID),
zap.Int64("new_nominal", info.Nominal),
)
// Update byID: drop old ID, add new one
delete(byID, existing.ID)
byID[id] = info
// Update ISO mapping
byISO[isoChar] = info
// Update numeric-code index if present
if isoNum != "" {
if existingID, ok := byNum[isoNum]; ok && existingID != id {
return nil, merrors.InvalidDataType("cbr: duplicate ISO numeric code " + isoNum)
}
byNum[isoNum] = id
}
continue
} }
// No existing ISO entry, do normal uniqueness checks.
if existing, ok := byID[id]; ok && existing.ISOCharCode != isoChar { if existing, ok := byID[id]; ok && existing.ISOCharCode != isoChar {
return nil, merrors.InvalidDataType("cbr: duplicate valute id " + id) return nil, merrors.InvalidDataType("cbr: duplicate valute id " + id)
} }
if isoNum != "" { if isoNum != "" {
if existingID, ok := byNum[isoNum]; ok && existingID != id { if existingID, ok := byNum[isoNum]; ok && existingID != id {
return nil, merrors.InvalidDataType("cbr: duplicate ISO numeric code " + isoNum) return nil, merrors.InvalidDataType("cbr: duplicate ISO numeric code " + isoNum)
@@ -378,6 +513,8 @@ func buildValuteMapping(items []valuteItem) (*valuteMapping, error) {
byNum[isoNum] = id byNum[isoNum] = id
} }
logger.Info("Installing currency code", zap.String("iso_code", isoChar), zap.String("id", id), zap.Int64("nominal", nominal))
byISO[isoChar] = info byISO[isoChar] = info
byID[id] = info byID[id] = info
} }

View File

@@ -0,0 +1,85 @@
package cbr
import (
"context"
"net/http"
"strings"
"go.uber.org/zap"
)
const (
defaultUserAgent = "Mozilla/5.0 (compatible; SendicoFX/1.0; +https://app.sendico.io)"
defaultAccept = "application/xml,text/xml;q=0.9,*/*;q=0.8"
)
// httpClient wraps http.Client to ensure CBR requests always carry required headers.
type httpClient struct {
client *http.Client
headers http.Header
logger *zap.Logger
}
type httpClientOptions struct {
userAgent string
accept string
referer string
}
func newHTTPClient(logger *zap.Logger, client *http.Client, opts httpClientOptions) *httpClient {
userAgent := opts.userAgent
if strings.TrimSpace(userAgent) == "" {
userAgent = defaultUserAgent
}
accept := opts.accept
if strings.TrimSpace(accept) == "" {
accept = defaultAccept
}
referer := opts.referer
if strings.TrimSpace(referer) == "" {
referer = defaultCBRBaseURL
}
l := logger.Named("http_client")
headers := make(http.Header, 3)
headers.Set("User-Agent", userAgent)
headers.Set("Accept", accept)
headers.Set("Referer", referer)
l.Info("HTTP client initialized", zap.String("user_agent", userAgent),
zap.String("accept", accept), zap.String("referrer", referer))
return &httpClient{
client: client,
headers: headers,
logger: l,
}
}
func (h *httpClient) NewRequest(ctx context.Context, method, endpoint string) (*http.Request, error) {
return http.NewRequestWithContext(ctx, method, endpoint, nil)
}
func (h *httpClient) Do(req *http.Request) (*http.Response, error) {
enriched := req.Clone(req.Context())
for key, values := range h.headers {
if enriched.Header.Get(key) != "" {
continue
}
for _, value := range values {
enriched.Header.Add(key, value)
}
}
r, err := h.client.Do(enriched)
if err != nil {
h.logger.Warn("HTTP request failed", zap.Error(err), zap.String("method", req.Method),
zap.String("url", req.URL.String()))
}
return r, err
}
func (h *httpClient) headerValue(name string) string {
return h.headers.Get(name)
}

View File

@@ -40,15 +40,15 @@ func main() {
application, err := app.New(logger, *configFile) application, err := app.New(logger, *configFile)
if err != nil { if err != nil {
logger.Fatal("Failed to initialise application", zap.Error(err)) logger.Error("Failed to initialise application", zap.Error(err))
} } else {
if err := application.Run(ctx); err != nil {
if err := application.Run(ctx); err != nil { if errors.Is(err, context.Canceled) {
if errors.Is(err, context.Canceled) { logger.Info("FX ingestor stopped")
logger.Info("FX ingestor stopped") return
return }
logger.Error("Ingestor terminated with error", zap.Error(err))
} }
logger.Fatal("Ingestor terminated with error", zap.Error(err))
} }
logger.Info("FX ingestor stopped") logger.Info("FX ingestor stopped")

View File

@@ -13,8 +13,8 @@ require (
github.com/tech/sendico/pkg v0.1.0 github.com/tech/sendico/pkg v0.1.0
go.mongodb.org/mongo-driver v1.17.6 go.mongodb.org/mongo-driver v1.17.6
go.uber.org/zap v1.27.1 go.uber.org/zap v1.27.1
google.golang.org/grpc v1.77.0 google.golang.org/grpc v1.78.0
google.golang.org/protobuf v1.36.10 google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
@@ -33,7 +33,7 @@ require (
github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/montanaflynn/stats v0.7.1 // indirect github.com/montanaflynn/stats v0.7.1 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nats-io/nats.go v1.47.0 // indirect github.com/nats-io/nats.go v1.48.0 // indirect
github.com/nats-io/nkeys v0.4.12 // indirect github.com/nats-io/nkeys v0.4.12 // indirect
github.com/nats-io/nuid v1.0.1 // indirect github.com/nats-io/nuid v1.0.1 // indirect
github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect
@@ -50,5 +50,5 @@ require (
golang.org/x/sync v0.19.0 // indirect golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect golang.org/x/text v0.32.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
) )

View File

@@ -95,8 +95,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nats-io/nats.go v1.47.0 h1:YQdADw6J/UfGUd2Oy6tn4Hq6YHxCaJrVKayxxFqYrgM= github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
github.com/nats-io/nats.go v1.47.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc= github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg= github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
@@ -212,12 +212,12 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

View File

@@ -8,9 +8,9 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"github.com/tech/sendico/fx/storage/model" "github.com/tech/sendico/fx/storage/model"
"github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/merrors"
smodel "github.com/tech/sendico/pkg/model"
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1" fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
"go.mongodb.org/mongo-driver/bson/primitive" "go.mongodb.org/mongo-driver/bson/primitive"
) )
@@ -138,11 +138,11 @@ func (qc *quoteComputation) buildModelQuote(firm bool, expiryMillis int64, req *
Pair: qc.pair.Pair, Pair: qc.pair.Pair,
Side: qc.sideModel, Side: qc.sideModel,
Price: formatRat(qc.priceRounded, qc.priceScale), Price: formatRat(qc.priceRounded, qc.priceScale),
BaseAmount: model.Money{ BaseAmount: smodel.Money{
Currency: qc.pair.Pair.Base, Currency: qc.pair.Pair.Base,
Amount: formatRat(qc.baseRounded, qc.baseScale), Amount: formatRat(qc.baseRounded, qc.baseScale),
}, },
QuoteAmount: model.Money{ QuoteAmount: smodel.Money{
Currency: qc.pair.Pair.Quote, Currency: qc.pair.Pair.Quote,
Amount: formatRat(qc.quoteRounded, qc.quoteScale), Amount: formatRat(qc.quoteRounded, qc.quoteScale),
}, },
@@ -170,10 +170,13 @@ func buildQuoteMeta(meta *oraclev1.RequestMeta) *model.QuoteMeta {
} }
trace := meta.GetTrace() trace := meta.GetTrace()
qm := &model.QuoteMeta{ qm := &model.QuoteMeta{
RequestRef: deriveRequestRef(meta, trace), TenantRef: meta.GetTenantRef(),
TenantRef: meta.GetTenantRef(), }
TraceRef: deriveTraceRef(meta, trace),
IdempotencyKey: deriveIdempotencyKey(meta, trace), if trace != nil {
qm.RequestRef = trace.GetRequestRef()
qm.TraceRef = trace.GetTraceRef()
qm.IdempotencyKey = trace.GetIdempotencyKey()
} }
if org := strings.TrimSpace(meta.GetOrganizationRef()); org != "" { if org := strings.TrimSpace(meta.GetOrganizationRef()); org != "" {
if objID, err := primitive.ObjectIDFromHex(org); err == nil { if objID, err := primitive.ObjectIDFromHex(org); err == nil {
@@ -200,24 +203,3 @@ func computeExpiry(now time.Time, ttlMs int64) (int64, error) {
} }
return now.Add(time.Duration(ttlMs) * time.Millisecond).UnixMilli(), nil return now.Add(time.Duration(ttlMs) * time.Millisecond).UnixMilli(), nil
} }
func deriveRequestRef(meta *oraclev1.RequestMeta, trace *tracev1.TraceContext) string {
if trace != nil && trace.GetRequestRef() != "" {
return trace.GetRequestRef()
}
return meta.GetRequestRef()
}
func deriveTraceRef(meta *oraclev1.RequestMeta, trace *tracev1.TraceContext) string {
if trace != nil && trace.GetTraceRef() != "" {
return trace.GetTraceRef()
}
return meta.GetTraceRef()
}
func deriveIdempotencyKey(meta *oraclev1.RequestMeta, trace *tracev1.TraceContext) string {
if trace != nil && trace.GetIdempotencyKey() != "" {
return trace.GetIdempotencyKey()
}
return meta.GetIdempotencyKey()
}

View File

@@ -0,0 +1,90 @@
package oracle
import (
"strings"
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
"go.uber.org/zap"
)
func quoteRequestFields(req *oraclev1.GetQuoteRequest) []zap.Field {
if req == nil {
return nil
}
fields := requestMetaFields(req.GetMeta())
if pair := req.GetPair(); pair != nil {
if base := strings.TrimSpace(pair.GetBase()); base != "" {
fields = append(fields, zap.String("pair_base", base))
}
if quote := strings.TrimSpace(pair.GetQuote()); quote != "" {
fields = append(fields, zap.String("pair_quote", quote))
}
}
if side := req.GetSide(); side != fxv1.Side_SIDE_UNSPECIFIED {
fields = append(fields, zap.String("side", side.String()))
}
if req.GetFirm() {
fields = append(fields, zap.Bool("firm", req.GetFirm()))
}
if ttl := req.GetTtlMs(); ttl > 0 {
fields = append(fields, zap.Int64("ttl_ms", ttl))
}
if maxAge := req.GetMaxAgeMs(); maxAge > 0 {
fields = append(fields, zap.Int32("max_age_ms", maxAge))
}
if provider := strings.TrimSpace(req.GetPreferredProvider()); provider != "" {
fields = append(fields, zap.String("preferred_provider", provider))
}
fields = append(fields, moneyFields("base_amount", req.GetBaseAmount())...)
fields = append(fields, moneyFields("quote_amount", req.GetQuoteAmount())...)
return fields
}
func requestMetaFields(meta *oraclev1.RequestMeta) []zap.Field {
if meta == nil {
return nil
}
fields := make([]zap.Field, 0, 4)
if org := strings.TrimSpace(meta.GetOrganizationRef()); org != "" {
fields = append(fields, zap.String("organization_ref", org))
}
if tenant := strings.TrimSpace(meta.GetTenantRef()); tenant != "" {
fields = append(fields, zap.String("tenant_ref", tenant))
}
fields = append(fields, traceFields(meta.GetTrace())...)
return fields
}
func moneyFields(prefix string, money *moneyv1.Money) []zap.Field {
if money == nil {
return nil
}
fields := make([]zap.Field, 0, 2)
if amt := strings.TrimSpace(money.GetAmount()); amt != "" {
fields = append(fields, zap.String(prefix, amt))
}
if ccy := strings.TrimSpace(money.GetCurrency()); ccy != "" {
fields = append(fields, zap.String(prefix+"_currency", ccy))
}
return fields
}
func traceFields(trace *tracev1.TraceContext) []zap.Field {
if trace == nil {
return nil
}
fields := make([]zap.Field, 0, 3)
if ref := strings.TrimSpace(trace.GetTraceRef()); ref != "" {
fields = append(fields, zap.String("trace_ref", ref))
}
if idem := strings.TrimSpace(trace.GetIdempotencyKey()); idem != "" {
fields = append(fields, zap.String("idempotency_key", idem))
}
if req := strings.TrimSpace(trace.GetRequestRef()); req != "" {
fields = append(fields, zap.String("request_ref", req))
}
return fields
}

View File

@@ -3,7 +3,6 @@ package oracle
import ( import (
"math/big" "math/big"
"strings" "strings"
"time"
"github.com/tech/sendico/fx/storage/model" "github.com/tech/sendico/fx/storage/model"
"github.com/tech/sendico/pkg/decimal" "github.com/tech/sendico/pkg/decimal"
@@ -61,7 +60,3 @@ func priceFromRate(rate *model.RateSnapshot, side fxv1.Side) (*big.Rat, error) {
return ratFromString(priceStr) return ratFromString(priceStr)
} }
func timeFromUnixMilli(ms int64) time.Time {
return time.Unix(0, ms*int64(time.Millisecond))
}

View File

@@ -101,22 +101,27 @@ func (s *Service) getQuoteResponder(ctx context.Context, req *oraclev1.GetQuoteR
if req == nil { if req == nil {
req = &oraclev1.GetQuoteRequest{} req = &oraclev1.GetQuoteRequest{}
} }
s.logger.Debug("Handling GetQuote", zap.String("pair", req.GetPair().GetBase()+"/"+req.GetPair().GetQuote()), zap.Bool("firm", req.GetFirm())) logger := s.logger.With(quoteRequestFields(req)...)
logger.Debug("Handling GetQuote")
if req.GetSide() == fxv1.Side_SIDE_UNSPECIFIED { if req.GetSide() == fxv1.Side_SIDE_UNSPECIFIED {
logger.Warn("GetQuote invalid: side missing")
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, errSideRequired) return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, errSideRequired)
} }
if req.GetBaseAmount() != nil && req.GetQuoteAmount() != nil { if req.GetBaseAmount() != nil && req.GetQuoteAmount() != nil {
logger.Warn("GetQuote invalid: both base_amount and quote_amount provided")
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, errAmountsMutuallyExclusive) return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, errAmountsMutuallyExclusive)
} }
if req.GetBaseAmount() == nil && req.GetQuoteAmount() == nil { if req.GetBaseAmount() == nil && req.GetQuoteAmount() == nil {
logger.Warn("GetQuote invalid: amount missing")
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, errAmountRequired) return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, errAmountRequired)
} }
if err := s.pingStorage(ctx); err != nil { if err := s.pingStorage(ctx); err != nil {
s.logger.Warn("Storage unavailable during GetQuote", zap.Error(err)) logger.Warn("Storage unavailable during GetQuote", zap.Error(err))
return gsresponse.Unavailable[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err) return gsresponse.Unavailable[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
} }
pairMsg := req.GetPair() pairMsg := req.GetPair()
if pairMsg == nil || strings.TrimSpace(pairMsg.GetBase()) == "" || strings.TrimSpace(pairMsg.GetQuote()) == "" { if pairMsg == nil || strings.TrimSpace(pairMsg.GetBase()) == "" || strings.TrimSpace(pairMsg.GetQuote()) == "" {
logger.Warn("GetQuote invalid: pair missing")
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, errEmptyRequest) return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, errEmptyRequest)
} }
pairKey := model.CurrencyPair{Base: strings.ToUpper(pairMsg.GetBase()), Quote: strings.ToUpper(pairMsg.GetQuote())} pairKey := model.CurrencyPair{Base: strings.ToUpper(pairMsg.GetBase()), Quote: strings.ToUpper(pairMsg.GetQuote())}
@@ -125,8 +130,10 @@ func (s *Service) getQuoteResponder(ctx context.Context, req *oraclev1.GetQuoteR
if err != nil { if err != nil {
switch { switch {
case errors.Is(err, merrors.ErrNoData): case errors.Is(err, merrors.ErrNoData):
logger.Warn("pair not supported", zap.String("pair", pairKey.Base+"/"+pairKey.Quote))
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, merrors.InvalidArgument("pair_not_supported")) return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, merrors.InvalidArgument("pair_not_supported"))
default: default:
logger.Warn("GetQuote failed to load pair", zap.Error(err))
return gsresponse.Internal[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err) return gsresponse.Internal[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
} }
} }
@@ -143,8 +150,10 @@ func (s *Service) getQuoteResponder(ctx context.Context, req *oraclev1.GetQuoteR
if err != nil { if err != nil {
switch { switch {
case errors.Is(err, merrors.ErrNoData): case errors.Is(err, merrors.ErrNoData):
logger.Warn("rate not found", zap.String("pair", pairKey.Base+"/"+pairKey.Quote), zap.String("provider", provider))
return gsresponse.FailedPrecondition[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, "rate_not_found", err) return gsresponse.FailedPrecondition[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, "rate_not_found", err)
default: default:
logger.Warn("GetQuote failed to load rate", zap.Error(err), zap.String("pair", pairKey.Base+"/"+pairKey.Quote), zap.String("provider", provider))
return gsresponse.Internal[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err) return gsresponse.Internal[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
} }
} }
@@ -153,27 +162,31 @@ func (s *Service) getQuoteResponder(ctx context.Context, req *oraclev1.GetQuoteR
if maxAge := req.GetMaxAgeMs(); maxAge > 0 { if maxAge := req.GetMaxAgeMs(); maxAge > 0 {
age := now.UnixMilli() - rate.AsOfUnixMs age := now.UnixMilli() - rate.AsOfUnixMs
if age > int64(maxAge) { if age > int64(maxAge) {
s.logger.Warn("Rate snapshot stale", zap.Int64("age_ms", age), zap.Int32("max_age_ms", req.GetMaxAgeMs()), zap.String("pair", pairKey.Base+"/"+pairKey.Quote), zap.String("provider", provider)) logger.Warn("Rate snapshot stale", zap.Int64("age_ms", age), zap.Int32("max_age_ms", req.GetMaxAgeMs()), zap.String("pair", pairKey.Base+"/"+pairKey.Quote), zap.String("provider", provider))
return gsresponse.FailedPrecondition[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, "stale_rate", merrors.InvalidArgument("rate older than allowed window")) return gsresponse.FailedPrecondition[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, "stale_rate", merrors.InvalidArgument("rate older than allowed window"))
} }
} }
comp, err := newQuoteComputation(pair, rate, req.GetSide(), provider) comp, err := newQuoteComputation(pair, rate, req.GetSide(), provider)
if err != nil { if err != nil {
logger.Warn("GetQuote invalid input", zap.Error(err))
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err) return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
} }
if req.GetBaseAmount() != nil { if req.GetBaseAmount() != nil {
if err := comp.withBaseInput(req.GetBaseAmount()); err != nil { if err := comp.withBaseInput(req.GetBaseAmount()); err != nil {
logger.Warn("GetQuote invalid base input", zap.Error(err))
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err) return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
} }
} else if req.GetQuoteAmount() != nil { } else if req.GetQuoteAmount() != nil {
if err := comp.withQuoteInput(req.GetQuoteAmount()); err != nil { if err := comp.withQuoteInput(req.GetQuoteAmount()); err != nil {
logger.Warn("GetQuote invalid quote input", zap.Error(err))
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err) return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
} }
} }
if err := comp.compute(); err != nil { if err := comp.compute(); err != nil {
logger.Warn("GetQuote computation failed", zap.Error(err))
return gsresponse.Internal[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err) return gsresponse.Internal[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
} }
@@ -195,12 +208,14 @@ func (s *Service) getQuoteResponder(ctx context.Context, req *oraclev1.GetQuoteR
if err := s.storage.Quotes().Issue(ctx, quoteModel); err != nil { if err := s.storage.Quotes().Issue(ctx, quoteModel); err != nil {
switch { switch {
case errors.Is(err, merrors.ErrDataConflict): case errors.Is(err, merrors.ErrDataConflict):
logger.Warn("GetQuote conflict issuing firm quote", zap.Error(err), zap.String("quote_ref", quoteModel.QuoteRef))
return gsresponse.Conflict[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err) return gsresponse.Conflict[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
default: default:
logger.Warn("GetQuote failed to issue firm quote", zap.Error(err), zap.String("quote_ref", quoteModel.QuoteRef))
return gsresponse.Internal[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err) return gsresponse.Internal[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
} }
} }
s.logger.Info("Firm quote stored", zap.String("quote_ref", quoteModel.QuoteRef), zap.String("pair", pairKey.Base+"/"+pairKey.Quote), zap.String("provider", quoteModel.Provider), zap.Int64("expires_at_ms", quoteModel.ExpiresAtUnixMs)) logger.Info("Firm quote stored", zap.String("quote_ref", quoteModel.QuoteRef), zap.String("pair", pairKey.Base+"/"+pairKey.Quote), zap.String("provider", quoteModel.Provider), zap.Int64("expires_at_ms", quoteModel.ExpiresAtUnixMs))
} }
resp := &oraclev1.GetQuoteResponse{ resp := &oraclev1.GetQuoteResponse{
@@ -214,18 +229,24 @@ func (s *Service) validateQuoteResponder(ctx context.Context, req *oraclev1.Vali
if req == nil { if req == nil {
req = &oraclev1.ValidateQuoteRequest{} req = &oraclev1.ValidateQuoteRequest{}
} }
s.logger.Debug("Handling ValidateQuote", zap.String("quote_ref", req.GetQuoteRef())) logger := s.logger.With(requestMetaFields(req.GetMeta())...)
if ref := strings.TrimSpace(req.GetQuoteRef()); ref != "" {
logger = logger.With(zap.String("quote_ref", ref))
}
logger.Debug("Handling ValidateQuote")
if req.GetQuoteRef() == "" { if req.GetQuoteRef() == "" {
logger.Warn("ValidateQuote invalid: quote_ref missing")
return gsresponse.InvalidArgument[oraclev1.ValidateQuoteResponse](s.logger, mservice.FXOracle, errQuoteRefRequired) return gsresponse.InvalidArgument[oraclev1.ValidateQuoteResponse](s.logger, mservice.FXOracle, errQuoteRefRequired)
} }
if err := s.pingStorage(ctx); err != nil { if err := s.pingStorage(ctx); err != nil {
s.logger.Warn("Storage unavailable during ValidateQuote", zap.Error(err)) logger.Warn("Storage unavailable during ValidateQuote", zap.Error(err))
return gsresponse.Unavailable[oraclev1.ValidateQuoteResponse](s.logger, mservice.FXOracle, err) return gsresponse.Unavailable[oraclev1.ValidateQuoteResponse](s.logger, mservice.FXOracle, err)
} }
quote, err := s.storage.Quotes().GetByRef(ctx, req.GetQuoteRef()) quote, err := s.storage.Quotes().GetByRef(ctx, req.GetQuoteRef())
if err != nil { if err != nil {
switch { switch {
case errors.Is(err, merrors.ErrNoData): case errors.Is(err, merrors.ErrNoData):
logger.Warn("ValidateQuote: quote not found", zap.String("quote_ref", req.GetQuoteRef()))
resp := &oraclev1.ValidateQuoteResponse{ resp := &oraclev1.ValidateQuoteResponse{
Meta: buildResponseMeta(req.GetMeta()), Meta: buildResponseMeta(req.GetMeta()),
Quote: nil, Quote: nil,
@@ -234,6 +255,7 @@ func (s *Service) validateQuoteResponder(ctx context.Context, req *oraclev1.Vali
} }
return gsresponse.Success(resp) return gsresponse.Success(resp)
default: default:
logger.Warn("ValidateQuote failed", zap.Error(err))
return gsresponse.Internal[oraclev1.ValidateQuoteResponse](s.logger, mservice.FXOracle, err) return gsresponse.Internal[oraclev1.ValidateQuoteResponse](s.logger, mservice.FXOracle, err)
} }
} }
@@ -255,6 +277,11 @@ func (s *Service) validateQuoteResponder(ctx context.Context, req *oraclev1.Vali
Valid: valid, Valid: valid,
Reason: reason, Reason: reason,
} }
if !valid {
logger.Info("ValidateQuote invalid", zap.String("reason", reason), zap.Bool("firm", quote.Firm))
} else {
logger.Debug("ValidateQuote valid", zap.Bool("firm", quote.Firm))
}
return gsresponse.Success(resp) return gsresponse.Success(resp)
} }
@@ -262,29 +289,43 @@ func (s *Service) consumeQuoteResponder(ctx context.Context, req *oraclev1.Consu
if req == nil { if req == nil {
req = &oraclev1.ConsumeQuoteRequest{} req = &oraclev1.ConsumeQuoteRequest{}
} }
s.logger.Debug("Handling ConsumeQuote", zap.String("quote_ref", req.GetQuoteRef()), zap.String("ledger_txn_ref", req.GetLedgerTxnRef())) logger := s.logger.With(requestMetaFields(req.GetMeta())...)
if ref := strings.TrimSpace(req.GetQuoteRef()); ref != "" {
logger = logger.With(zap.String("quote_ref", ref))
}
if ledger := strings.TrimSpace(req.GetLedgerTxnRef()); ledger != "" {
logger = logger.With(zap.String("ledger_txn_ref", ledger))
}
logger.Debug("Handling ConsumeQuote")
if req.GetQuoteRef() == "" { if req.GetQuoteRef() == "" {
logger.Warn("ConsumeQuote invalid: quote_ref missing")
return gsresponse.InvalidArgument[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, errQuoteRefRequired) return gsresponse.InvalidArgument[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, errQuoteRefRequired)
} }
if req.GetLedgerTxnRef() == "" { if req.GetLedgerTxnRef() == "" {
logger.Warn("ConsumeQuote invalid: ledger_txn_ref missing")
return gsresponse.InvalidArgument[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, errLedgerTxnRefRequired) return gsresponse.InvalidArgument[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, errLedgerTxnRefRequired)
} }
if err := s.pingStorage(ctx); err != nil { if err := s.pingStorage(ctx); err != nil {
s.logger.Warn("Storage unavailable during ConsumeQuote", zap.Error(err)) logger.Warn("Storage unavailable during ConsumeQuote", zap.Error(err))
return gsresponse.Unavailable[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, err) return gsresponse.Unavailable[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, err)
} }
_, err := s.storage.Quotes().Consume(ctx, req.GetQuoteRef(), req.GetLedgerTxnRef(), time.Now()) _, err := s.storage.Quotes().Consume(ctx, req.GetQuoteRef(), req.GetLedgerTxnRef(), time.Now())
if err != nil { if err != nil {
switch { switch {
case errors.Is(err, storage.ErrQuoteExpired): case errors.Is(err, storage.ErrQuoteExpired):
logger.Warn("ConsumeQuote failed: expired")
return gsresponse.FailedPrecondition[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, "expired", err) return gsresponse.FailedPrecondition[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, "expired", err)
case errors.Is(err, storage.ErrQuoteConsumed): case errors.Is(err, storage.ErrQuoteConsumed):
logger.Warn("ConsumeQuote failed: already consumed")
return gsresponse.FailedPrecondition[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, "consumed", err) return gsresponse.FailedPrecondition[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, "consumed", err)
case errors.Is(err, storage.ErrQuoteNotFirm): case errors.Is(err, storage.ErrQuoteNotFirm):
logger.Warn("ConsumeQuote failed: quote not firm")
return gsresponse.FailedPrecondition[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, "not_firm", err) return gsresponse.FailedPrecondition[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, "not_firm", err)
case errors.Is(err, merrors.ErrNoData): case errors.Is(err, merrors.ErrNoData):
logger.Warn("ConsumeQuote failed: quote not found")
return gsresponse.NotFound[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, err) return gsresponse.NotFound[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, err)
default: default:
logger.Warn("ConsumeQuote failed", zap.Error(err))
return gsresponse.Internal[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, err) return gsresponse.Internal[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, err)
} }
} }
@@ -294,7 +335,7 @@ func (s *Service) consumeQuoteResponder(ctx context.Context, req *oraclev1.Consu
Consumed: true, Consumed: true,
Reason: "consumed", Reason: "consumed",
} }
s.logger.Debug("Quote consumed", zap.String("quote_ref", req.GetQuoteRef()), zap.String("ledger_txn_ref", req.GetLedgerTxnRef())) logger.Info("Quote consumed")
return gsresponse.Success(resp) return gsresponse.Success(resp)
} }
@@ -302,13 +343,21 @@ func (s *Service) latestRateResponder(ctx context.Context, req *oraclev1.LatestR
if req == nil { if req == nil {
req = &oraclev1.LatestRateRequest{} req = &oraclev1.LatestRateRequest{}
} }
s.logger.Debug("Handling LatestRate", zap.String("pair", req.GetPair().GetBase()+"/"+req.GetPair().GetQuote())) logger := s.logger.With(requestMetaFields(req.GetMeta())...)
if pair := req.GetPair(); pair != nil {
logger = logger.With(zap.String("pair_base", strings.TrimSpace(pair.GetBase())), zap.String("pair_quote", strings.TrimSpace(pair.GetQuote())))
}
if provider := strings.TrimSpace(req.GetProvider()); provider != "" {
logger = logger.With(zap.String("provider", provider))
}
logger.Debug("Handling LatestRate")
if err := s.pingStorage(ctx); err != nil { if err := s.pingStorage(ctx); err != nil {
s.logger.Warn("Storage unavailable during LatestRate", zap.Error(err)) logger.Warn("Storage unavailable during LatestRate", zap.Error(err))
return gsresponse.Unavailable[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, err) return gsresponse.Unavailable[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, err)
} }
pairMsg := req.GetPair() pairMsg := req.GetPair()
if pairMsg == nil || strings.TrimSpace(pairMsg.GetBase()) == "" || strings.TrimSpace(pairMsg.GetQuote()) == "" { if pairMsg == nil || strings.TrimSpace(pairMsg.GetBase()) == "" || strings.TrimSpace(pairMsg.GetQuote()) == "" {
logger.Warn("LatestRate invalid: pair missing")
return gsresponse.InvalidArgument[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, errEmptyRequest) return gsresponse.InvalidArgument[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, errEmptyRequest)
} }
pair := model.CurrencyPair{Base: strings.ToUpper(pairMsg.GetBase()), Quote: strings.ToUpper(pairMsg.GetQuote())} pair := model.CurrencyPair{Base: strings.ToUpper(pairMsg.GetBase()), Quote: strings.ToUpper(pairMsg.GetQuote())}
@@ -317,8 +366,10 @@ func (s *Service) latestRateResponder(ctx context.Context, req *oraclev1.LatestR
if err != nil { if err != nil {
switch { switch {
case errors.Is(err, merrors.ErrNoData): case errors.Is(err, merrors.ErrNoData):
logger.Warn("LatestRate pair not found")
return gsresponse.NotFound[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, err) return gsresponse.NotFound[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, err)
default: default:
logger.Warn("LatestRate failed to load pair", zap.Error(err))
return gsresponse.Internal[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, err) return gsresponse.Internal[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, err)
} }
} }
@@ -335,8 +386,10 @@ func (s *Service) latestRateResponder(ctx context.Context, req *oraclev1.LatestR
if err != nil { if err != nil {
switch { switch {
case errors.Is(err, merrors.ErrNoData): case errors.Is(err, merrors.ErrNoData):
logger.Warn("LatestRate not found", zap.String("provider", provider))
return gsresponse.NotFound[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, err) return gsresponse.NotFound[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, err)
default: default:
logger.Warn("LatestRate failed", zap.Error(err))
return gsresponse.Internal[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, err) return gsresponse.Internal[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, err)
} }
} }
@@ -345,6 +398,7 @@ func (s *Service) latestRateResponder(ctx context.Context, req *oraclev1.LatestR
Meta: buildResponseMeta(req.GetMeta()), Meta: buildResponseMeta(req.GetMeta()),
Rate: rateModelToProto(rate), Rate: rateModelToProto(rate),
} }
logger.Debug("LatestRate succeeded", zap.String("provider", provider), zap.Int64("asof_unix_ms", rate.AsOfUnixMs))
return gsresponse.Success(resp) return gsresponse.Success(resp)
} }
@@ -352,13 +406,15 @@ func (s *Service) listPairsResponder(ctx context.Context, req *oraclev1.ListPair
if req == nil { if req == nil {
req = &oraclev1.ListPairsRequest{} req = &oraclev1.ListPairsRequest{}
} }
s.logger.Debug("Handling ListPairs") logger := s.logger.With(requestMetaFields(req.GetMeta())...)
logger.Debug("Handling ListPairs")
if err := s.pingStorage(ctx); err != nil { if err := s.pingStorage(ctx); err != nil {
s.logger.Warn("Storage unavailable during ListPairs", zap.Error(err)) logger.Warn("Storage unavailable during ListPairs", zap.Error(err))
return gsresponse.Unavailable[oraclev1.ListPairsResponse](s.logger, mservice.FXOracle, err) return gsresponse.Unavailable[oraclev1.ListPairsResponse](s.logger, mservice.FXOracle, err)
} }
pairs, err := s.storage.Pairs().ListEnabled(ctx) pairs, err := s.storage.Pairs().ListEnabled(ctx)
if err != nil { if err != nil {
logger.Warn("ListPairs failed", zap.Error(err))
return gsresponse.Internal[oraclev1.ListPairsResponse](s.logger, mservice.FXOracle, err) return gsresponse.Internal[oraclev1.ListPairsResponse](s.logger, mservice.FXOracle, err)
} }
result := make([]*oraclev1.PairMeta, 0, len(pairs)) result := make([]*oraclev1.PairMeta, 0, len(pairs))
@@ -369,7 +425,7 @@ func (s *Service) listPairsResponder(ctx context.Context, req *oraclev1.ListPair
Meta: buildResponseMeta(req.GetMeta()), Meta: buildResponseMeta(req.GetMeta()),
Pairs: result, Pairs: result,
} }
s.logger.Debug("ListPairs returning metadata", zap.Int("pairs", len(resp.GetPairs()))) logger.Debug("ListPairs returning metadata", zap.Int("pairs", len(resp.GetPairs())))
return gsresponse.Success(resp) return gsresponse.Success(resp)
} }

View File

@@ -9,6 +9,7 @@ import (
"github.com/tech/sendico/fx/storage" "github.com/tech/sendico/fx/storage"
"github.com/tech/sendico/fx/storage/model" "github.com/tech/sendico/fx/storage/model"
"github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/merrors"
smodel "github.com/tech/sendico/pkg/model"
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1" fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1" tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
@@ -381,8 +382,8 @@ func TestServiceValidateQuote(t *testing.T) {
Pair: model.CurrencyPair{Base: "USD", Quote: "EUR"}, Pair: model.CurrencyPair{Base: "USD", Quote: "EUR"},
Side: model.QuoteSideBuyBaseSellQuote, Side: model.QuoteSideBuyBaseSellQuote,
Price: "1.10", Price: "1.10",
BaseAmount: model.Money{Currency: "USD", Amount: "100"}, BaseAmount: smodel.Money{Currency: "USD", Amount: "100"},
QuoteAmount: model.Money{Currency: "EUR", Amount: "110"}, QuoteAmount: smodel.Money{Currency: "EUR", Amount: "110"},
ExpiresAtUnixMs: now.UnixMilli(), ExpiresAtUnixMs: now.UnixMilli(),
Status: model.QuoteStatusIssued, Status: model.QuoteStatusIssued,
}, nil }, nil

View File

@@ -4,9 +4,9 @@ import (
"strings" "strings"
"github.com/tech/sendico/fx/storage/model" "github.com/tech/sendico/fx/storage/model"
smodel "github.com/tech/sendico/pkg/model"
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1" fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
) )
@@ -15,18 +15,11 @@ func buildResponseMeta(meta *oraclev1.RequestMeta) *oraclev1.ResponseMeta {
if meta == nil { if meta == nil {
return resp return resp
} }
resp.RequestRef = meta.GetRequestRef()
resp.TraceRef = meta.GetTraceRef()
trace := meta.GetTrace() trace := meta.GetTrace()
if trace == nil { if trace != nil {
trace = &tracev1.TraceContext{ resp.Trace = trace
RequestRef: meta.GetRequestRef(),
IdempotencyKey: meta.GetIdempotencyKey(),
TraceRef: meta.GetTraceRef(),
}
} }
resp.Trace = trace
return resp return resp
} }
@@ -49,7 +42,7 @@ func quoteModelToProto(q *model.Quote) *oraclev1.Quote {
} }
} }
func moneyModelToProto(m *model.Money) *moneyv1.Money { func moneyModelToProto(m *smodel.Money) *moneyv1.Money {
if m == nil { if m == nil {
return nil return nil
} }

View File

@@ -28,5 +28,5 @@ require (
golang.org/x/crypto v0.46.0 // indirect golang.org/x/crypto v0.46.0 // indirect
golang.org/x/sync v0.19.0 // indirect golang.org/x/sync v0.19.0 // indirect
golang.org/x/text v0.32.0 // indirect golang.org/x/text v0.32.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect google.golang.org/protobuf v1.36.11 // indirect
) )

View File

@@ -169,7 +169,7 @@ golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBn
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -4,6 +4,7 @@ import (
"time" "time"
"github.com/tech/sendico/pkg/db/storable" "github.com/tech/sendico/pkg/db/storable"
"github.com/tech/sendico/pkg/model"
) )
// Quote represents a firm or indicative quote persisted by the oracle. // Quote represents a firm or indicative quote persisted by the oracle.
@@ -16,8 +17,8 @@ type Quote struct {
Pair CurrencyPair `bson:"pair" json:"pair"` Pair CurrencyPair `bson:"pair" json:"pair"`
Side QuoteSide `bson:"side" json:"side"` Side QuoteSide `bson:"side" json:"side"`
Price string `bson:"price" json:"price"` Price string `bson:"price" json:"price"`
BaseAmount Money `bson:"baseAmount" json:"baseAmount"` BaseAmount model.Money `bson:"baseAmount" json:"baseAmount"`
QuoteAmount Money `bson:"quoteAmount" json:"quoteAmount"` QuoteAmount model.Money `bson:"quoteAmount" json:"quoteAmount"`
AmountType QuoteAmountType `bson:"amountType" json:"amountType"` AmountType QuoteAmountType `bson:"amountType" json:"amountType"`
ExpiresAtUnixMs int64 `bson:"expiresAtUnixMs" json:"expiresAtUnixMs"` ExpiresAtUnixMs int64 `bson:"expiresAtUnixMs" json:"expiresAtUnixMs"`
ExpiresAt *time.Time `bson:"expiresAt,omitempty" json:"expiresAt,omitempty"` ExpiresAt *time.Time `bson:"expiresAt,omitempty" json:"expiresAt,omitempty"`

View File

@@ -51,12 +51,6 @@ type CurrencyPair struct {
Quote string `bson:"quote" json:"quote"` Quote string `bson:"quote" json:"quote"`
} }
// Money represents an exact decimal amount with its currency.
type Money struct {
Currency string `bson:"currency" json:"currency"`
Amount string `bson:"amount" json:"amount"`
}
// QuoteMeta carries request-scoped metadata associated with a quote. // QuoteMeta carries request-scoped metadata associated with a quote.
type QuoteMeta struct { type QuoteMeta struct {
model.OrganizationBoundBase `bson:",inline" json:",inline"` model.OrganizationBoundBase `bson:",inline" json:",inline"`

View File

@@ -35,7 +35,7 @@ messaging:
chains: chains:
- name: arbitrum_one - name: arbitrum_one
rpc_url_env: CHAIN_GATEWAY_ARBITRUM_RPC_URL rpc_url_env: CHAIN_GATEWAY_RPC_URL
tokens: tokens:
- symbol: USDC - symbol: USDC
contract: "0xaf88d065e77c8cc2239327c5edb3a432268e5831" contract: "0xaf88d065e77c8cc2239327c5edb3a432268e5831"

View File

@@ -15,14 +15,14 @@ require (
github.com/tech/sendico/pkg v0.1.0 github.com/tech/sendico/pkg v0.1.0
go.mongodb.org/mongo-driver v1.17.6 go.mongodb.org/mongo-driver v1.17.6
go.uber.org/zap v1.27.1 go.uber.org/zap v1.27.1
google.golang.org/grpc v1.77.0 google.golang.org/grpc v1.78.0
google.golang.org/protobuf v1.36.10 google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
require ( require (
github.com/Microsoft/go-winio v0.6.2 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251208031133-be43a854e4be // indirect github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251222215617-2e6965a531ff // indirect
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/bits-and-blooms/bitset v1.24.4 // indirect github.com/bits-and-blooms/bitset v1.24.4 // indirect
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
@@ -60,7 +60,7 @@ require (
github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/montanaflynn/stats v0.7.1 // indirect github.com/montanaflynn/stats v0.7.1 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nats-io/nats.go v1.47.0 // indirect github.com/nats-io/nats.go v1.48.0 // indirect
github.com/nats-io/nkeys v0.4.12 // indirect github.com/nats-io/nkeys v0.4.12 // indirect
github.com/nats-io/nuid v1.0.1 // indirect github.com/nats-io/nuid v1.0.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
@@ -86,5 +86,5 @@ require (
golang.org/x/sys v0.39.0 // indirect golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect golang.org/x/text v0.32.0 // indirect
golang.org/x/time v0.14.0 // indirect golang.org/x/time v0.14.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // 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/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 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251208031133-be43a854e4be h1:1LtMLkGIqE5IQZ7Vdh4zv8A6LECInKF86/fTVxKxYLE= github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251222215617-2e6965a531ff h1:dkcn0B/pE1RTOeW9MB/fCxpKq0QaeWE6LkCmzURNE4g=
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251208031133-be43a854e4be/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI= github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251222215617-2e6965a531ff/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0= 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/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
@@ -207,8 +207,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nats-io/nats.go v1.47.0 h1:YQdADw6J/UfGUd2Oy6tn4Hq6YHxCaJrVKayxxFqYrgM= github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
github.com/nats-io/nats.go v1.47.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc= github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg= github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
@@ -362,12 +362,12 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

View File

@@ -9,6 +9,7 @@ import (
"github.com/tech/sendico/gateway/chain/storage/model" "github.com/tech/sendico/gateway/chain/storage/model"
"github.com/tech/sendico/pkg/api/routers/gsresponse" "github.com/tech/sendico/pkg/api/routers/gsresponse"
"github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/merrors"
pkgmodel "github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice" "github.com/tech/sendico/pkg/mservice"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
"go.uber.org/zap" "go.uber.org/zap"
@@ -95,7 +96,31 @@ func (c *createManagedWalletCommand) Execute(ctx context.Context, req *chainv1.C
return gsresponse.Internal[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.Internal("key manager returned empty address")) return gsresponse.Internal[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.Internal("key manager returned empty address"))
} }
metadata := shared.CloneMetadata(req.GetMetadata())
desc := req.GetDescribable()
name := strings.TrimSpace(desc.GetName())
if name == "" {
name = strings.TrimSpace(metadata["name"])
}
var description *string
if desc != nil && desc.Description != nil {
if trimmed := strings.TrimSpace(desc.GetDescription()); trimmed != "" {
description = &trimmed
}
}
if description == nil {
if trimmed := strings.TrimSpace(metadata["description"]); trimmed != "" {
description = &trimmed
}
}
if name == "" {
name = walletRef
}
wallet := &model.ManagedWallet{ wallet := &model.ManagedWallet{
Describable: pkgmodel.Describable{
Name: name,
},
IdempotencyKey: idempotencyKey, IdempotencyKey: idempotencyKey,
WalletRef: walletRef, WalletRef: walletRef,
OrganizationRef: organizationRef, OrganizationRef: organizationRef,
@@ -106,7 +131,10 @@ func (c *createManagedWalletCommand) Execute(ctx context.Context, req *chainv1.C
DepositAddress: strings.ToLower(keyInfo.Address), DepositAddress: strings.ToLower(keyInfo.Address),
KeyReference: keyInfo.KeyID, KeyReference: keyInfo.KeyID,
Status: model.ManagedWalletStatusActive, Status: model.ManagedWalletStatusActive,
Metadata: shared.CloneMetadata(req.GetMetadata()), Metadata: metadata,
}
if description != nil {
wallet.Describable.Description = description
} }
created, err := c.deps.Storage.Wallets().Create(ctx, wallet) created, err := c.deps.Storage.Wallets().Create(ctx, wallet)

View File

@@ -1,8 +1,11 @@
package wallet package wallet
import ( import (
"strings"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared" "github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/chain/storage/model" "github.com/tech/sendico/gateway/chain/storage/model"
describablev1 "github.com/tech/sendico/pkg/proto/common/describable/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
"google.golang.org/protobuf/types/known/timestamppb" "google.golang.org/protobuf/types/known/timestamppb"
) )
@@ -16,6 +19,25 @@ func toProtoManagedWallet(wallet *model.ManagedWallet) *chainv1.ManagedWallet {
TokenSymbol: wallet.TokenSymbol, TokenSymbol: wallet.TokenSymbol,
ContractAddress: wallet.ContractAddress, ContractAddress: wallet.ContractAddress,
} }
name := strings.TrimSpace(wallet.Name)
if name == "" {
name = strings.TrimSpace(wallet.Metadata["name"])
}
if name == "" {
name = wallet.WalletRef
}
description := ""
switch {
case wallet.Description != nil:
description = strings.TrimSpace(*wallet.Description)
default:
description = strings.TrimSpace(wallet.Metadata["description"])
}
desc := &describablev1.Describable{Name: name}
if description != "" {
desc.Description = &description
}
return &chainv1.ManagedWallet{ return &chainv1.ManagedWallet{
WalletRef: wallet.WalletRef, WalletRef: wallet.WalletRef,
OrganizationRef: wallet.OrganizationRef, OrganizationRef: wallet.OrganizationRef,
@@ -26,6 +48,7 @@ func toProtoManagedWallet(wallet *model.ManagedWallet) *chainv1.ManagedWallet {
Metadata: shared.CloneMetadata(wallet.Metadata), Metadata: shared.CloneMetadata(wallet.Metadata),
CreatedAt: timestamppb.New(wallet.CreatedAt.UTC()), CreatedAt: timestamppb.New(wallet.CreatedAt.UTC()),
UpdatedAt: timestamppb.New(wallet.UpdatedAt.UTC()), UpdatedAt: timestamppb.New(wallet.UpdatedAt.UTC()),
Describable: desc,
} }
} }

View File

@@ -5,6 +5,7 @@ import (
"time" "time"
"github.com/tech/sendico/pkg/db/storable" "github.com/tech/sendico/pkg/db/storable"
pkgmodel "github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice" "github.com/tech/sendico/pkg/mservice"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
) )
@@ -19,7 +20,8 @@ const (
// ManagedWallet represents a user-controlled on-chain wallet managed by the service. // ManagedWallet represents a user-controlled on-chain wallet managed by the service.
type ManagedWallet struct { type ManagedWallet struct {
storable.Base `bson:",inline" json:",inline"` storable.Base `bson:",inline" json:",inline"`
pkgmodel.Describable `bson:",inline" json:",inline"`
IdempotencyKey string `bson:"idempotencyKey" json:"idempotencyKey"` IdempotencyKey string `bson:"idempotencyKey" json:"idempotencyKey"`
WalletRef string `bson:"walletRef" json:"walletRef"` WalletRef string `bson:"walletRef" json:"walletRef"`
@@ -77,6 +79,15 @@ func (m *ManagedWallet) Normalize() {
m.WalletRef = strings.TrimSpace(m.WalletRef) m.WalletRef = strings.TrimSpace(m.WalletRef)
m.OrganizationRef = strings.TrimSpace(m.OrganizationRef) m.OrganizationRef = strings.TrimSpace(m.OrganizationRef)
m.OwnerRef = strings.TrimSpace(m.OwnerRef) m.OwnerRef = strings.TrimSpace(m.OwnerRef)
m.Name = strings.TrimSpace(m.Name)
if m.Description != nil {
desc := strings.TrimSpace(*m.Description)
if desc == "" {
m.Description = nil
} else {
m.Description = &desc
}
}
m.Network = strings.TrimSpace(strings.ToLower(m.Network)) m.Network = strings.TrimSpace(strings.ToLower(m.Network))
m.TokenSymbol = strings.TrimSpace(strings.ToUpper(m.TokenSymbol)) m.TokenSymbol = strings.TrimSpace(strings.ToUpper(m.TokenSymbol))
m.ContractAddress = strings.TrimSpace(strings.ToLower(m.ContractAddress)) m.ContractAddress = strings.TrimSpace(strings.ToLower(m.ContractAddress))

View File

@@ -0,0 +1,84 @@
package client
import (
"context"
"strings"
"time"
"github.com/tech/sendico/pkg/merrors"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
// Client wraps the Monetix gateway gRPC API.
type Client interface {
CreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error)
CreateCardTokenPayout(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error)
GetCardPayoutStatus(ctx context.Context, req *mntxv1.GetCardPayoutStatusRequest) (*mntxv1.GetCardPayoutStatusResponse, error)
Close() error
}
type gatewayClient struct {
conn *grpc.ClientConn
client mntxv1.MntxGatewayServiceClient
cfg Config
}
// New dials the Monetix gateway.
func New(ctx context.Context, cfg Config, opts ...grpc.DialOption) (Client, error) {
cfg.setDefaults()
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...)
if err != nil {
return nil, merrors.Internal("mntx: dial failed: " + err.Error())
}
return &gatewayClient{
conn: conn,
client: mntxv1.NewMntxGatewayServiceClient(conn),
cfg: cfg,
}, nil
}
func (g *gatewayClient) Close() error {
if g.conn != nil {
return g.conn.Close()
}
return nil
}
func (g *gatewayClient) callContext(ctx context.Context) (context.Context, context.CancelFunc) {
timeout := g.cfg.CallTimeout
if timeout <= 0 {
timeout = 5 * time.Second
}
return context.WithTimeout(ctx, timeout)
}
func (g *gatewayClient) CreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error) {
ctx, cancel := g.callContext(ctx)
defer cancel()
return g.client.CreateCardPayout(ctx, req)
}
func (g *gatewayClient) CreateCardTokenPayout(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error) {
ctx, cancel := g.callContext(ctx)
defer cancel()
return g.client.CreateCardTokenPayout(ctx, req)
}
func (g *gatewayClient) GetCardPayoutStatus(ctx context.Context, req *mntxv1.GetCardPayoutStatusRequest) (*mntxv1.GetCardPayoutStatusResponse, error) {
ctx, cancel := g.callContext(ctx)
defer cancel()
return g.client.GetCardPayoutStatus(ctx, req)
}

View File

@@ -0,0 +1,19 @@
package client
import "time"
// Config holds Monetix gateway client settings.
type Config struct {
Address string
DialTimeout time.Duration
CallTimeout time.Duration
}
func (c *Config) setDefaults() {
if c.DialTimeout <= 0 {
c.DialTimeout = 5 * time.Second
}
if c.CallTimeout <= 0 {
c.CallTimeout = 10 * time.Second
}
}

View File

@@ -0,0 +1,37 @@
package client
import (
"context"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
)
// Fake implements Client for tests.
type Fake struct {
CreateCardPayoutFn func(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error)
CreateCardTokenPayoutFn func(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error)
GetCardPayoutStatusFn func(ctx context.Context, req *mntxv1.GetCardPayoutStatusRequest) (*mntxv1.GetCardPayoutStatusResponse, error)
}
func (f *Fake) CreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error) {
if f.CreateCardPayoutFn != nil {
return f.CreateCardPayoutFn(ctx, req)
}
return &mntxv1.CardPayoutResponse{}, nil
}
func (f *Fake) CreateCardTokenPayout(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error) {
if f.CreateCardTokenPayoutFn != nil {
return f.CreateCardTokenPayoutFn(ctx, req)
}
return &mntxv1.CardTokenPayoutResponse{}, nil
}
func (f *Fake) GetCardPayoutStatus(ctx context.Context, req *mntxv1.GetCardPayoutStatusRequest) (*mntxv1.GetCardPayoutStatusResponse, error) {
if f.GetCardPayoutStatusFn != nil {
return f.GetCardPayoutStatusFn(ctx, req)
}
return &mntxv1.GetCardPayoutStatusResponse{}, nil
}
func (f *Fake) Close() error { return nil }

View File

@@ -11,8 +11,8 @@ require (
github.com/shopspring/decimal v1.4.0 github.com/shopspring/decimal v1.4.0
github.com/tech/sendico/pkg v0.1.0 github.com/tech/sendico/pkg v0.1.0
go.uber.org/zap v1.27.1 go.uber.org/zap v1.27.1
google.golang.org/grpc v1.77.0 google.golang.org/grpc v1.78.0
google.golang.org/protobuf v1.36.10 google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
@@ -30,7 +30,7 @@ require (
github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/montanaflynn/stats v0.7.1 // indirect github.com/montanaflynn/stats v0.7.1 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nats-io/nats.go v1.47.0 // indirect github.com/nats-io/nats.go v1.48.0 // indirect
github.com/nats-io/nkeys v0.4.12 // indirect github.com/nats-io/nkeys v0.4.12 // indirect
github.com/nats-io/nuid v1.0.1 // indirect github.com/nats-io/nuid v1.0.1 // indirect
github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect
@@ -50,5 +50,5 @@ require (
golang.org/x/sync v0.19.0 // indirect golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect golang.org/x/text v0.32.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
) )

View File

@@ -95,8 +95,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nats-io/nats.go v1.47.0 h1:YQdADw6J/UfGUd2Oy6tn4Hq6YHxCaJrVKayxxFqYrgM= github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
github.com/nats-io/nats.go v1.47.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc= github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg= github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
@@ -214,12 +214,12 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

View File

@@ -11,8 +11,8 @@ require (
github.com/tech/sendico/pkg v0.1.0 github.com/tech/sendico/pkg v0.1.0
go.mongodb.org/mongo-driver v1.17.6 go.mongodb.org/mongo-driver v1.17.6
go.uber.org/zap v1.27.1 go.uber.org/zap v1.27.1
google.golang.org/grpc v1.77.0 google.golang.org/grpc v1.78.0
google.golang.org/protobuf v1.36.10 google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
@@ -33,7 +33,7 @@ require (
github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/montanaflynn/stats v0.7.1 // indirect github.com/montanaflynn/stats v0.7.1 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nats-io/nats.go v1.47.0 // indirect github.com/nats-io/nats.go v1.48.0 // indirect
github.com/nats-io/nkeys v0.4.12 // indirect github.com/nats-io/nkeys v0.4.12 // indirect
github.com/nats-io/nuid v1.0.1 // indirect github.com/nats-io/nuid v1.0.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
@@ -51,5 +51,5 @@ require (
golang.org/x/sync v0.19.0 // indirect golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect golang.org/x/text v0.32.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
) )

View File

@@ -95,8 +95,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nats-io/nats.go v1.47.0 h1:YQdADw6J/UfGUd2Oy6tn4Hq6YHxCaJrVKayxxFqYrgM= github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
github.com/nats-io/nats.go v1.47.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc= github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg= github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
@@ -214,12 +214,12 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

View File

@@ -62,12 +62,6 @@ const (
OutboxStatusFailed OutboxStatus = "failed" OutboxStatusFailed OutboxStatus = "failed"
) )
// Money represents an exact decimal amount with its currency.
type Money struct {
Currency string `bson:"currency" json:"currency"`
Amount string `bson:"amount" json:"amount"` // stored as string for exact decimal representation
}
// LedgerMeta carries organization-scoped metadata for ledger entities. // LedgerMeta carries organization-scoped metadata for ledger entities.
type LedgerMeta struct { type LedgerMeta struct {
model.OrganizationBoundBase `bson:",inline" json:",inline"` model.OrganizationBoundBase `bson:",inline" json:",inline"`

View File

@@ -33,7 +33,7 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/montanaflynn/stats v0.7.1 // indirect github.com/montanaflynn/stats v0.7.1 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nats-io/nats.go v1.47.0 // indirect github.com/nats-io/nats.go v1.48.0 // indirect
github.com/nats-io/nkeys v0.4.12 // indirect github.com/nats-io/nkeys v0.4.12 // indirect
github.com/nats-io/nuid v1.0.1 // indirect github.com/nats-io/nuid v1.0.1 // indirect
github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_golang v1.23.2 // indirect
@@ -52,7 +52,7 @@ require (
golang.org/x/net v0.48.0 // indirect golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect golang.org/x/sys v0.39.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
google.golang.org/grpc v1.77.0 // indirect google.golang.org/grpc v1.78.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect google.golang.org/protobuf v1.36.11 // indirect
) )

View File

@@ -99,8 +99,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nats-io/nats.go v1.47.0 h1:YQdADw6J/UfGUd2Oy6tn4Hq6YHxCaJrVKayxxFqYrgM= github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
github.com/nats-io/nats.go v1.47.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc= github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg= github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
@@ -227,12 +227,12 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

View File

@@ -17,6 +17,8 @@ import (
// Client exposes typed helpers around the payment orchestrator gRPC API. // Client exposes typed helpers around the payment orchestrator gRPC API.
type Client interface { type Client interface {
QuotePayment(ctx context.Context, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.QuotePaymentResponse, error) QuotePayment(ctx context.Context, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.QuotePaymentResponse, error)
QuotePayments(ctx context.Context, req *orchestratorv1.QuotePaymentsRequest) (*orchestratorv1.QuotePaymentsResponse, error)
InitiatePayments(ctx context.Context, req *orchestratorv1.InitiatePaymentsRequest) (*orchestratorv1.InitiatePaymentsResponse, error)
InitiatePayment(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error) InitiatePayment(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error)
CancelPayment(ctx context.Context, req *orchestratorv1.CancelPaymentRequest) (*orchestratorv1.CancelPaymentResponse, error) CancelPayment(ctx context.Context, req *orchestratorv1.CancelPaymentRequest) (*orchestratorv1.CancelPaymentResponse, error)
GetPayment(ctx context.Context, req *orchestratorv1.GetPaymentRequest) (*orchestratorv1.GetPaymentResponse, error) GetPayment(ctx context.Context, req *orchestratorv1.GetPaymentRequest) (*orchestratorv1.GetPaymentResponse, error)
@@ -29,6 +31,8 @@ type Client interface {
type grpcOrchestratorClient interface { type grpcOrchestratorClient interface {
QuotePayment(ctx context.Context, in *orchestratorv1.QuotePaymentRequest, opts ...grpc.CallOption) (*orchestratorv1.QuotePaymentResponse, error) QuotePayment(ctx context.Context, in *orchestratorv1.QuotePaymentRequest, opts ...grpc.CallOption) (*orchestratorv1.QuotePaymentResponse, error)
QuotePayments(ctx context.Context, in *orchestratorv1.QuotePaymentsRequest, opts ...grpc.CallOption) (*orchestratorv1.QuotePaymentsResponse, error)
InitiatePayments(ctx context.Context, in *orchestratorv1.InitiatePaymentsRequest, opts ...grpc.CallOption) (*orchestratorv1.InitiatePaymentsResponse, error)
InitiatePayment(ctx context.Context, in *orchestratorv1.InitiatePaymentRequest, opts ...grpc.CallOption) (*orchestratorv1.InitiatePaymentResponse, error) InitiatePayment(ctx context.Context, in *orchestratorv1.InitiatePaymentRequest, opts ...grpc.CallOption) (*orchestratorv1.InitiatePaymentResponse, error)
CancelPayment(ctx context.Context, in *orchestratorv1.CancelPaymentRequest, opts ...grpc.CallOption) (*orchestratorv1.CancelPaymentResponse, error) CancelPayment(ctx context.Context, in *orchestratorv1.CancelPaymentRequest, opts ...grpc.CallOption) (*orchestratorv1.CancelPaymentResponse, error)
GetPayment(ctx context.Context, in *orchestratorv1.GetPaymentRequest, opts ...grpc.CallOption) (*orchestratorv1.GetPaymentResponse, error) GetPayment(ctx context.Context, in *orchestratorv1.GetPaymentRequest, opts ...grpc.CallOption) (*orchestratorv1.GetPaymentResponse, error)
@@ -97,6 +101,18 @@ func (c *orchestratorClient) QuotePayment(ctx context.Context, req *orchestrator
return c.client.QuotePayment(ctx, req) return c.client.QuotePayment(ctx, req)
} }
func (c *orchestratorClient) QuotePayments(ctx context.Context, req *orchestratorv1.QuotePaymentsRequest) (*orchestratorv1.QuotePaymentsResponse, error) {
ctx, cancel := c.callContext(ctx)
defer cancel()
return c.client.QuotePayments(ctx, req)
}
func (c *orchestratorClient) InitiatePayments(ctx context.Context, req *orchestratorv1.InitiatePaymentsRequest) (*orchestratorv1.InitiatePaymentsResponse, error) {
ctx, cancel := c.callContext(ctx)
defer cancel()
return c.client.InitiatePayments(ctx, req)
}
func (c *orchestratorClient) InitiatePayment(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error) { func (c *orchestratorClient) InitiatePayment(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error) {
ctx, cancel := c.callContext(ctx) ctx, cancel := c.callContext(ctx)
defer cancel() defer cancel()

View File

@@ -9,6 +9,8 @@ import (
// Fake implements Client for tests. // Fake implements Client for tests.
type Fake struct { type Fake struct {
QuotePaymentFn func(ctx context.Context, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.QuotePaymentResponse, error) QuotePaymentFn func(ctx context.Context, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.QuotePaymentResponse, error)
QuotePaymentsFn func(ctx context.Context, req *orchestratorv1.QuotePaymentsRequest) (*orchestratorv1.QuotePaymentsResponse, error)
InitiatePaymentsFn func(ctx context.Context, req *orchestratorv1.InitiatePaymentsRequest) (*orchestratorv1.InitiatePaymentsResponse, error)
InitiatePaymentFn func(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error) InitiatePaymentFn func(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error)
CancelPaymentFn func(ctx context.Context, req *orchestratorv1.CancelPaymentRequest) (*orchestratorv1.CancelPaymentResponse, error) CancelPaymentFn func(ctx context.Context, req *orchestratorv1.CancelPaymentRequest) (*orchestratorv1.CancelPaymentResponse, error)
GetPaymentFn func(ctx context.Context, req *orchestratorv1.GetPaymentRequest) (*orchestratorv1.GetPaymentResponse, error) GetPaymentFn func(ctx context.Context, req *orchestratorv1.GetPaymentRequest) (*orchestratorv1.GetPaymentResponse, error)
@@ -26,6 +28,20 @@ func (f *Fake) QuotePayment(ctx context.Context, req *orchestratorv1.QuotePaymen
return &orchestratorv1.QuotePaymentResponse{}, nil return &orchestratorv1.QuotePaymentResponse{}, nil
} }
func (f *Fake) QuotePayments(ctx context.Context, req *orchestratorv1.QuotePaymentsRequest) (*orchestratorv1.QuotePaymentsResponse, error) {
if f.QuotePaymentsFn != nil {
return f.QuotePaymentsFn(ctx, req)
}
return &orchestratorv1.QuotePaymentsResponse{}, nil
}
func (f *Fake) InitiatePayments(ctx context.Context, req *orchestratorv1.InitiatePaymentsRequest) (*orchestratorv1.InitiatePaymentsResponse, error) {
if f.InitiatePaymentsFn != nil {
return f.InitiatePaymentsFn(ctx, req)
}
return &orchestratorv1.InitiatePaymentsResponse{}, nil
}
func (f *Fake) InitiatePayment(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error) { func (f *Fake) InitiatePayment(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error) {
if f.InitiatePaymentFn != nil { if f.InitiatePaymentFn != nil {
return f.InitiatePaymentFn(ctx, req) return f.InitiatePaymentFn(ctx, req)

View File

@@ -56,3 +56,11 @@ oracle:
dial_timeout_seconds: 5 dial_timeout_seconds: 5
call_timeout_seconds: 3 call_timeout_seconds: 3
insecure: true insecure: true
card_gateways:
monetix:
funding_address: "wallet_funding_monetix"
fee_address: "wallet_fee_monetix"
fee_ledger_accounts:
monetix: "ledger:fees:monetix"

View File

@@ -8,6 +8,8 @@ replace github.com/tech/sendico/billing/fees => ../../billing/fees
replace github.com/tech/sendico/gateway/chain => ../../gateway/chain replace github.com/tech/sendico/gateway/chain => ../../gateway/chain
replace github.com/tech/sendico/gateway/mntx => ../../gateway/mntx
replace github.com/tech/sendico/fx/oracle => ../../fx/oracle replace github.com/tech/sendico/fx/oracle => ../../fx/oracle
replace github.com/tech/sendico/ledger => ../../ledger replace github.com/tech/sendico/ledger => ../../ledger
@@ -17,12 +19,13 @@ require (
github.com/shopspring/decimal v1.4.0 github.com/shopspring/decimal v1.4.0
github.com/tech/sendico/fx/oracle v0.0.0-00010101000000-000000000000 github.com/tech/sendico/fx/oracle v0.0.0-00010101000000-000000000000
github.com/tech/sendico/gateway/chain v0.0.0-00010101000000-000000000000 github.com/tech/sendico/gateway/chain v0.0.0-00010101000000-000000000000
github.com/tech/sendico/gateway/mntx v0.0.0-00010101000000-000000000000
github.com/tech/sendico/ledger v0.0.0-00010101000000-000000000000 github.com/tech/sendico/ledger v0.0.0-00010101000000-000000000000
github.com/tech/sendico/pkg v0.1.0 github.com/tech/sendico/pkg v0.1.0
go.mongodb.org/mongo-driver v1.17.6 go.mongodb.org/mongo-driver v1.17.6
go.uber.org/zap v1.27.1 go.uber.org/zap v1.27.1
google.golang.org/grpc v1.77.0 google.golang.org/grpc v1.78.0
google.golang.org/protobuf v1.36.10 google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
@@ -42,7 +45,7 @@ require (
github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/montanaflynn/stats v0.7.1 // indirect github.com/montanaflynn/stats v0.7.1 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nats-io/nats.go v1.47.0 // indirect github.com/nats-io/nats.go v1.48.0 // indirect
github.com/nats-io/nkeys v0.4.12 // indirect github.com/nats-io/nkeys v0.4.12 // indirect
github.com/nats-io/nuid v1.0.1 // indirect github.com/nats-io/nuid v1.0.1 // indirect
github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect
@@ -59,5 +62,5 @@ require (
golang.org/x/sync v0.19.0 // indirect golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect golang.org/x/text v0.32.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
) )

View File

@@ -95,8 +95,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nats-io/nats.go v1.47.0 h1:YQdADw6J/UfGUd2Oy6tn4Hq6YHxCaJrVKayxxFqYrgM= github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
github.com/nats-io/nats.go v1.47.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc= github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg= github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
@@ -119,8 +119,8 @@ github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+L
github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI=
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI= github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI=
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
@@ -215,12 +215,12 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

View File

@@ -7,8 +7,8 @@ import (
"strings" "strings"
"time" "time"
chainclient "github.com/tech/sendico/gateway/chain/client"
oracleclient "github.com/tech/sendico/fx/oracle/client" oracleclient "github.com/tech/sendico/fx/oracle/client"
chainclient "github.com/tech/sendico/gateway/chain/client"
ledgerclient "github.com/tech/sendico/ledger/client" ledgerclient "github.com/tech/sendico/ledger/client"
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrator" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrator"
"github.com/tech/sendico/payments/orchestrator/storage" "github.com/tech/sendico/payments/orchestrator/storage"
@@ -41,10 +41,12 @@ type Imp struct {
type config struct { type config struct {
*grpcapp.Config `yaml:",inline"` *grpcapp.Config `yaml:",inline"`
Fees clientConfig `yaml:"fees"` Fees clientConfig `yaml:"fees"`
Ledger clientConfig `yaml:"ledger"` Ledger clientConfig `yaml:"ledger"`
Gateway clientConfig `yaml:"gateway"` Gateway clientConfig `yaml:"gateway"`
Oracle clientConfig `yaml:"oracle"` Oracle clientConfig `yaml:"oracle"`
CardGateways map[string]cardGatewayRouteConfig `yaml:"card_gateways"`
FeeAccounts map[string]string `yaml:"fee_ledger_accounts"`
} }
type clientConfig struct { type clientConfig struct {
@@ -54,6 +56,11 @@ type clientConfig struct {
InsecureTransport bool `yaml:"insecure"` InsecureTransport bool `yaml:"insecure"`
} }
type cardGatewayRouteConfig struct {
FundingAddress string `yaml:"funding_address"`
FeeAddress string `yaml:"fee_address"`
}
func (c clientConfig) address() string { func (c clientConfig) address() string {
return strings.TrimSpace(c.Address) return strings.TrimSpace(c.Address)
} }
@@ -107,7 +114,7 @@ func (i *Imp) Shutdown() {
func (i *Imp) Start() error { func (i *Imp) Start() error {
cfg, err := i.loadConfig() cfg, err := i.loadConfig()
if err != nil { if err != nil {
return err return err
} }
i.config = cfg i.config = cfg
@@ -150,6 +157,12 @@ func (i *Imp) Start() error {
if oracleClient != nil { if oracleClient != nil {
opts = append(opts, orchestrator.WithOracleClient(oracleClient)) opts = append(opts, orchestrator.WithOracleClient(oracleClient))
} }
if routes := buildCardGatewayRoutes(cfg.CardGateways); len(routes) > 0 {
opts = append(opts, orchestrator.WithCardGatewayRoutes(routes))
}
if feeAccounts := buildFeeLedgerAccounts(cfg.FeeAccounts); len(feeAccounts) > 0 {
opts = append(opts, orchestrator.WithFeeLedgerAccounts(feeAccounts))
}
return orchestrator.NewService(logger, repo, opts...), nil return orchestrator.NewService(logger, repo, opts...), nil
} }
@@ -296,3 +309,37 @@ func (i *Imp) loadConfig() (*config, error) {
return cfg, nil return cfg, nil
} }
func buildCardGatewayRoutes(src map[string]cardGatewayRouteConfig) map[string]orchestrator.CardGatewayRoute {
if len(src) == 0 {
return nil
}
result := make(map[string]orchestrator.CardGatewayRoute, len(src))
for key, route := range src {
trimmedKey := strings.TrimSpace(key)
if trimmedKey == "" {
continue
}
result[trimmedKey] = orchestrator.CardGatewayRoute{
FundingAddress: strings.TrimSpace(route.FundingAddress),
FeeAddress: strings.TrimSpace(route.FeeAddress),
}
}
return result
}
func buildFeeLedgerAccounts(src map[string]string) map[string]string {
if len(src) == 0 {
return nil
}
result := make(map[string]string, len(src))
for key, account := range src {
k := strings.ToLower(strings.TrimSpace(key))
v := strings.TrimSpace(account)
if k == "" || v == "" {
continue
}
result[k] = v
}
return result
}

View File

@@ -0,0 +1,252 @@
package orchestrator
import (
"context"
"strings"
"github.com/shopspring/decimal"
"github.com/tech/sendico/payments/orchestrator/storage/model"
"github.com/tech/sendico/pkg/merrors"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
"go.uber.org/zap"
)
const defaultCardGateway = "monetix"
func (s *Service) cardRoute(gateway string) (CardGatewayRoute, error) {
if len(s.deps.cardRoutes) == 0 {
s.logger.Warn("card routing not configured", zap.String("gateway", gateway))
return CardGatewayRoute{}, merrors.InvalidArgument("card routing not configured")
}
key := strings.ToLower(strings.TrimSpace(gateway))
if key == "" {
key = defaultCardGateway
}
route, ok := s.deps.cardRoutes[key]
if !ok {
s.logger.Warn("card routing missing for gateway", zap.String("gateway", key))
return CardGatewayRoute{}, merrors.InvalidArgument("card routing missing for gateway " + key)
}
if strings.TrimSpace(route.FundingAddress) == "" {
s.logger.Warn("card routing missing funding address", zap.String("gateway", key))
return CardGatewayRoute{}, merrors.InvalidArgument("card funding address is required for gateway " + key)
}
return route, nil
}
func (s *Service) submitCardFundingTransfers(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote) error {
if payment == nil {
return merrors.InvalidArgument("payment is required")
}
intent := payment.Intent
source := intent.Source.ManagedWallet
if source == nil || strings.TrimSpace(source.ManagedWalletRef) == "" {
return merrors.InvalidArgument("card funding: source managed wallet is required")
}
if !s.deps.gateway.available() {
s.logger.Warn("card funding aborted: chain gateway unavailable")
return merrors.InvalidArgument("card funding: chain gateway unavailable")
}
route, err := s.cardRoute(defaultCardGateway)
if err != nil {
return err
}
amount := cloneMoney(intent.Amount)
if amount == nil {
return merrors.InvalidArgument("card funding: amount is required")
}
exec := payment.Execution
if exec == nil {
exec = &model.ExecutionRefs{}
}
// Transfer payout amount to funding wallet.
fundReq := &chainv1.SubmitTransferRequest{
IdempotencyKey: payment.IdempotencyKey + ":card:fund",
OrganizationRef: payment.OrganizationRef.Hex(),
SourceWalletRef: strings.TrimSpace(source.ManagedWalletRef),
Destination: &chainv1.TransferDestination{
Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: strings.TrimSpace(route.FundingAddress)},
},
Amount: amount,
Metadata: cloneMetadata(payment.Metadata),
ClientReference: payment.PaymentRef,
}
fundResp, err := s.deps.gateway.client.SubmitTransfer(ctx, fundReq)
if err != nil {
s.logger.Warn("card funding transfer failed", zap.Error(err), zap.String("payment_ref", payment.PaymentRef))
return err
}
if fundResp != nil && fundResp.GetTransfer() != nil {
exec.ChainTransferRef = strings.TrimSpace(fundResp.GetTransfer().GetTransferRef())
}
s.logger.Info("card funding transfer submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", exec.ChainTransferRef))
feeMoney := quote.GetExpectedFeeTotal()
if feeMoney != nil && strings.TrimSpace(feeMoney.GetAmount()) != "" {
if strings.TrimSpace(route.FeeAddress) == "" {
return merrors.InvalidArgument("card funding: fee address is required when fee exists")
}
feeDecimal, err := decimalFromMoney(feeMoney)
if err != nil {
return err
}
if feeDecimal.IsPositive() {
feeReq := &chainv1.SubmitTransferRequest{
IdempotencyKey: payment.IdempotencyKey + ":card:fee",
OrganizationRef: payment.OrganizationRef.Hex(),
SourceWalletRef: strings.TrimSpace(source.ManagedWalletRef),
Destination: &chainv1.TransferDestination{
Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: strings.TrimSpace(route.FeeAddress)},
},
Amount: feeMoney,
Metadata: cloneMetadata(payment.Metadata),
ClientReference: payment.PaymentRef,
}
feeResp, feeErr := s.deps.gateway.client.SubmitTransfer(ctx, feeReq)
if feeErr != nil {
s.logger.Warn("card fee transfer failed", zap.Error(feeErr), zap.String("payment_ref", payment.PaymentRef))
return feeErr
}
if feeResp != nil && feeResp.GetTransfer() != nil {
exec.FeeTransferRef = strings.TrimSpace(feeResp.GetTransfer().GetTransferRef())
}
s.logger.Info("card fee transfer submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", exec.FeeTransferRef))
}
}
payment.Execution = exec
return nil
}
func (s *Service) submitCardPayout(ctx context.Context, payment *model.Payment) error {
if payment == nil {
return merrors.InvalidArgument("payment is required")
}
intent := payment.Intent
card := intent.Destination.Card
if card == nil {
return merrors.InvalidArgument("card payout: card endpoint is required")
}
amount := cloneMoney(intent.Amount)
if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" {
return merrors.InvalidArgument("card payout: amount is required")
}
amtDec, err := decimalFromMoney(amount)
if err != nil {
return err
}
minor := amtDec.Mul(decimal.NewFromInt(100)).IntPart()
payoutID := payment.PaymentRef
currency := strings.TrimSpace(amount.GetCurrency())
holder := strings.TrimSpace(card.Cardholder)
meta := cloneMetadata(payment.Metadata)
var (
state *mntxv1.CardPayoutState
)
if token := strings.TrimSpace(card.Token); token != "" {
req := &mntxv1.CardTokenPayoutRequest{
PayoutId: payoutID,
AmountMinor: minor,
Currency: currency,
CardToken: token,
CardHolder: holder,
MaskedPan: strings.TrimSpace(card.MaskedPan),
Metadata: meta,
}
resp, err := s.deps.mntx.client.CreateCardTokenPayout(ctx, req)
if err != nil {
s.logger.Warn("card token payout failed", zap.Error(err), zap.String("payment_ref", payment.PaymentRef))
return err
}
state = resp.GetPayout()
} else if pan := strings.TrimSpace(card.Pan); pan != "" {
req := &mntxv1.CardPayoutRequest{
PayoutId: payoutID,
AmountMinor: minor,
Currency: currency,
CardPan: pan,
CardExpYear: card.ExpYear,
CardExpMonth: card.ExpMonth,
CardHolder: holder,
Metadata: meta,
}
resp, err := s.deps.mntx.client.CreateCardPayout(ctx, req)
if err != nil {
s.logger.Warn("card payout failed", zap.Error(err), zap.String("payment_ref", payment.PaymentRef))
return err
}
state = resp.GetPayout()
} else {
return merrors.InvalidArgument("card payout: either token or pan must be provided")
}
if state == nil {
return merrors.Internal("card payout: missing payout state")
}
recordCardPayoutState(payment, state)
if payment.Execution == nil {
payment.Execution = &model.ExecutionRefs{}
}
if payment.Execution.CardPayoutRef == "" {
payment.Execution.CardPayoutRef = strings.TrimSpace(state.GetPayoutId())
}
s.logger.Info("card payout submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("payout_id", payment.Execution.CardPayoutRef))
return nil
}
func recordCardPayoutState(payment *model.Payment, state *mntxv1.CardPayoutState) {
if payment == nil || state == nil {
return
}
if payment.CardPayout == nil {
payment.CardPayout = &model.CardPayout{}
}
payment.CardPayout.PayoutRef = strings.TrimSpace(state.GetPayoutId())
payment.CardPayout.ProviderPaymentID = strings.TrimSpace(state.GetProviderPaymentId())
payment.CardPayout.Status = state.GetStatus().String()
payment.CardPayout.FailureReason = strings.TrimSpace(state.GetProviderMessage())
payment.CardPayout.ProviderCode = strings.TrimSpace(state.GetProviderCode())
if payment.CardPayout.CardCountry == "" && payment.Intent.Destination.Card != nil {
payment.CardPayout.CardCountry = strings.TrimSpace(payment.Intent.Destination.Card.Country)
}
if payment.CardPayout.MaskedPan == "" && payment.Intent.Destination.Card != nil {
payment.CardPayout.MaskedPan = strings.TrimSpace(payment.Intent.Destination.Card.MaskedPan)
}
payment.CardPayout.GatewayReference = strings.TrimSpace(state.GetPayoutId())
}
func applyCardPayoutUpdate(payment *model.Payment, payout *mntxv1.CardPayoutState) {
if payment == nil || payout == nil {
return
}
recordCardPayoutState(payment, payout)
if payment.Execution == nil {
payment.Execution = &model.ExecutionRefs{}
}
if payment.Execution.CardPayoutRef == "" {
payment.Execution.CardPayoutRef = strings.TrimSpace(payout.GetPayoutId())
}
payment.State = mapMntxStatusToState(payout.GetStatus())
switch payout.GetStatus() {
case mntxv1.PayoutStatus_PAYOUT_STATUS_PROCESSED:
payment.FailureCode = model.PaymentFailureCodeUnspecified
payment.FailureReason = ""
case mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED:
payment.FailureCode = model.PaymentFailureCodePolicy
payment.FailureReason = strings.TrimSpace(payout.GetProviderMessage())
default:
// leave as-is for pending/unspecified
}
}

View File

@@ -0,0 +1,97 @@
package orchestrator
import (
"context"
"time"
"github.com/tech/sendico/payments/orchestrator/storage"
"github.com/tech/sendico/payments/orchestrator/storage/model"
"github.com/tech/sendico/pkg/mlogger"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
)
type paymentEngine interface {
EnsureRepository(ctx context.Context) error
BuildPaymentQuote(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.PaymentQuote, time.Time, error)
ResolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, error)
ExecutePayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, quote *orchestratorv1.PaymentQuote) error
Repository() storage.Repository
}
type defaultPaymentEngine struct {
svc *Service
}
func (e defaultPaymentEngine) EnsureRepository(ctx context.Context) error {
return e.svc.ensureRepository(ctx)
}
func (e defaultPaymentEngine) BuildPaymentQuote(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.PaymentQuote, time.Time, error) {
return e.svc.buildPaymentQuote(ctx, orgRef, req)
}
func (e defaultPaymentEngine) ResolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, error) {
return e.svc.resolvePaymentQuote(ctx, in)
}
func (e defaultPaymentEngine) ExecutePayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, quote *orchestratorv1.PaymentQuote) error {
return e.svc.executePayment(ctx, store, payment, quote)
}
func (e defaultPaymentEngine) Repository() storage.Repository {
return e.svc.storage
}
type paymentCommandFactory struct {
engine paymentEngine
logger mlogger.Logger
}
func newPaymentCommandFactory(engine paymentEngine, logger mlogger.Logger) *paymentCommandFactory {
return &paymentCommandFactory{
engine: engine,
logger: logger.Named("commands"),
}
}
func (f *paymentCommandFactory) QuotePayment() *quotePaymentCommand {
return &quotePaymentCommand{
engine: f.engine,
logger: f.logger.Named("quote_payment"),
}
}
func (f *paymentCommandFactory) QuotePayments() *quotePaymentsCommand {
return &quotePaymentsCommand{
engine: f.engine,
logger: f.logger.Named("quote_payments"),
}
}
func (f *paymentCommandFactory) InitiatePayment() *initiatePaymentCommand {
return &initiatePaymentCommand{
engine: f.engine,
logger: f.logger.Named("initiate_payment"),
}
}
func (f *paymentCommandFactory) InitiatePayments() *initiatePaymentsCommand {
return &initiatePaymentsCommand{
engine: f.engine,
logger: f.logger.Named("initiate_payments"),
}
}
func (f *paymentCommandFactory) CancelPayment() *cancelPaymentCommand {
return &cancelPaymentCommand{
engine: f.engine,
logger: f.logger.Named("cancel_payment"),
}
}
func (f *paymentCommandFactory) InitiateConversion() *initiateConversionCommand {
return &initiateConversionCommand{
engine: f.engine,
logger: f.logger.Named("initiate_conversion"),
}
}

View File

@@ -5,9 +5,7 @@ import (
"time" "time"
"github.com/tech/sendico/payments/orchestrator/storage/model" "github.com/tech/sendico/payments/orchestrator/storage/model"
"github.com/tech/sendico/pkg/merrors"
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1" fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
@@ -20,13 +18,14 @@ func intentFromProto(src *orchestratorv1.PaymentIntent) model.PaymentIntent {
return model.PaymentIntent{} return model.PaymentIntent{}
} }
intent := model.PaymentIntent{ intent := model.PaymentIntent{
Kind: modelKindFromProto(src.GetKind()), Kind: modelKindFromProto(src.GetKind()),
Source: endpointFromProto(src.GetSource()), Source: endpointFromProto(src.GetSource()),
Destination: endpointFromProto(src.GetDestination()), Destination: endpointFromProto(src.GetDestination()),
Amount: cloneMoney(src.GetAmount()), Amount: cloneMoney(src.GetAmount()),
RequiresFX: src.GetRequiresFx(), RequiresFX: src.GetRequiresFx(),
FeePolicy: src.GetFeePolicy(), FeePolicy: src.GetFeePolicy(),
Attributes: cloneMetadata(src.GetAttributes()), SettlementMode: src.GetSettlementMode(),
Attributes: cloneMetadata(src.GetAttributes()),
} }
if src.GetFx() != nil { if src.GetFx() != nil {
intent.FX = fxIntentFromProto(src.GetFx()) intent.FX = fxIntentFromProto(src.GetFx())
@@ -67,6 +66,19 @@ func endpointFromProto(src *orchestratorv1.PaymentEndpoint) model.PaymentEndpoin
} }
return result return result
} }
if card := src.GetCard(); card != nil {
result.Type = model.EndpointTypeCard
result.Card = &model.CardEndpoint{
Pan: strings.TrimSpace(card.GetPan()),
Token: strings.TrimSpace(card.GetToken()),
Cardholder: strings.TrimSpace(card.GetCardholderName()),
ExpMonth: card.GetExpMonth(),
ExpYear: card.GetExpYear(),
Country: strings.TrimSpace(card.GetCountry()),
MaskedPan: strings.TrimSpace(card.GetMaskedPan()),
}
return result
}
return result return result
} }
@@ -96,7 +108,6 @@ func quoteSnapshotToModel(src *orchestratorv1.PaymentQuote) *model.PaymentQuoteS
FeeRules: cloneFeeRules(src.GetFeeRules()), FeeRules: cloneFeeRules(src.GetFeeRules()),
FXQuote: cloneFXQuote(src.GetFxQuote()), FXQuote: cloneFXQuote(src.GetFxQuote()),
NetworkFee: cloneNetworkEstimate(src.GetNetworkFee()), NetworkFee: cloneNetworkEstimate(src.GetNetworkFee()),
FeeQuoteToken: strings.TrimSpace(src.GetFeeQuoteToken()),
QuoteRef: strings.TrimSpace(src.GetQuoteRef()), QuoteRef: strings.TrimSpace(src.GetQuoteRef()),
} }
} }
@@ -116,6 +127,18 @@ func toProtoPayment(src *model.Payment) *orchestratorv1.Payment {
Execution: protoExecutionFromModel(src.Execution), Execution: protoExecutionFromModel(src.Execution),
Metadata: cloneMetadata(src.Metadata), Metadata: cloneMetadata(src.Metadata),
} }
if src.CardPayout != nil {
payment.CardPayout = &orchestratorv1.CardPayout{
PayoutRef: src.CardPayout.PayoutRef,
ProviderPaymentId: src.CardPayout.ProviderPaymentID,
Status: src.CardPayout.Status,
FailureReason: src.CardPayout.FailureReason,
CardCountry: src.CardPayout.CardCountry,
MaskedPan: src.CardPayout.MaskedPan,
ProviderCode: src.CardPayout.ProviderCode,
GatewayReference: src.CardPayout.GatewayReference,
}
}
if src.CreatedAt.IsZero() { if src.CreatedAt.IsZero() {
payment.CreatedAt = timestamppb.New(time.Now().UTC()) payment.CreatedAt = timestamppb.New(time.Now().UTC())
} else { } else {
@@ -129,13 +152,14 @@ func toProtoPayment(src *model.Payment) *orchestratorv1.Payment {
func protoIntentFromModel(src model.PaymentIntent) *orchestratorv1.PaymentIntent { func protoIntentFromModel(src model.PaymentIntent) *orchestratorv1.PaymentIntent {
intent := &orchestratorv1.PaymentIntent{ intent := &orchestratorv1.PaymentIntent{
Kind: protoKindFromModel(src.Kind), Kind: protoKindFromModel(src.Kind),
Source: protoEndpointFromModel(src.Source), Source: protoEndpointFromModel(src.Source),
Destination: protoEndpointFromModel(src.Destination), Destination: protoEndpointFromModel(src.Destination),
Amount: cloneMoney(src.Amount), Amount: cloneMoney(src.Amount),
RequiresFx: src.RequiresFX, RequiresFx: src.RequiresFX,
FeePolicy: src.FeePolicy, FeePolicy: src.FeePolicy,
Attributes: cloneMetadata(src.Attributes), SettlementMode: src.SettlementMode,
Attributes: cloneMetadata(src.Attributes),
} }
if src.FX != nil { if src.FX != nil {
intent.Fx = protoFXIntentFromModel(src.FX) intent.Fx = protoFXIntentFromModel(src.FX)
@@ -176,6 +200,23 @@ func protoEndpointFromModel(src model.PaymentEndpoint) *orchestratorv1.PaymentEn
}, },
} }
} }
case model.EndpointTypeCard:
if src.Card != nil {
card := &orchestratorv1.CardEndpoint{
CardholderName: src.Card.Cardholder,
ExpMonth: src.Card.ExpMonth,
ExpYear: src.Card.ExpYear,
Country: src.Card.Country,
MaskedPan: src.Card.MaskedPan,
}
if pan := strings.TrimSpace(src.Card.Pan); pan != "" {
card.Card = &orchestratorv1.CardEndpoint_Pan{Pan: pan}
}
if token := strings.TrimSpace(src.Card.Token); token != "" {
card.Card = &orchestratorv1.CardEndpoint_Token{Token: token}
}
endpoint.Endpoint = &orchestratorv1.PaymentEndpoint_Card{Card: card}
}
default: default:
// leave unspecified // leave unspecified
} }
@@ -205,6 +246,8 @@ func protoExecutionFromModel(src *model.ExecutionRefs) *orchestratorv1.Execution
CreditEntryRef: src.CreditEntryRef, CreditEntryRef: src.CreditEntryRef,
FxEntryRef: src.FXEntryRef, FxEntryRef: src.FXEntryRef,
ChainTransferRef: src.ChainTransferRef, ChainTransferRef: src.ChainTransferRef,
CardPayoutRef: src.CardPayoutRef,
FeeTransferRef: src.FeeTransferRef,
} }
} }
@@ -220,7 +263,6 @@ func modelQuoteToProto(src *model.PaymentQuoteSnapshot) *orchestratorv1.PaymentQ
FeeRules: cloneFeeRules(src.FeeRules), FeeRules: cloneFeeRules(src.FeeRules),
FxQuote: cloneFXQuote(src.FXQuote), FxQuote: cloneFXQuote(src.FXQuote),
NetworkFee: cloneNetworkEstimate(src.NetworkFee), NetworkFee: cloneNetworkEstimate(src.NetworkFee),
FeeQuoteToken: src.FeeQuoteToken,
QuoteRef: strings.TrimSpace(src.QuoteRef), QuoteRef: strings.TrimSpace(src.QuoteRef),
} }
} }
@@ -369,60 +411,3 @@ func cloneNetworkEstimate(resp *chainv1.EstimateTransferFeeResponse) *chainv1.Es
} }
return nil return nil
} }
func protoFailureToModel(code orchestratorv1.PaymentFailureCode) model.PaymentFailureCode {
switch code {
case orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_BALANCE:
return model.PaymentFailureCodeBalance
case orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_LEDGER:
return model.PaymentFailureCodeLedger
case orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_FX:
return model.PaymentFailureCodeFX
case orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_CHAIN:
return model.PaymentFailureCodeChain
case orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_FEES:
return model.PaymentFailureCodeFees
case orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_POLICY:
return model.PaymentFailureCodePolicy
default:
return model.PaymentFailureCodeUnspecified
}
}
func applyProtoPaymentToModel(src *orchestratorv1.Payment, dst *model.Payment) error {
if src == nil || dst == nil {
return merrors.InvalidArgument("payment payload is required")
}
dst.PaymentRef = strings.TrimSpace(src.GetPaymentRef())
dst.IdempotencyKey = strings.TrimSpace(src.GetIdempotencyKey())
dst.Intent = intentFromProto(src.GetIntent())
dst.State = modelStateFromProto(src.GetState())
dst.FailureCode = protoFailureToModel(src.GetFailureCode())
dst.FailureReason = strings.TrimSpace(src.GetFailureReason())
dst.Metadata = cloneMetadata(src.GetMetadata())
dst.LastQuote = quoteSnapshotToModel(src.GetLastQuote())
dst.Execution = executionFromProto(src.GetExecution())
return nil
}
func executionFromProto(src *orchestratorv1.ExecutionRefs) *model.ExecutionRefs {
if src == nil {
return nil
}
return &model.ExecutionRefs{
DebitEntryRef: strings.TrimSpace(src.GetDebitEntryRef()),
CreditEntryRef: strings.TrimSpace(src.GetCreditEntryRef()),
FXEntryRef: strings.TrimSpace(src.GetFxEntryRef()),
ChainTransferRef: strings.TrimSpace(src.GetChainTransferRef()),
}
}
func ensurePageRequest(req *orchestratorv1.ListPaymentsRequest) *paginationv1.CursorPageRequest {
if req == nil {
return &paginationv1.CursorPageRequest{}
}
if req.GetPage() == nil {
return &paginationv1.CursorPageRequest{}
}
return req.GetPage()
}

View File

@@ -0,0 +1,69 @@
package orchestrator
import (
"testing"
"github.com/tech/sendico/payments/orchestrator/storage/model"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
)
func TestEndpointFromProtoCard(t *testing.T) {
protoEndpoint := &orchestratorv1.PaymentEndpoint{
Endpoint: &orchestratorv1.PaymentEndpoint_Card{
Card: &orchestratorv1.CardEndpoint{
Card: &orchestratorv1.CardEndpoint_Pan{Pan: " 411111 "},
CardholderName: " Jane Doe ",
ExpMonth: 12,
ExpYear: 2030,
Country: " US ",
MaskedPan: " ****1111 ",
},
},
Metadata: map[string]string{"k": "v"},
}
modelEndpoint := endpointFromProto(protoEndpoint)
if modelEndpoint.Type != model.EndpointTypeCard {
t.Fatalf("expected card type, got %s", modelEndpoint.Type)
}
if modelEndpoint.Card == nil {
t.Fatalf("card payload missing")
}
if modelEndpoint.Card.Pan != "411111" || modelEndpoint.Card.Cardholder != "Jane Doe" || modelEndpoint.Card.Country != "US" || modelEndpoint.Card.MaskedPan != "****1111" {
t.Fatalf("card payload not trimmed as expected: %#v", modelEndpoint.Card)
}
if modelEndpoint.Metadata["k"] != "v" {
t.Fatalf("metadata not preserved")
}
}
func TestProtoEndpointFromModelCard(t *testing.T) {
modelEndpoint := model.PaymentEndpoint{
Type: model.EndpointTypeCard,
Card: &model.CardEndpoint{
Token: "tok_123",
Cardholder: "Jane",
ExpMonth: 1,
ExpYear: 2028,
Country: "GB",
MaskedPan: "****1234",
},
Metadata: map[string]string{"k": "v"},
}
protoEndpoint := protoEndpointFromModel(modelEndpoint)
card := protoEndpoint.GetCard()
if card == nil {
t.Fatalf("card payload missing in proto")
}
token, ok := card.Card.(*orchestratorv1.CardEndpoint_Token)
if !ok || token.Token != "tok_123" {
t.Fatalf("expected token payload, got %T %#v", card.Card, card.Card)
}
if card.GetCardholderName() != "Jane" || card.GetCountry() != "GB" || card.GetMaskedPan() != "****1234" {
t.Fatalf("card details mismatch: %#v", card)
}
if protoEndpoint.GetMetadata()["k"] != "v" {
t.Fatalf("metadata not preserved in proto endpoint")
}
}

View File

@@ -0,0 +1,446 @@
package orchestrator
import (
"context"
"errors"
"strings"
"time"
"github.com/tech/sendico/payments/orchestrator/storage"
"github.com/tech/sendico/payments/orchestrator/storage/model"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.uber.org/zap"
)
type quotePaymentCommand struct {
engine paymentEngine
logger mlogger.Logger
}
func (h *quotePaymentCommand) Execute(ctx context.Context, req *orchestratorv1.QuotePaymentRequest) gsresponse.Responder[orchestratorv1.QuotePaymentResponse] {
if err := h.engine.EnsureRepository(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
}
orgRef, orgID, err := validateMetaAndOrgRef(req.GetMeta())
if err != nil {
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if err := requireNonNilIntent(req.GetIntent()); err != nil {
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
intent := req.GetIntent()
quote, expiresAt, err := h.engine.BuildPaymentQuote(ctx, orgRef, req)
if err != nil {
return gsresponse.Auto[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if !req.GetPreviewOnly() {
quotesStore, err := ensureQuotesStore(h.engine.Repository())
if err != nil {
return gsresponse.Unavailable[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
quoteRef := primitive.NewObjectID().Hex()
quote.QuoteRef = quoteRef
record := &model.PaymentQuoteRecord{
QuoteRef: quoteRef,
Intent: intentFromProto(intent),
Quote: quoteSnapshotToModel(quote),
ExpiresAt: expiresAt,
}
record.SetID(primitive.NewObjectID())
record.SetOrganizationRef(orgID)
if err := quotesStore.Create(ctx, record); err != nil {
return gsresponse.Auto[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
h.logger.Info("stored payment quote", zap.String("quote_ref", quoteRef), zap.String("org_ref", orgID.Hex()))
}
return gsresponse.Success(&orchestratorv1.QuotePaymentResponse{Quote: quote})
}
type quotePaymentsCommand struct {
engine paymentEngine
logger mlogger.Logger
}
func (h *quotePaymentsCommand) Execute(ctx context.Context, req *orchestratorv1.QuotePaymentsRequest) gsresponse.Responder[orchestratorv1.QuotePaymentsResponse] {
if err := h.engine.EnsureRepository(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
}
orgRef, orgID, err := validateMetaAndOrgRef(req.GetMeta())
if err != nil {
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
intents := req.GetIntents()
if len(intents) == 0 {
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("intents are required"))
}
baseKey := strings.TrimSpace(req.GetIdempotencyKey())
quotes := make([]*orchestratorv1.PaymentQuote, 0, len(intents))
expires := make([]time.Time, 0, len(intents))
for i, intent := range intents {
if err := requireNonNilIntent(intent); err != nil {
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
quoteReq := &orchestratorv1.QuotePaymentRequest{
Meta: req.GetMeta(),
IdempotencyKey: perIntentIdempotencyKey(baseKey, i, len(intents)),
Intent: intent,
PreviewOnly: req.GetPreviewOnly(),
}
quote, expiresAt, err := h.engine.BuildPaymentQuote(ctx, orgRef, quoteReq)
if err != nil {
return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
quotes = append(quotes, quote)
expires = append(expires, expiresAt)
}
aggregate, err := aggregatePaymentQuotes(quotes)
if err != nil {
return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InternalWrap(err, "quote aggregation failed"))
}
expiresAt, ok := minQuoteExpiry(expires)
if !ok {
return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.Internal("quote expiry missing"))
}
quoteRef := ""
if !req.GetPreviewOnly() {
quotesStore, err := ensureQuotesStore(h.engine.Repository())
if err != nil {
return gsresponse.Unavailable[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
quoteRef = primitive.NewObjectID().Hex()
record := &model.PaymentQuoteRecord{
QuoteRef: quoteRef,
Intents: intentsFromProto(intents),
Quotes: quoteSnapshotsFromProto(quotes),
ExpiresAt: expiresAt,
}
record.SetID(primitive.NewObjectID())
record.SetOrganizationRef(orgID)
if err := quotesStore.Create(ctx, record); err != nil {
return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
h.logger.Info("stored payment quotes", zap.String("quote_ref", quoteRef), zap.String("org_ref", orgID.Hex()))
}
return gsresponse.Success(&orchestratorv1.QuotePaymentsResponse{
QuoteRef: quoteRef,
Aggregate: aggregate,
Quotes: quotes,
})
}
type initiatePaymentsCommand struct {
engine paymentEngine
logger mlogger.Logger
}
func (h *initiatePaymentsCommand) Execute(ctx context.Context, req *orchestratorv1.InitiatePaymentsRequest) gsresponse.Responder[orchestratorv1.InitiatePaymentsResponse] {
if err := h.engine.EnsureRepository(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
}
_, orgID, err := validateMetaAndOrgRef(req.GetMeta())
if err != nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
idempotencyKey, err := requireIdempotencyKey(req.GetIdempotencyKey())
if err != nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
quoteRef := strings.TrimSpace(req.GetQuoteRef())
if quoteRef == "" {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("quote_ref is required"))
}
quotesStore, err := ensureQuotesStore(h.engine.Repository())
if err != nil {
return gsresponse.Unavailable[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
record, err := quotesStore.GetByRef(ctx, orgID, quoteRef)
if err != nil {
if errors.Is(err, storage.ErrQuoteNotFound) {
return gsresponse.FailedPrecondition[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, "quote_not_found", merrors.InvalidArgument("quote_ref not found or expired"))
}
return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
intents := record.Intents
quotes := record.Quotes
if len(intents) == 0 && record.Intent.Kind != "" && record.Intent.Kind != model.PaymentKindUnspecified {
intents = []model.PaymentIntent{record.Intent}
}
if len(quotes) == 0 && record.Quote != nil {
quotes = []*model.PaymentQuoteSnapshot{record.Quote}
}
if len(intents) == 0 || len(quotes) == 0 || len(intents) != len(quotes) {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("stored quote payload is incomplete"))
}
store, err := ensurePaymentsStore(h.engine.Repository())
if err != nil {
return gsresponse.Unavailable[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
payments := make([]*orchestratorv1.Payment, 0, len(intents))
for i := range intents {
intentProto := protoIntentFromModel(intents[i])
if err := requireNonNilIntent(intentProto); err != nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
quoteProto := modelQuoteToProto(quotes[i])
if quoteProto == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("stored quote is empty"))
}
quoteProto.QuoteRef = quoteRef
perKey := perIntentIdempotencyKey(idempotencyKey, i, len(intents))
if existing, err := getPaymentByIdempotencyKey(ctx, store, orgID, perKey); err == nil && existing != nil {
payments = append(payments, toProtoPayment(existing))
continue
} else if err != nil && !errors.Is(err, storage.ErrPaymentNotFound) {
return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
entity := newPayment(orgID, intentProto, perKey, req.GetMetadata(), quoteProto)
if err = store.Create(ctx, entity); err != nil {
if errors.Is(err, storage.ErrDuplicatePayment) {
return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.DataConflict("payment already exists"))
}
return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if err := h.engine.ExecutePayment(ctx, store, entity, quoteProto); err != nil {
return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
payments = append(payments, toProtoPayment(entity))
}
return gsresponse.Success(&orchestratorv1.InitiatePaymentsResponse{Payments: payments})
}
type initiatePaymentCommand struct {
engine paymentEngine
logger mlogger.Logger
}
func (h *initiatePaymentCommand) Execute(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) gsresponse.Responder[orchestratorv1.InitiatePaymentResponse] {
if err := h.engine.EnsureRepository(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
}
orgRef, orgID, err := validateMetaAndOrgRef(req.GetMeta())
if err != nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
intent := req.GetIntent()
if err := requireNonNilIntent(intent); err != nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
idempotencyKey, err := requireIdempotencyKey(req.GetIdempotencyKey())
if err != nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
store, err := ensurePaymentsStore(h.engine.Repository())
if err != nil {
return gsresponse.Unavailable[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if existing, err := getPaymentByIdempotencyKey(ctx, store, orgID, idempotencyKey); err == nil && existing != nil {
h.logger.Debug("idempotent payment request reused", zap.String("payment_ref", existing.PaymentRef), zap.String("org_ref", orgID.Hex()))
return gsresponse.Success(&orchestratorv1.InitiatePaymentResponse{Payment: toProtoPayment(existing)})
} else if err != nil && !errors.Is(err, storage.ErrPaymentNotFound) {
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
quoteSnapshot, err := h.engine.ResolvePaymentQuote(ctx, quoteResolutionInput{
OrgRef: orgRef,
OrgID: orgID,
Meta: req.GetMeta(),
Intent: intent,
QuoteRef: req.GetQuoteRef(),
IdempotencyKey: req.GetIdempotencyKey(),
})
if err != nil {
if qerr, ok := err.(quoteResolutionError); ok {
switch qerr.code {
case "quote_not_found":
return gsresponse.FailedPrecondition[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, qerr.code, qerr.err)
case "quote_expired":
return gsresponse.FailedPrecondition[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, qerr.code, qerr.err)
case "quote_intent_mismatch":
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, qerr.err)
default:
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, qerr.err)
}
}
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if quoteSnapshot == nil {
quoteSnapshot = &orchestratorv1.PaymentQuote{}
}
entity := newPayment(orgID, intent, idempotencyKey, req.GetMetadata(), quoteSnapshot)
if err = store.Create(ctx, entity); err != nil {
if errors.Is(err, storage.ErrDuplicatePayment) {
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.DataConflict("payment already exists"))
}
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if err := h.engine.ExecutePayment(ctx, store, entity, quoteSnapshot); err != nil {
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
h.logger.Info("payment initiated", zap.String("payment_ref", entity.PaymentRef), zap.String("org_ref", orgID.Hex()), zap.String("kind", intent.GetKind().String()))
return gsresponse.Success(&orchestratorv1.InitiatePaymentResponse{
Payment: toProtoPayment(entity),
})
}
type cancelPaymentCommand struct {
engine paymentEngine
logger mlogger.Logger
}
func (h *cancelPaymentCommand) Execute(ctx context.Context, req *orchestratorv1.CancelPaymentRequest) gsresponse.Responder[orchestratorv1.CancelPaymentResponse] {
if err := h.engine.EnsureRepository(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.CancelPaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.CancelPaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
}
paymentRef, err := requirePaymentRef(req.GetPaymentRef())
if err != nil {
return gsresponse.InvalidArgument[orchestratorv1.CancelPaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
store, err := ensurePaymentsStore(h.engine.Repository())
if err != nil {
return gsresponse.Unavailable[orchestratorv1.CancelPaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
payment, err := store.GetByPaymentRef(ctx, paymentRef)
if err != nil {
return paymentNotFoundResponder[orchestratorv1.CancelPaymentResponse](mservice.PaymentOrchestrator, h.logger, err)
}
if payment.State != model.PaymentStateAccepted {
reason := merrors.InvalidArgument("payment cannot be cancelled in current state")
return gsresponse.FailedPrecondition[orchestratorv1.CancelPaymentResponse](h.logger, mservice.PaymentOrchestrator, "payment_not_cancellable", reason)
}
payment.State = model.PaymentStateCancelled
payment.FailureCode = model.PaymentFailureCodePolicy
payment.FailureReason = strings.TrimSpace(req.GetReason())
if err := store.Update(ctx, payment); err != nil {
return gsresponse.Auto[orchestratorv1.CancelPaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
h.logger.Info("payment cancelled", zap.String("payment_ref", payment.PaymentRef), zap.String("org_ref", payment.OrganizationRef.Hex()))
return gsresponse.Success(&orchestratorv1.CancelPaymentResponse{Payment: toProtoPayment(payment)})
}
type initiateConversionCommand struct {
engine paymentEngine
logger mlogger.Logger
}
func (h *initiateConversionCommand) Execute(ctx context.Context, req *orchestratorv1.InitiateConversionRequest) gsresponse.Responder[orchestratorv1.InitiateConversionResponse] {
if err := h.engine.EnsureRepository(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
}
orgRef, orgID, err := validateMetaAndOrgRef(req.GetMeta())
if err != nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
}
idempotencyKey, err := requireIdempotencyKey(req.GetIdempotencyKey())
if err != nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if req.GetSource() == nil || req.GetSource().GetLedger() == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("source ledger endpoint is required"))
}
if req.GetDestination() == nil || req.GetDestination().GetLedger() == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("destination ledger endpoint is required"))
}
fxIntent := req.GetFx()
if fxIntent == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("fx intent is required"))
}
store, err := ensurePaymentsStore(h.engine.Repository())
if err != nil {
return gsresponse.Unavailable[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if existing, err := getPaymentByIdempotencyKey(ctx, store, orgID, idempotencyKey); err == nil && existing != nil {
h.logger.Debug("idempotent conversion request reused", zap.String("payment_ref", existing.PaymentRef), zap.String("org_ref", orgID.Hex()))
return gsresponse.Success(&orchestratorv1.InitiateConversionResponse{Conversion: toProtoPayment(existing)})
} else if err != nil && !errors.Is(err, storage.ErrPaymentNotFound) {
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
}
amount, err := conversionAmountFromMetadata(req.GetMetadata(), fxIntent)
if err != nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
}
intentProto := &orchestratorv1.PaymentIntent{
Kind: orchestratorv1.PaymentKind_PAYMENT_KIND_FX_CONVERSION,
Source: req.GetSource(),
Destination: req.GetDestination(),
Amount: amount,
RequiresFx: true,
Fx: fxIntent,
FeePolicy: req.GetFeePolicy(),
}
quote, _, err := h.engine.BuildPaymentQuote(ctx, orgRef, &orchestratorv1.QuotePaymentRequest{
Meta: req.GetMeta(),
IdempotencyKey: req.GetIdempotencyKey(),
Intent: intentProto,
})
if err != nil {
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
}
entity := newPayment(orgID, intentProto, idempotencyKey, req.GetMetadata(), quote)
if err = store.Create(ctx, entity); err != nil {
if errors.Is(err, storage.ErrDuplicatePayment) {
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, merrors.DataConflict("payment already exists"))
}
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if err := h.engine.ExecutePayment(ctx, store, entity, quote); err != nil {
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
}
h.logger.Info("conversion initiated", zap.String("payment_ref", entity.PaymentRef), zap.String("org_ref", orgID.Hex()))
return gsresponse.Success(&orchestratorv1.InitiateConversionResponse{
Conversion: toProtoPayment(entity),
})
}

View File

@@ -0,0 +1,139 @@
package orchestrator
import (
"context"
"strings"
"github.com/tech/sendico/payments/orchestrator/storage"
"github.com/tech/sendico/payments/orchestrator/storage/model"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
"go.uber.org/zap"
)
type paymentEventHandler struct {
repo storage.Repository
ensureRepo func(ctx context.Context) error
logger mlogger.Logger
}
func newPaymentEventHandler(repo storage.Repository, ensure func(ctx context.Context) error, logger mlogger.Logger) *paymentEventHandler {
return &paymentEventHandler{
repo: repo,
ensureRepo: ensure,
logger: logger,
}
}
func (h *paymentEventHandler) processTransferUpdate(ctx context.Context, req *orchestratorv1.ProcessTransferUpdateRequest) gsresponse.Responder[orchestratorv1.ProcessTransferUpdateResponse] {
if err := h.ensureRepo(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if req == nil || req.GetEvent() == nil || req.GetEvent().GetTransfer() == nil {
return gsresponse.InvalidArgument[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("transfer event is required"))
}
transfer := req.GetEvent().GetTransfer()
transferRef := strings.TrimSpace(transfer.GetTransferRef())
if transferRef == "" {
return gsresponse.InvalidArgument[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("transfer_ref is required"))
}
store := h.repo.Payments()
if store == nil {
return gsresponse.Unavailable[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, errStorageUnavailable)
}
payment, err := store.GetByChainTransferRef(ctx, transferRef)
if err != nil {
return paymentNotFoundResponder[orchestratorv1.ProcessTransferUpdateResponse](mservice.PaymentOrchestrator, h.logger, err)
}
applyTransferStatus(req.GetEvent(), payment)
if err := store.Update(ctx, payment); err != nil {
return gsresponse.Auto[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, err)
}
h.logger.Info("transfer update applied", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", transferRef), zap.Any("state", payment.State))
return gsresponse.Success(&orchestratorv1.ProcessTransferUpdateResponse{Payment: toProtoPayment(payment)})
}
func (h *paymentEventHandler) processDepositObserved(ctx context.Context, req *orchestratorv1.ProcessDepositObservedRequest) gsresponse.Responder[orchestratorv1.ProcessDepositObservedResponse] {
if err := h.ensureRepo(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.ProcessDepositObservedResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if req == nil || req.GetEvent() == nil {
return gsresponse.InvalidArgument[orchestratorv1.ProcessDepositObservedResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("deposit event is required"))
}
event := req.GetEvent()
walletRef := strings.TrimSpace(event.GetWalletRef())
if walletRef == "" {
return gsresponse.InvalidArgument[orchestratorv1.ProcessDepositObservedResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("wallet_ref is required"))
}
store := h.repo.Payments()
if store == nil {
return gsresponse.Unavailable[orchestratorv1.ProcessDepositObservedResponse](h.logger, mservice.PaymentOrchestrator, errStorageUnavailable)
}
filter := &model.PaymentFilter{
States: []model.PaymentState{model.PaymentStateSubmitted, model.PaymentStateFundsReserved},
DestinationRef: walletRef,
}
result, err := store.List(ctx, filter)
if err != nil {
return gsresponse.Auto[orchestratorv1.ProcessDepositObservedResponse](h.logger, mservice.PaymentOrchestrator, err)
}
for _, payment := range result.Items {
if payment.Intent.Destination.Type != model.EndpointTypeManagedWallet {
continue
}
if !moneyEquals(payment.Intent.Amount, event.GetAmount()) {
continue
}
payment.State = model.PaymentStateSettled
payment.FailureCode = model.PaymentFailureCodeUnspecified
payment.FailureReason = ""
if payment.Execution == nil {
payment.Execution = &model.ExecutionRefs{}
}
if payment.Execution.ChainTransferRef == "" {
payment.Execution.ChainTransferRef = strings.TrimSpace(event.GetTransactionHash())
}
if err := store.Update(ctx, payment); err != nil {
return gsresponse.Auto[orchestratorv1.ProcessDepositObservedResponse](h.logger, mservice.PaymentOrchestrator, err)
}
h.logger.Info("deposit observed matched payment", zap.String("payment_ref", payment.PaymentRef), zap.String("wallet_ref", walletRef))
return gsresponse.Success(&orchestratorv1.ProcessDepositObservedResponse{Payment: toProtoPayment(payment)})
}
return gsresponse.Success(&orchestratorv1.ProcessDepositObservedResponse{})
}
func (h *paymentEventHandler) processCardPayoutUpdate(ctx context.Context, req *orchestratorv1.ProcessCardPayoutUpdateRequest) gsresponse.Responder[orchestratorv1.ProcessCardPayoutUpdateResponse] {
if err := h.ensureRepo(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.ProcessCardPayoutUpdateResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if req == nil || req.GetEvent() == nil || req.GetEvent().GetPayout() == nil {
return gsresponse.InvalidArgument[orchestratorv1.ProcessCardPayoutUpdateResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("event is required"))
}
payout := req.GetEvent().GetPayout()
paymentRef := strings.TrimSpace(payout.GetPayoutId())
if paymentRef == "" {
return gsresponse.InvalidArgument[orchestratorv1.ProcessCardPayoutUpdateResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("payout_id is required"))
}
store := h.repo.Payments()
if store == nil {
return gsresponse.Unavailable[orchestratorv1.ProcessCardPayoutUpdateResponse](h.logger, mservice.PaymentOrchestrator, errStorageUnavailable)
}
payment, err := store.GetByPaymentRef(ctx, paymentRef)
if err != nil {
return paymentNotFoundResponder[orchestratorv1.ProcessCardPayoutUpdateResponse](mservice.PaymentOrchestrator, h.logger, err)
}
applyCardPayoutUpdate(payment, payout)
if err := store.Update(ctx, payment); err != nil {
return gsresponse.Auto[orchestratorv1.ProcessCardPayoutUpdateResponse](h.logger, mservice.PaymentOrchestrator, err)
}
h.logger.Info("card payout update applied", zap.String("payment_ref", payment.PaymentRef), zap.String("payout_id", paymentRef), zap.Any("state", payment.State))
return gsresponse.Success(&orchestratorv1.ProcessCardPayoutUpdateResponse{
Payment: toProtoPayment(payment),
})
}

View File

@@ -0,0 +1,80 @@
package orchestrator
import (
"context"
"github.com/tech/sendico/payments/orchestrator/storage"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
"go.uber.org/zap"
)
type paymentQueryHandler struct {
repo storage.Repository
ensureRepo func(ctx context.Context) error
logger mlogger.Logger
}
func newPaymentQueryHandler(repo storage.Repository, ensure func(ctx context.Context) error, logger mlogger.Logger) *paymentQueryHandler {
return &paymentQueryHandler{
repo: repo,
ensureRepo: ensure,
logger: logger,
}
}
func (h *paymentQueryHandler) getPayment(ctx context.Context, req *orchestratorv1.GetPaymentRequest) gsresponse.Responder[orchestratorv1.GetPaymentResponse] {
if err := h.ensureRepo(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.GetPaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.GetPaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
}
paymentRef, err := requirePaymentRef(req.GetPaymentRef())
if err != nil {
return gsresponse.InvalidArgument[orchestratorv1.GetPaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
store, err := ensurePaymentsStore(h.repo)
if err != nil {
return gsresponse.Unavailable[orchestratorv1.GetPaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
entity, err := store.GetByPaymentRef(ctx, paymentRef)
if err != nil {
return paymentNotFoundResponder[orchestratorv1.GetPaymentResponse](mservice.PaymentOrchestrator, h.logger, err)
}
h.logger.Debug("payment fetched", zap.String("payment_ref", paymentRef))
return gsresponse.Success(&orchestratorv1.GetPaymentResponse{Payment: toProtoPayment(entity)})
}
func (h *paymentQueryHandler) listPayments(ctx context.Context, req *orchestratorv1.ListPaymentsRequest) gsresponse.Responder[orchestratorv1.ListPaymentsResponse] {
if err := h.ensureRepo(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.ListPaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.ListPaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
}
store, err := ensurePaymentsStore(h.repo)
if err != nil {
return gsresponse.Unavailable[orchestratorv1.ListPaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
filter := filterFromProto(req)
result, err := store.List(ctx, filter)
if err != nil {
return gsresponse.Auto[orchestratorv1.ListPaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
resp := &orchestratorv1.ListPaymentsResponse{
Page: &paginationv1.CursorPageResponse{
NextCursor: result.NextCursor,
},
}
resp.Payments = make([]*orchestratorv1.Payment, 0, len(result.Items))
for _, item := range result.Items {
resp.Payments = append(resp.Payments, toProtoPayment(item))
}
h.logger.Debug("payments listed", zap.Int("count", len(resp.Payments)), zap.String("next_cursor", resp.GetPage().GetNextCursor()))
return gsresponse.Success(resp)
}

View File

@@ -145,7 +145,7 @@ func resolveTradeAmounts(intentAmount *moneyv1.Money, fxQuote *oraclev1.Quote, s
} }
} }
func computeAggregates(pay, settlement, fee *moneyv1.Money, network *chainv1.EstimateTransferFeeResponse, fxQuote *oraclev1.Quote) (*moneyv1.Money, *moneyv1.Money) { func computeAggregates(pay, settlement, fee *moneyv1.Money, network *chainv1.EstimateTransferFeeResponse, fxQuote *oraclev1.Quote, mode orchestratorv1.SettlementMode) (*moneyv1.Money, *moneyv1.Money) {
if pay == nil { if pay == nil {
return nil, nil return nil, nil
} }
@@ -166,7 +166,7 @@ func computeAggregates(pay, settlement, fee *moneyv1.Money, network *chainv1.Est
} }
} }
adjustDebit := func(m *moneyv1.Money) { applyChargeToDebit := func(m *moneyv1.Money) {
converted, err := ensureCurrency(m, pay.GetCurrency(), fxQuote) converted, err := ensureCurrency(m, pay.GetCurrency(), fxQuote)
if err != nil || converted == nil { if err != nil || converted == nil {
return return
@@ -176,7 +176,7 @@ func computeAggregates(pay, settlement, fee *moneyv1.Money, network *chainv1.Est
} }
} }
adjustSettlement := func(m *moneyv1.Money) { applyChargeToSettlement := func(m *moneyv1.Money) {
converted, err := ensureCurrency(m, settlementCurrency, fxQuote) converted, err := ensureCurrency(m, settlementCurrency, fxQuote)
if err != nil || converted == nil { if err != nil || converted == nil {
return return
@@ -186,12 +186,22 @@ func computeAggregates(pay, settlement, fee *moneyv1.Money, network *chainv1.Est
} }
} }
adjustDebit(fee) switch mode {
adjustSettlement(fee) case orchestratorv1.SettlementMode_SETTLEMENT_MODE_FIX_RECEIVED:
// Sender pays the fee: keep settlement fixed, increase debit.
applyChargeToDebit(fee)
default:
// Recipient pays the fee (default): reduce settlement, keep debit fixed.
applyChargeToSettlement(fee)
}
if network != nil && network.GetNetworkFee() != nil { if network != nil && network.GetNetworkFee() != nil {
adjustDebit(network.GetNetworkFee()) switch mode {
adjustSettlement(network.GetNetworkFee()) case orchestratorv1.SettlementMode_SETTLEMENT_MODE_FIX_RECEIVED:
applyChargeToDebit(network.GetNetworkFee())
default:
applyChargeToSettlement(network.GetNetworkFee())
}
} }
return makeMoney(pay.GetCurrency(), debitDecimal), makeMoney(settlementCurrency, settlementDecimal) return makeMoney(pay.GetCurrency(), debitDecimal), makeMoney(settlementCurrency, settlementDecimal)
@@ -204,20 +214,6 @@ func decimalFromMoney(m *moneyv1.Money) (decimal.Decimal, error) {
return decimal.NewFromString(m.GetAmount()) return decimal.NewFromString(m.GetAmount())
} }
func decimalFromMoneyMatching(reference, candidate *moneyv1.Money) (*decimal.Decimal, error) {
if reference == nil || candidate == nil {
return nil, nil
}
if !strings.EqualFold(reference.GetCurrency(), candidate.GetCurrency()) {
return nil, nil
}
value, err := decimal.NewFromString(candidate.GetAmount())
if err != nil {
return nil, err
}
return &value, nil
}
func makeMoney(currency string, value decimal.Decimal) *moneyv1.Money { func makeMoney(currency string, value decimal.Decimal) *moneyv1.Money {
return &moneyv1.Money{ return &moneyv1.Money{
Currency: currency, Currency: currency,
@@ -383,6 +379,22 @@ func feeBreakdownFromQuote(quote *orchestratorv1.PaymentQuote) []*chainv1.Servic
return breakdown return breakdown
} }
func assignLedgerAccounts(lines []*feesv1.DerivedPostingLine, account string) []*feesv1.DerivedPostingLine {
if account == "" || len(lines) == 0 {
return lines
}
for _, line := range lines {
if line == nil {
continue
}
if strings.TrimSpace(line.GetLedgerAccountRef()) != "" {
continue
}
line.LedgerAccountRef = account
}
return lines
}
func moneyEquals(a, b *moneyv1.Money) bool { func moneyEquals(a, b *moneyv1.Money) bool {
if a == nil || b == nil { if a == nil || b == nil {
return false return false

View File

@@ -7,6 +7,7 @@ import (
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
) )
func TestResolveTradeAmountsBuyBase(t *testing.T) { func TestResolveTradeAmountsBuyBase(t *testing.T) {
@@ -47,11 +48,32 @@ func TestComputeAggregatesConvertsCurrencies(t *testing.T) {
}, },
} }
debit, settlement := computeAggregates(pay, settle, fee, network, fxQuote) debit, settlement := computeAggregates(pay, settle, fee, network, fxQuote, orchestratorv1.SettlementMode_SETTLEMENT_MODE_FIX_RECEIVED)
if debit.GetCurrency() != "USD" || debit.GetAmount() != "115" { if debit.GetCurrency() != "USD" || debit.GetAmount() != "115" {
t.Fatalf("expected debit 115 USD, got %s %s", debit.GetCurrency(), debit.GetAmount()) t.Fatalf("expected debit 115 USD, got %s %s", debit.GetCurrency(), debit.GetAmount())
} }
if settlement.GetCurrency() != "EUR" || settlement.GetAmount() != "42.5" { if settlement.GetCurrency() != "EUR" || settlement.GetAmount() != "50" {
t.Fatalf("expected settlement 42.5 EUR, got %s %s", settlement.GetCurrency(), settlement.GetAmount()) t.Fatalf("expected settlement 50 EUR, got %s %s", settlement.GetCurrency(), settlement.GetAmount())
}
}
func TestComputeAggregatesRecipientPaysFee(t *testing.T) {
pay := &moneyv1.Money{Currency: "USDT", Amount: "100"}
settle := &moneyv1.Money{Currency: "RUB", Amount: "7932"} // 100 * 79.32
fee := &moneyv1.Money{Currency: "USDT", Amount: "7"} // 7% of 100
fxQuote := &oraclev1.Quote{
Pair: &fxv1.CurrencyPair{Base: "USDT", Quote: "RUB"},
Side: fxv1.Side_SELL_BASE_BUY_QUOTE,
Price: &moneyv1.Decimal{
Value: "79.32",
},
}
debit, settlement := computeAggregates(pay, settle, fee, nil, fxQuote, orchestratorv1.SettlementMode_SETTLEMENT_MODE_FIX_SOURCE)
if debit.GetCurrency() != "USDT" || debit.GetAmount() != "100" {
t.Fatalf("expected debit 100 USDT, got %s %s", debit.GetCurrency(), debit.GetAmount())
}
if settlement.GetCurrency() != "RUB" || settlement.GetAmount() != "7376.76" {
t.Fatalf("expected settlement 7376.76 RUB, got %s %s", settlement.GetCurrency(), settlement.GetAmount())
} }
} }

View File

@@ -4,9 +4,11 @@ import (
"context" "context"
"time" "time"
"github.com/tech/sendico/payments/orchestrator/storage/model"
"github.com/tech/sendico/pkg/api/routers/gsresponse" "github.com/tech/sendico/pkg/api/routers/gsresponse"
"github.com/tech/sendico/pkg/mservice" "github.com/tech/sendico/pkg/mservice"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
) )
@@ -51,10 +53,17 @@ func shouldEstimateNetworkFee(intent *orchestratorv1.PaymentIntent) bool {
if intent == nil { if intent == nil {
return false return false
} }
dest := intent.GetDestination()
if dest == nil {
return false
}
if dest.GetCard() != nil {
return false
}
if intent.GetKind() == orchestratorv1.PaymentKind_PAYMENT_KIND_PAYOUT { if intent.GetKind() == orchestratorv1.PaymentKind_PAYMENT_KIND_PAYOUT {
return true return true
} }
if intent.GetDestination().GetManagedWallet() != nil || intent.GetDestination().GetExternalChain() != nil { if dest.GetManagedWallet() != nil || dest.GetExternalChain() != nil {
return true return true
} }
return false return false
@@ -69,3 +78,16 @@ func shouldRequestFX(intent *orchestratorv1.PaymentIntent) bool {
} }
return intent.GetFx() != nil && intent.GetFx().GetPair() != nil return intent.GetFx() != nil && intent.GetFx().GetPair() != nil
} }
func mapMntxStatusToState(status mntxv1.PayoutStatus) model.PaymentState {
switch status {
case mntxv1.PayoutStatus_PAYOUT_STATUS_PROCESSED:
return model.PaymentStateSettled
case mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED:
return model.PaymentStateFailed
case mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING:
return model.PaymentStateSubmitted
default:
return model.PaymentStateUnspecified
}
}

View File

@@ -0,0 +1,51 @@
package orchestrator
import (
"testing"
"github.com/tech/sendico/payments/orchestrator/storage/model"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
)
func TestShouldEstimateNetworkFeeSkipsCard(t *testing.T) {
intent := &orchestratorv1.PaymentIntent{
Kind: orchestratorv1.PaymentKind_PAYMENT_KIND_PAYOUT,
Destination: &orchestratorv1.PaymentEndpoint{
Endpoint: &orchestratorv1.PaymentEndpoint_Card{
Card: &orchestratorv1.CardEndpoint{},
},
},
}
if shouldEstimateNetworkFee(intent) {
t.Fatalf("expected network fee estimation to be skipped for card payouts")
}
}
func TestShouldEstimateNetworkFeeManagedWallet(t *testing.T) {
intent := &orchestratorv1.PaymentIntent{
Destination: &orchestratorv1.PaymentEndpoint{
Endpoint: &orchestratorv1.PaymentEndpoint_ManagedWallet{
ManagedWallet: &orchestratorv1.ManagedWalletEndpoint{ManagedWalletRef: "mw"},
},
},
}
if !shouldEstimateNetworkFee(intent) {
t.Fatalf("expected network fee estimation when destination is managed wallet")
}
}
func TestMapMntxStatusToState(t *testing.T) {
if mapMntxStatusToState(mntxv1.PayoutStatus_PAYOUT_STATUS_PROCESSED) != model.PaymentStateSettled {
t.Fatalf("processed should map to settled")
}
if mapMntxStatusToState(mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED) != model.PaymentStateFailed {
t.Fatalf("failed should map to failed")
}
if mapMntxStatusToState(mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING) != model.PaymentStateSubmitted {
t.Fatalf("pending should map to submitted")
}
if mapMntxStatusToState(mntxv1.PayoutStatus_PAYOUT_STATUS_UNSPECIFIED) != model.PaymentStateUnspecified {
t.Fatalf("unspecified should map to unspecified")
}
}

View File

@@ -1,10 +1,12 @@
package orchestrator package orchestrator
import ( import (
"strings"
"time" "time"
oracleclient "github.com/tech/sendico/fx/oracle/client" oracleclient "github.com/tech/sendico/fx/oracle/client"
chainclient "github.com/tech/sendico/gateway/chain/client" chainclient "github.com/tech/sendico/gateway/chain/client"
mntxclient "github.com/tech/sendico/gateway/mntx/client"
ledgerclient "github.com/tech/sendico/ledger/client" ledgerclient "github.com/tech/sendico/ledger/client"
clockpkg "github.com/tech/sendico/pkg/clock" clockpkg "github.com/tech/sendico/pkg/clock"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
@@ -46,10 +48,24 @@ func (o oracleDependency) available() bool {
return o.client != nil return o.client != nil
} }
type mntxDependency struct {
client mntxclient.Client
}
func (m mntxDependency) available() bool {
return m.client != nil
}
// CardGatewayRoute maps a gateway to its funding and fee destinations (addresses).
type CardGatewayRoute struct {
FundingAddress string
FeeAddress string
}
// WithFeeEngine wires the fee engine client. // WithFeeEngine wires the fee engine client.
func WithFeeEngine(client feesv1.FeeEngineClient, timeout time.Duration) Option { func WithFeeEngine(client feesv1.FeeEngineClient, timeout time.Duration) Option {
return func(s *Service) { return func(s *Service) {
s.fees = feesDependency{ s.deps.fees = feesDependency{
client: client, client: client,
timeout: timeout, timeout: timeout,
} }
@@ -59,21 +75,59 @@ func WithFeeEngine(client feesv1.FeeEngineClient, timeout time.Duration) Option
// WithLedgerClient wires the ledger client. // WithLedgerClient wires the ledger client.
func WithLedgerClient(client ledgerclient.Client) Option { func WithLedgerClient(client ledgerclient.Client) Option {
return func(s *Service) { return func(s *Service) {
s.ledger = ledgerDependency{client: client} s.deps.ledger = ledgerDependency{client: client}
} }
} }
// WithChainGatewayClient wires the chain gateway client. // WithChainGatewayClient wires the chain gateway client.
func WithChainGatewayClient(client chainclient.Client) Option { func WithChainGatewayClient(client chainclient.Client) Option {
return func(s *Service) { return func(s *Service) {
s.gateway = gatewayDependency{client: client} s.deps.gateway = gatewayDependency{client: client}
} }
} }
// WithOracleClient wires the FX oracle client. // WithOracleClient wires the FX oracle client.
func WithOracleClient(client oracleclient.Client) Option { func WithOracleClient(client oracleclient.Client) Option {
return func(s *Service) { return func(s *Service) {
s.oracle = oracleDependency{client: client} s.deps.oracle = oracleDependency{client: client}
}
}
// WithMntxGateway wires the Monetix gateway client.
func WithMntxGateway(client mntxclient.Client) Option {
return func(s *Service) {
s.deps.mntx = mntxDependency{client: client}
}
}
// WithCardGatewayRoutes configures funding/fee wallet routing per gateway.
func WithCardGatewayRoutes(routes map[string]CardGatewayRoute) Option {
return func(s *Service) {
if len(routes) == 0 {
return
}
s.deps.cardRoutes = make(map[string]CardGatewayRoute, len(routes))
for k, v := range routes {
s.deps.cardRoutes[strings.ToLower(strings.TrimSpace(k))] = v
}
}
}
// WithFeeLedgerAccounts maps gateway identifiers to ledger accounts used for fees.
func WithFeeLedgerAccounts(routes map[string]string) Option {
return func(s *Service) {
if len(routes) == 0 {
return
}
s.deps.feeLedgerAccounts = make(map[string]string, len(routes))
for k, v := range routes {
key := strings.ToLower(strings.TrimSpace(k))
val := strings.TrimSpace(v)
if key == "" || val == "" {
continue
}
s.deps.feeLedgerAccounts[key] = val
}
} }
} }

View File

@@ -3,215 +3,30 @@ package orchestrator
import ( import (
"context" "context"
"strings" "strings"
"time"
oracleclient "github.com/tech/sendico/fx/oracle/client"
"github.com/tech/sendico/payments/orchestrator/storage" "github.com/tech/sendico/payments/orchestrator/storage"
"github.com/tech/sendico/payments/orchestrator/storage/model" "github.com/tech/sendico/payments/orchestrator/storage/model"
"github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/merrors"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" "github.com/tech/sendico/pkg/mlogger"
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1" fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1" ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
"go.mongodb.org/mongo-driver/bson/primitive" "go.mongodb.org/mongo-driver/bson/primitive"
"go.uber.org/zap" "go.uber.org/zap"
"google.golang.org/protobuf/types/known/timestamppb"
) )
func (s *Service) buildPaymentQuote(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.PaymentQuote, time.Time, error) { type paymentExecutor struct {
intent := req.GetIntent() deps *serviceDependencies
amount := intent.GetAmount() logger mlogger.Logger
fxSide := fxv1.Side_SIDE_UNSPECIFIED svc *Service
if intent.GetFx() != nil {
fxSide = intent.GetFx().GetSide()
}
var fxQuote *oraclev1.Quote
var err error
if shouldRequestFX(intent) {
fxQuote, err = s.requestFXQuote(ctx, orgRef, req)
if err != nil {
return nil, time.Time{}, err
}
}
payAmount, settlementAmountBeforeFees := resolveTradeAmounts(amount, fxQuote, fxSide)
feeBaseAmount := payAmount
if feeBaseAmount == nil {
feeBaseAmount = cloneMoney(amount)
}
feeQuote, err := s.quoteFees(ctx, orgRef, req, feeBaseAmount)
if err != nil {
return nil, time.Time{}, err
}
feeCurrency := ""
if feeBaseAmount != nil {
feeCurrency = feeBaseAmount.GetCurrency()
} else if amount != nil {
feeCurrency = amount.GetCurrency()
}
feeTotal := extractFeeTotal(feeQuote.GetLines(), feeCurrency)
var networkFee *chainv1.EstimateTransferFeeResponse
if shouldEstimateNetworkFee(intent) {
networkFee, err = s.estimateNetworkFee(ctx, intent)
if err != nil {
return nil, time.Time{}, err
}
}
debitAmount, settlementAmount := computeAggregates(payAmount, settlementAmountBeforeFees, feeTotal, networkFee, fxQuote)
quote := &orchestratorv1.PaymentQuote{
DebitAmount: debitAmount,
ExpectedSettlementAmount: settlementAmount,
ExpectedFeeTotal: feeTotal,
FeeLines: cloneFeeLines(feeQuote.GetLines()),
FeeRules: cloneFeeRules(feeQuote.GetApplied()),
FxQuote: fxQuote,
NetworkFee: networkFee,
FeeQuoteToken: feeQuote.GetFeeQuoteToken(),
}
expiresAt := quoteExpiry(s.clock.Now(), feeQuote, fxQuote)
return quote, expiresAt, nil
} }
func (s *Service) quoteFees(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest, baseAmount *moneyv1.Money) (*feesv1.PrecomputeFeesResponse, error) { func newPaymentExecutor(deps *serviceDependencies, logger mlogger.Logger, svc *Service) *paymentExecutor {
if !s.fees.available() { return &paymentExecutor{deps: deps, logger: logger, svc: svc}
return &feesv1.PrecomputeFeesResponse{}, nil
}
intent := req.GetIntent()
amount := cloneMoney(baseAmount)
if amount == nil {
amount = cloneMoney(intent.GetAmount())
}
feeIntent := &feesv1.Intent{
Trigger: triggerFromKind(intent.GetKind(), intent.GetRequiresFx()),
BaseAmount: amount,
BookedAt: timestamppb.New(s.clock.Now()),
OriginType: "payments.orchestrator.quote",
OriginRef: strings.TrimSpace(req.GetIdempotencyKey()),
Attributes: cloneMetadata(intent.GetAttributes()),
}
timeout := req.GetMeta().GetTrace()
ctxTimeout, cancel := s.withTimeout(ctx, s.fees.timeout)
defer cancel()
resp, err := s.fees.client.PrecomputeFees(ctxTimeout, &feesv1.PrecomputeFeesRequest{
Meta: &feesv1.RequestMeta{
OrganizationRef: orgRef,
Trace: timeout,
},
Intent: feeIntent,
TtlMs: defaultFeeQuoteTTLMillis,
})
if err != nil {
s.logger.Error("fees precompute failed", zap.Error(err))
return nil, merrors.Internal("fees_precompute_failed")
}
return resp, nil
} }
func (s *Service) estimateNetworkFee(ctx context.Context, intent *orchestratorv1.PaymentIntent) (*chainv1.EstimateTransferFeeResponse, error) { func (p *paymentExecutor) executePayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, quote *orchestratorv1.PaymentQuote) error {
if !s.gateway.available() {
return nil, nil
}
req := &chainv1.EstimateTransferFeeRequest{
Amount: cloneMoney(intent.GetAmount()),
}
if src := intent.GetSource().GetManagedWallet(); src != nil {
req.SourceWalletRef = strings.TrimSpace(src.GetManagedWalletRef())
}
if dst := intent.GetDestination().GetManagedWallet(); dst != nil {
req.Destination = &chainv1.TransferDestination{
Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: strings.TrimSpace(dst.GetManagedWalletRef())},
}
}
if dst := intent.GetDestination().GetExternalChain(); dst != nil {
req.Destination = &chainv1.TransferDestination{
Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: strings.TrimSpace(dst.GetAddress())},
Memo: strings.TrimSpace(dst.GetMemo()),
}
req.Asset = dst.GetAsset()
}
if req.Asset == nil {
if src := intent.GetSource().GetManagedWallet(); src != nil {
req.Asset = src.GetAsset()
}
}
resp, err := s.gateway.client.EstimateTransferFee(ctx, req)
if err != nil {
s.logger.Error("chain gateway fee estimation failed", zap.Error(err))
return nil, merrors.Internal("chain_gateway_fee_estimation_failed")
}
return resp, nil
}
func (s *Service) requestFXQuote(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest) (*oraclev1.Quote, error) {
if !s.oracle.available() {
return nil, nil
}
intent := req.GetIntent()
meta := req.GetMeta()
fxIntent := intent.GetFx()
if fxIntent == nil {
return nil, nil
}
ttl := fxIntent.GetTtlMs()
if ttl <= 0 {
ttl = defaultOracleTTLMillis
}
params := oracleclient.GetQuoteParams{
Meta: oracleclient.RequestMeta{
OrganizationRef: orgRef,
Trace: meta.GetTrace(),
},
Pair: fxIntent.GetPair(),
Side: fxIntent.GetSide(),
Firm: fxIntent.GetFirm(),
TTL: time.Duration(ttl) * time.Millisecond,
PreferredProvider: strings.TrimSpace(fxIntent.GetPreferredProvider()),
}
if fxIntent.GetMaxAgeMs() > 0 {
params.MaxAge = time.Duration(fxIntent.GetMaxAgeMs()) * time.Millisecond
}
if amount := intent.GetAmount(); amount != nil {
pair := fxIntent.GetPair()
if pair != nil {
switch {
case strings.EqualFold(amount.GetCurrency(), pair.GetBase()):
params.BaseAmount = cloneMoney(amount)
case strings.EqualFold(amount.GetCurrency(), pair.GetQuote()):
params.QuoteAmount = cloneMoney(amount)
default:
params.BaseAmount = cloneMoney(amount)
}
} else {
params.BaseAmount = cloneMoney(amount)
}
}
quote, err := s.oracle.client.GetQuote(ctx, params)
if err != nil {
s.logger.Error("fx oracle quote failed", zap.Error(err))
return nil, merrors.Internal("fx_quote_failed")
}
return quoteToProto(quote), nil
}
func (s *Service) executePayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, quote *orchestratorv1.PaymentQuote) error {
if store == nil { if store == nil {
return errStorageUnavailable return errStorageUnavailable
} }
@@ -219,6 +34,7 @@ func (s *Service) executePayment(ctx context.Context, store storage.PaymentsStor
charges := ledgerChargesFromFeeLines(quote.GetFeeLines()) charges := ledgerChargesFromFeeLines(quote.GetFeeLines())
ledgerNeeded := requiresLedger(payment) ledgerNeeded := requiresLedger(payment)
chainNeeded := requiresChain(payment) chainNeeded := requiresChain(payment)
cardNeeded := payment.Intent.Destination.Type == model.EndpointTypeCard
exec := payment.Execution exec := payment.Execution
if exec == nil { if exec == nil {
@@ -226,25 +42,26 @@ func (s *Service) executePayment(ctx context.Context, store storage.PaymentsStor
} }
if ledgerNeeded { if ledgerNeeded {
if !s.ledger.available() { if !p.deps.ledger.available() {
return s.failPayment(ctx, store, payment, model.PaymentFailureCodeLedger, "ledger_client_unavailable", merrors.Internal("ledger_client_unavailable")) return p.failPayment(ctx, store, payment, model.PaymentFailureCodeLedger, "ledger_client_unavailable", merrors.Internal("ledger_client_unavailable"))
} }
if err := s.performLedgerOperation(ctx, payment, quote, charges); err != nil { if err := p.performLedgerOperation(ctx, payment, quote, charges); err != nil {
return s.failPayment(ctx, store, payment, model.PaymentFailureCodeLedger, strings.TrimSpace(err.Error()), err) return p.failPayment(ctx, store, payment, model.PaymentFailureCodeLedger, strings.TrimSpace(err.Error()), err)
} }
payment.State = model.PaymentStateFundsReserved payment.State = model.PaymentStateFundsReserved
if err := s.persistPayment(ctx, store, payment); err != nil { if err := p.persistPayment(ctx, store, payment); err != nil {
return err return err
} }
p.logger.Info("ledger reservation completed", zap.String("payment_ref", payment.PaymentRef))
} }
if chainNeeded { if chainNeeded {
if !s.gateway.available() { if !p.deps.gateway.available() {
return s.failPayment(ctx, store, payment, model.PaymentFailureCodeChain, "chain_client_unavailable", merrors.Internal("chain_client_unavailable")) return p.failPayment(ctx, store, payment, model.PaymentFailureCodeChain, "chain_client_unavailable", merrors.Internal("chain_client_unavailable"))
} }
resp, err := s.submitChainTransfer(ctx, payment, quote) resp, err := p.submitChainTransfer(ctx, payment, quote)
if err != nil { if err != nil {
return s.failPayment(ctx, store, payment, model.PaymentFailureCodeChain, strings.TrimSpace(err.Error()), err) return p.failPayment(ctx, store, payment, model.PaymentFailureCodeChain, strings.TrimSpace(err.Error()), err)
} }
exec = payment.Execution exec = payment.Execution
if exec == nil { if exec == nil {
@@ -255,17 +72,42 @@ func (s *Service) executePayment(ctx context.Context, store storage.PaymentsStor
} }
payment.Execution = exec payment.Execution = exec
payment.State = model.PaymentStateSubmitted payment.State = model.PaymentStateSubmitted
if err := s.persistPayment(ctx, store, payment); err != nil { if err := p.persistPayment(ctx, store, payment); err != nil {
return err return err
} }
p.logger.Info("chain transfer submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", exec.ChainTransferRef))
if !cardNeeded {
return nil
}
}
if cardNeeded {
if !p.deps.mntx.available() {
return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, "card_gateway_unavailable", merrors.Internal("card_gateway_unavailable"))
}
if err := p.svc.submitCardFundingTransfers(ctx, payment, quote); err != nil {
return p.failPayment(ctx, store, payment, model.PaymentFailureCodeChain, strings.TrimSpace(err.Error()), err)
}
if err := p.svc.submitCardPayout(ctx, payment); err != nil {
return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, strings.TrimSpace(err.Error()), err)
}
payment.State = model.PaymentStateSubmitted
if err := p.persistPayment(ctx, store, payment); err != nil {
return err
}
p.logger.Info("card payout submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("card_payout_ref", payment.Execution.CardPayoutRef))
return nil return nil
} }
payment.State = model.PaymentStateSettled payment.State = model.PaymentStateSettled
return s.persistPayment(ctx, store, payment) if err := p.persistPayment(ctx, store, payment); err != nil {
return err
}
p.logger.Info("payment settled without chain", zap.String("payment_ref", payment.PaymentRef))
return nil
} }
func (s *Service) performLedgerOperation(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote, charges []*ledgerv1.PostingLine) error { func (p *paymentExecutor) performLedgerOperation(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote, charges []*ledgerv1.PostingLine) error {
intent := payment.Intent intent := payment.Intent
if payment.OrganizationRef == primitive.NilObjectID { if payment.OrganizationRef == primitive.NilObjectID {
return merrors.InvalidArgument("ledger: organization_ref is required") return merrors.InvalidArgument("ledger: organization_ref is required")
@@ -285,7 +127,7 @@ func (s *Service) performLedgerOperation(ctx context.Context, payment *model.Pay
switch intent.Kind { switch intent.Kind {
case model.PaymentKindFXConversion: case model.PaymentKindFXConversion:
if err := s.applyFX(ctx, payment, quote, charges, description, metadata, exec); err != nil { if err := p.applyFX(ctx, payment, quote, charges, description, metadata, exec); err != nil {
return err return err
} }
case model.PaymentKindInternalTransfer, model.PaymentKindPayout, model.PaymentKindUnspecified: case model.PaymentKindInternalTransfer, model.PaymentKindPayout, model.PaymentKindUnspecified:
@@ -303,7 +145,7 @@ func (s *Service) performLedgerOperation(ctx context.Context, payment *model.Pay
Charges: charges, Charges: charges,
Metadata: metadata, Metadata: metadata,
} }
resp, err := s.ledger.client.TransferInternal(ctx, req) resp, err := p.deps.ledger.client.TransferInternal(ctx, req)
if err != nil { if err != nil {
return err return err
} }
@@ -316,7 +158,7 @@ func (s *Service) performLedgerOperation(ctx context.Context, payment *model.Pay
return nil return nil
} }
func (s *Service) applyFX(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote, charges []*ledgerv1.PostingLine, description string, metadata map[string]string, exec *model.ExecutionRefs) error { func (p *paymentExecutor) applyFX(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote, charges []*ledgerv1.PostingLine, description string, metadata map[string]string, exec *model.ExecutionRefs) error {
intent := payment.Intent intent := payment.Intent
source := intent.Source.Ledger source := intent.Source.Ledger
destination := intent.Destination.Ledger destination := intent.Destination.Ledger
@@ -354,7 +196,7 @@ func (s *Service) applyFX(ctx context.Context, payment *model.Payment, quote *or
Charges: charges, Charges: charges,
Metadata: metadata, Metadata: metadata,
} }
resp, err := s.ledger.client.ApplyFXWithCharges(ctx, req) resp, err := p.deps.ledger.client.ApplyFXWithCharges(ctx, req)
if err != nil { if err != nil {
return err return err
} }
@@ -363,7 +205,7 @@ func (s *Service) applyFX(ctx context.Context, payment *model.Payment, quote *or
return nil return nil
} }
func (s *Service) submitChainTransfer(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote) (*chainv1.SubmitTransferResponse, error) { func (p *paymentExecutor) submitChainTransfer(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote) (*chainv1.SubmitTransferResponse, error) {
intent := payment.Intent intent := payment.Intent
source := intent.Source.ManagedWallet source := intent.Source.ManagedWallet
destination := intent.Destination destination := intent.Destination
@@ -389,23 +231,23 @@ func (s *Service) submitChainTransfer(ctx context.Context, payment *model.Paymen
Metadata: cloneMetadata(payment.Metadata), Metadata: cloneMetadata(payment.Metadata),
ClientReference: payment.PaymentRef, ClientReference: payment.PaymentRef,
} }
return s.gateway.client.SubmitTransfer(ctx, req) return p.deps.gateway.client.SubmitTransfer(ctx, req)
} }
func (s *Service) persistPayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment) error { func (p *paymentExecutor) persistPayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment) error {
if store == nil { if store == nil {
return errStorageUnavailable return errStorageUnavailable
} }
return store.Update(ctx, payment) return store.Update(ctx, payment)
} }
func (s *Service) failPayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, code model.PaymentFailureCode, reason string, err error) error { func (p *paymentExecutor) failPayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, code model.PaymentFailureCode, reason string, err error) error {
payment.State = model.PaymentStateFailed payment.State = model.PaymentStateFailed
payment.FailureCode = code payment.FailureCode = code
payment.FailureReason = strings.TrimSpace(reason) payment.FailureReason = strings.TrimSpace(reason)
if store != nil { if store != nil {
if updateErr := store.Update(ctx, payment); updateErr != nil { if updateErr := store.Update(ctx, payment); updateErr != nil {
s.logger.Error("failed to persist payment failure", zap.Error(updateErr), zap.String("payment_ref", payment.PaymentRef)) p.logger.Error("failed to persist payment failure", zap.Error(updateErr), zap.String("payment_ref", payment.PaymentRef))
} }
} }
if err != nil { if err != nil {
@@ -414,6 +256,21 @@ func (s *Service) failPayment(ctx context.Context, store storage.PaymentsStore,
return merrors.Internal(reason) return merrors.Internal(reason)
} }
func paymentDescription(payment *model.Payment) string {
if payment == nil {
return ""
}
if val := strings.TrimSpace(payment.Intent.Attributes["description"]); val != "" {
return val
}
if payment.Metadata != nil {
if val := strings.TrimSpace(payment.Metadata["description"]); val != "" {
return val
}
}
return payment.PaymentRef
}
func resolveLedgerAccounts(intent model.PaymentIntent) (string, string, error) { func resolveLedgerAccounts(intent model.PaymentIntent) (string, string, error) {
source := intent.Source.Ledger source := intent.Source.Ledger
destination := intent.Destination.Ledger destination := intent.Destination.Ledger
@@ -432,21 +289,6 @@ func resolveLedgerAccounts(intent model.PaymentIntent) (string, string, error) {
return strings.TrimSpace(source.LedgerAccountRef), to, nil return strings.TrimSpace(source.LedgerAccountRef), to, nil
} }
func paymentDescription(payment *model.Payment) string {
if payment == nil {
return ""
}
if val := strings.TrimSpace(payment.Intent.Attributes["description"]); val != "" {
return val
}
if payment.Metadata != nil {
if val := strings.TrimSpace(payment.Metadata["description"]); val != "" {
return val
}
}
return payment.PaymentRef
}
func requiresLedger(payment *model.Payment) bool { func requiresLedger(payment *model.Payment) bool {
if payment == nil { if payment == nil {
return false return false

View File

@@ -0,0 +1,145 @@
package orchestrator
import (
"fmt"
"sort"
"strings"
"time"
"github.com/shopspring/decimal"
"github.com/tech/sendico/payments/orchestrator/storage/model"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
)
func perIntentIdempotencyKey(base string, index int, total int) string {
base = strings.TrimSpace(base)
if base == "" {
return ""
}
if total <= 1 {
return base
}
return fmt.Sprintf("%s:%d", base, index+1)
}
func minQuoteExpiry(expires []time.Time) (time.Time, bool) {
var min time.Time
for _, exp := range expires {
if exp.IsZero() {
continue
}
if min.IsZero() || exp.Before(min) {
min = exp
}
}
if min.IsZero() {
return time.Time{}, false
}
return min, true
}
func aggregatePaymentQuotes(quotes []*orchestratorv1.PaymentQuote) (*orchestratorv1.PaymentQuoteAggregate, error) {
if len(quotes) == 0 {
return nil, nil
}
debitTotals := map[string]decimal.Decimal{}
settlementTotals := map[string]decimal.Decimal{}
feeTotals := map[string]decimal.Decimal{}
networkTotals := map[string]decimal.Decimal{}
for _, quote := range quotes {
if quote == nil {
continue
}
if err := accumulateMoney(debitTotals, quote.GetDebitAmount()); err != nil {
return nil, err
}
if err := accumulateMoney(settlementTotals, quote.GetExpectedSettlementAmount()); err != nil {
return nil, err
}
if err := accumulateMoney(feeTotals, quote.GetExpectedFeeTotal()); err != nil {
return nil, err
}
if nf := quote.GetNetworkFee(); nf != nil {
if err := accumulateMoney(networkTotals, nf.GetNetworkFee()); err != nil {
return nil, err
}
}
}
return &orchestratorv1.PaymentQuoteAggregate{
DebitAmounts: totalsToMoney(debitTotals),
ExpectedSettlementAmounts: totalsToMoney(settlementTotals),
ExpectedFeeTotals: totalsToMoney(feeTotals),
NetworkFeeTotals: totalsToMoney(networkTotals),
}, nil
}
func accumulateMoney(totals map[string]decimal.Decimal, money *moneyv1.Money) error {
if money == nil {
return nil
}
currency := strings.TrimSpace(money.GetCurrency())
if currency == "" {
return nil
}
amount, err := decimal.NewFromString(money.GetAmount())
if err != nil {
return err
}
if current, ok := totals[currency]; ok {
totals[currency] = current.Add(amount)
return nil
}
totals[currency] = amount
return nil
}
func totalsToMoney(totals map[string]decimal.Decimal) []*moneyv1.Money {
if len(totals) == 0 {
return nil
}
currencies := make([]string, 0, len(totals))
for currency := range totals {
currencies = append(currencies, currency)
}
sort.Strings(currencies)
result := make([]*moneyv1.Money, 0, len(currencies))
for _, currency := range currencies {
amount := totals[currency]
result = append(result, &moneyv1.Money{
Amount: amount.String(),
Currency: currency,
})
}
return result
}
func intentsFromProto(intents []*orchestratorv1.PaymentIntent) []model.PaymentIntent {
if len(intents) == 0 {
return nil
}
result := make([]model.PaymentIntent, 0, len(intents))
for _, intent := range intents {
result = append(result, intentFromProto(intent))
}
return result
}
func quoteSnapshotsFromProto(quotes []*orchestratorv1.PaymentQuote) []*model.PaymentQuoteSnapshot {
if len(quotes) == 0 {
return nil
}
result := make([]*model.PaymentQuoteSnapshot, 0, len(quotes))
for _, quote := range quotes {
if quote == nil {
continue
}
if snapshot := quoteSnapshotToModel(quote); snapshot != nil {
result = append(result, snapshot)
}
}
return result
}

View File

@@ -0,0 +1,102 @@
package orchestrator
import (
"testing"
"time"
"github.com/shopspring/decimal"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
)
func TestAggregatePaymentQuotes(t *testing.T) {
quotes := []*orchestratorv1.PaymentQuote{
{
DebitAmount: &moneyv1.Money{Amount: "10", Currency: "USD"},
ExpectedSettlementAmount: &moneyv1.Money{Amount: "8", Currency: "EUR"},
ExpectedFeeTotal: &moneyv1.Money{Amount: "1", Currency: "USD"},
NetworkFee: &chainv1.EstimateTransferFeeResponse{
NetworkFee: &moneyv1.Money{Amount: "0.5", Currency: "USD"},
},
},
{
DebitAmount: &moneyv1.Money{Amount: "5", Currency: "USD"},
ExpectedSettlementAmount: &moneyv1.Money{Amount: "1000", Currency: "NGN"},
ExpectedFeeTotal: &moneyv1.Money{Amount: "2", Currency: "USD"},
NetworkFee: &chainv1.EstimateTransferFeeResponse{
NetworkFee: &moneyv1.Money{Amount: "100", Currency: "NGN"},
},
},
}
agg, err := aggregatePaymentQuotes(quotes)
if err != nil {
t.Fatalf("aggregatePaymentQuotes returned error: %v", err)
}
assertMoneyTotals(t, agg.GetDebitAmounts(), map[string]string{"USD": "15"})
assertMoneyTotals(t, agg.GetExpectedSettlementAmounts(), map[string]string{"EUR": "8", "NGN": "1000"})
assertMoneyTotals(t, agg.GetExpectedFeeTotals(), map[string]string{"USD": "3"})
assertMoneyTotals(t, agg.GetNetworkFeeTotals(), map[string]string{"USD": "0.5", "NGN": "100"})
}
func TestAggregatePaymentQuotesInvalidAmount(t *testing.T) {
quotes := []*orchestratorv1.PaymentQuote{
{
DebitAmount: &moneyv1.Money{Amount: "bad", Currency: "USD"},
},
}
if _, err := aggregatePaymentQuotes(quotes); err == nil {
t.Fatal("expected error for invalid amount")
}
}
func TestMinQuoteExpiry(t *testing.T) {
now := time.Now().UTC()
later := now.Add(10 * time.Minute)
earliest := now.Add(5 * time.Minute)
min, ok := minQuoteExpiry([]time.Time{later, {}, earliest})
if !ok {
t.Fatal("expected min expiry to be set")
}
if !min.Equal(earliest) {
t.Fatalf("expected min expiry %v, got %v", earliest, min)
}
if _, ok := minQuoteExpiry([]time.Time{{}}); ok {
t.Fatal("expected min expiry to be unset")
}
}
func assertMoneyTotals(t *testing.T, list []*moneyv1.Money, expected map[string]string) {
t.Helper()
got := make(map[string]decimal.Decimal, len(list))
for _, item := range list {
if item == nil {
continue
}
val, err := decimal.NewFromString(item.GetAmount())
if err != nil {
t.Fatalf("invalid money amount %q: %v", item.GetAmount(), err)
}
got[item.GetCurrency()] = val
}
if len(got) != len(expected) {
t.Fatalf("expected %d totals, got %d", len(expected), len(got))
}
for currency, amount := range expected {
val, err := decimal.NewFromString(amount)
if err != nil {
t.Fatalf("invalid expected amount %q: %v", amount, err)
}
gotVal, ok := got[currency]
if !ok {
t.Fatalf("missing currency %s", currency)
}
if !gotVal.Equal(val) {
t.Fatalf("expected %s %s, got %s", amount, currency, gotVal.String())
}
}
}

View File

@@ -0,0 +1,261 @@
package orchestrator
import (
"context"
"strings"
"time"
oracleclient "github.com/tech/sendico/fx/oracle/client"
"github.com/tech/sendico/pkg/merrors"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
"go.uber.org/zap"
"google.golang.org/protobuf/types/known/timestamppb"
)
func (s *Service) buildPaymentQuote(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.PaymentQuote, time.Time, error) {
intent := req.GetIntent()
amount := intent.GetAmount()
fxSide := fxv1.Side_SIDE_UNSPECIFIED
if intent.GetFx() != nil {
fxSide = intent.GetFx().GetSide()
}
var fxQuote *oraclev1.Quote
var err error
if shouldRequestFX(intent) {
fxQuote, err = s.requestFXQuote(ctx, orgRef, req)
if err != nil {
return nil, time.Time{}, err
}
s.logger.Debug("fx quote attached to payment quote", zap.String("org_ref", orgRef))
}
payAmount, settlementAmountBeforeFees := resolveTradeAmounts(amount, fxQuote, fxSide)
feeBaseAmount := payAmount
if feeBaseAmount == nil {
feeBaseAmount = cloneMoney(amount)
}
feeQuote, err := s.quoteFees(ctx, orgRef, req, feeBaseAmount)
if err != nil {
return nil, time.Time{}, err
}
feeCurrency := ""
if feeBaseAmount != nil {
feeCurrency = feeBaseAmount.GetCurrency()
} else if amount != nil {
feeCurrency = amount.GetCurrency()
}
feeLines := cloneFeeLines(feeQuote.GetLines())
s.assignFeeLedgerAccounts(intent, feeLines)
feeTotal := extractFeeTotal(feeLines, feeCurrency)
var networkFee *chainv1.EstimateTransferFeeResponse
if shouldEstimateNetworkFee(intent) {
networkFee, err = s.estimateNetworkFee(ctx, intent)
if err != nil {
return nil, time.Time{}, err
}
s.logger.Debug("network fee estimated", zap.String("org_ref", orgRef))
}
debitAmount, settlementAmount := computeAggregates(payAmount, settlementAmountBeforeFees, feeTotal, networkFee, fxQuote, intent.GetSettlementMode())
quote := &orchestratorv1.PaymentQuote{
DebitAmount: debitAmount,
ExpectedSettlementAmount: settlementAmount,
ExpectedFeeTotal: feeTotal,
FeeLines: feeLines,
FeeRules: cloneFeeRules(feeQuote.GetApplied()),
FxQuote: fxQuote,
NetworkFee: networkFee,
}
expiresAt := quoteExpiry(s.clock.Now(), feeQuote, fxQuote)
return quote, expiresAt, nil
}
func (s *Service) quoteFees(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest, baseAmount *moneyv1.Money) (*feesv1.PrecomputeFeesResponse, error) {
if !s.deps.fees.available() {
return &feesv1.PrecomputeFeesResponse{}, nil
}
intent := req.GetIntent()
amount := cloneMoney(baseAmount)
if amount == nil {
amount = cloneMoney(intent.GetAmount())
}
feeIntent := &feesv1.Intent{
Trigger: triggerFromKind(intent.GetKind(), intent.GetRequiresFx()),
BaseAmount: amount,
BookedAt: timestamppb.New(s.clock.Now()),
OriginType: "payments.orchestrator.quote",
OriginRef: strings.TrimSpace(req.GetIdempotencyKey()),
Attributes: cloneMetadata(intent.GetAttributes()),
}
timeout := req.GetMeta().GetTrace()
ctxTimeout, cancel := s.withTimeout(ctx, s.deps.fees.timeout)
defer cancel()
resp, err := s.deps.fees.client.PrecomputeFees(ctxTimeout, &feesv1.PrecomputeFeesRequest{
Meta: &feesv1.RequestMeta{
OrganizationRef: orgRef,
Trace: timeout,
},
Intent: feeIntent,
TtlMs: defaultFeeQuoteTTLMillis,
})
if err != nil {
s.logger.Warn("fees precompute failed", zap.Error(err))
return nil, merrors.Internal("fees_precompute_failed")
}
return resp, nil
}
func (s *Service) estimateNetworkFee(ctx context.Context, intent *orchestratorv1.PaymentIntent) (*chainv1.EstimateTransferFeeResponse, error) {
if !s.deps.gateway.available() {
return nil, nil
}
req := &chainv1.EstimateTransferFeeRequest{
Amount: cloneMoney(intent.GetAmount()),
}
if src := intent.GetSource().GetManagedWallet(); src != nil {
req.SourceWalletRef = strings.TrimSpace(src.GetManagedWalletRef())
}
if dst := intent.GetDestination().GetManagedWallet(); dst != nil {
req.Destination = &chainv1.TransferDestination{
Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: strings.TrimSpace(dst.GetManagedWalletRef())},
}
}
if dst := intent.GetDestination().GetExternalChain(); dst != nil {
req.Destination = &chainv1.TransferDestination{
Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: strings.TrimSpace(dst.GetAddress())},
Memo: strings.TrimSpace(dst.GetMemo()),
}
req.Asset = dst.GetAsset()
}
if req.Asset == nil {
if src := intent.GetSource().GetManagedWallet(); src != nil {
req.Asset = src.GetAsset()
}
}
resp, err := s.deps.gateway.client.EstimateTransferFee(ctx, req)
if err != nil {
s.logger.Warn("chain gateway fee estimation failed", zap.Error(err))
return nil, merrors.Internal("chain_gateway_fee_estimation_failed")
}
return resp, nil
}
func (s *Service) requestFXQuote(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest) (*oraclev1.Quote, error) {
if !s.deps.oracle.available() {
return nil, nil
}
intent := req.GetIntent()
meta := req.GetMeta()
fxIntent := intent.GetFx()
if fxIntent == nil {
return nil, nil
}
ttl := fxIntent.GetTtlMs()
if ttl <= 0 {
ttl = defaultOracleTTLMillis
}
params := oracleclient.GetQuoteParams{
Meta: oracleclient.RequestMeta{
OrganizationRef: orgRef,
Trace: meta.GetTrace(),
},
Pair: fxIntent.GetPair(),
Side: fxIntent.GetSide(),
Firm: fxIntent.GetFirm(),
TTL: time.Duration(ttl) * time.Millisecond,
PreferredProvider: strings.TrimSpace(fxIntent.GetPreferredProvider()),
}
if fxIntent.GetMaxAgeMs() > 0 {
params.MaxAge = time.Duration(fxIntent.GetMaxAgeMs()) * time.Millisecond
}
if amount := intent.GetAmount(); amount != nil {
pair := fxIntent.GetPair()
if pair != nil {
switch {
case strings.EqualFold(amount.GetCurrency(), pair.GetBase()):
params.BaseAmount = cloneMoney(amount)
case strings.EqualFold(amount.GetCurrency(), pair.GetQuote()):
params.QuoteAmount = cloneMoney(amount)
default:
params.BaseAmount = cloneMoney(amount)
}
} else {
params.BaseAmount = cloneMoney(amount)
}
}
quote, err := s.deps.oracle.client.GetQuote(ctx, params)
if err != nil {
s.logger.Warn("fx oracle quote failed", zap.Error(err))
return nil, merrors.Internal("fx_quote_failed")
}
return quoteToProto(quote), nil
}
func (s *Service) feeLedgerAccountForIntent(intent *orchestratorv1.PaymentIntent) string {
if intent == nil || len(s.deps.feeLedgerAccounts) == 0 {
return ""
}
key := s.gatewayKeyFromIntent(intent)
if key == "" {
return ""
}
return strings.TrimSpace(s.deps.feeLedgerAccounts[key])
}
func (s *Service) assignFeeLedgerAccounts(intent *orchestratorv1.PaymentIntent, lines []*feesv1.DerivedPostingLine) {
account := s.feeLedgerAccountForIntent(intent)
key := s.gatewayKeyFromIntent(intent)
missing := 0
for _, line := range lines {
if line == nil {
continue
}
if strings.TrimSpace(line.GetLedgerAccountRef()) == "" {
missing++
}
}
if missing == 0 {
return
}
if account == "" {
s.logger.Debug("no fee ledger account mapping found", zap.String("gateway", key), zap.Int("missing_lines", missing))
return
}
assignLedgerAccounts(lines, account)
s.logger.Debug("applied fee ledger account mapping", zap.String("gateway", key), zap.String("ledger_account", account), zap.Int("lines", missing))
}
func (s *Service) gatewayKeyFromIntent(intent *orchestratorv1.PaymentIntent) string {
if intent == nil {
return ""
}
key := strings.TrimSpace(intent.GetAttributes()["gateway"])
if key == "" {
if dest := intent.GetDestination(); dest != nil && dest.GetCard() != nil {
key = defaultCardGateway
}
}
return strings.ToLower(key)
}

View File

@@ -19,19 +19,21 @@ func TestRequestFXQuoteUsesQuoteAmountWhenCurrencyMatchesQuote(t *testing.T) {
svc := &Service{ svc := &Service{
logger: zap.NewNop(), logger: zap.NewNop(),
clock: testClock{now: time.Now()}, clock: testClock{now: time.Now()},
oracle: oracleDependency{ deps: serviceDependencies{
client: &oracleclient.Fake{ oracle: oracleDependency{
GetQuoteFn: func(ctx context.Context, params oracleclient.GetQuoteParams) (*oracleclient.Quote, error) { client: &oracleclient.Fake{
captured = params GetQuoteFn: func(ctx context.Context, params oracleclient.GetQuoteParams) (*oracleclient.Quote, error) {
return &oracleclient.Quote{ captured = params
QuoteRef: "q", return &oracleclient.Quote{
Pair: params.Pair, QuoteRef: "q",
Side: params.Side, Pair: params.Pair,
Price: "1.1", Side: params.Side,
BaseAmount: params.BaseAmount, Price: "1.1",
QuoteAmount: params.QuoteAmount, BaseAmount: params.BaseAmount,
ExpiresAt: time.Now(), QuoteAmount: params.QuoteAmount,
}, nil ExpiresAt: time.Now(),
}, nil
},
}, },
}, },
}, },

View File

@@ -2,21 +2,14 @@ package orchestrator
import ( import (
"context" "context"
"strings"
"github.com/tech/sendico/payments/orchestrator/storage" "github.com/tech/sendico/payments/orchestrator/storage"
"github.com/tech/sendico/payments/orchestrator/storage/model" "github.com/tech/sendico/payments/orchestrator/storage/model"
"github.com/tech/sendico/pkg/api/routers" "github.com/tech/sendico/pkg/api/routers"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
clockpkg "github.com/tech/sendico/pkg/clock" clockpkg "github.com/tech/sendico/pkg/clock"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
"go.mongodb.org/mongo-driver/bson/primitive"
"google.golang.org/grpc" "google.golang.org/grpc"
"google.golang.org/protobuf/proto"
) )
type serviceError string type serviceError string
@@ -40,14 +33,33 @@ type Service struct {
storage storage.Repository storage storage.Repository
clock clockpkg.Clock clock clockpkg.Clock
fees feesDependency deps serviceDependencies
ledger ledgerDependency h handlerSet
gateway gatewayDependency comp componentSet
oracle oracleDependency
orchestratorv1.UnimplementedPaymentOrchestratorServer orchestratorv1.UnimplementedPaymentOrchestratorServer
} }
type serviceDependencies struct {
fees feesDependency
ledger ledgerDependency
gateway gatewayDependency
oracle oracleDependency
mntx mntxDependency
cardRoutes map[string]CardGatewayRoute
feeLedgerAccounts map[string]string
}
type handlerSet struct {
commands *paymentCommandFactory
queries *paymentQueryHandler
events *paymentEventHandler
}
type componentSet struct {
executor *paymentExecutor
}
// NewService constructs a payment orchestrator service. // NewService constructs a payment orchestrator service.
func NewService(logger mlogger.Logger, repo storage.Repository, opts ...Option) *Service { func NewService(logger mlogger.Logger, repo storage.Repository, opts ...Option) *Service {
svc := &Service{ svc := &Service{
@@ -68,9 +80,30 @@ func NewService(logger mlogger.Logger, repo storage.Repository, opts ...Option)
svc.clock = clockpkg.NewSystem() svc.clock = clockpkg.NewSystem()
} }
engine := defaultPaymentEngine{svc: svc}
svc.h.commands = newPaymentCommandFactory(engine, svc.logger)
svc.h.queries = newPaymentQueryHandler(svc.storage, svc.ensureRepository, svc.logger.Named("queries"))
svc.h.events = newPaymentEventHandler(svc.storage, svc.ensureRepository, svc.logger.Named("events"))
svc.comp.executor = newPaymentExecutor(&svc.deps, svc.logger.Named("payment_executor"), svc)
return svc return svc
} }
func (s *Service) ensureHandlers() {
if s.h.commands == nil {
s.h.commands = newPaymentCommandFactory(defaultPaymentEngine{svc: s}, s.logger)
}
if s.h.queries == nil {
s.h.queries = newPaymentQueryHandler(s.storage, s.ensureRepository, s.logger.Named("queries"))
}
if s.h.events == nil {
s.h.events = newPaymentEventHandler(s.storage, s.ensureRepository, s.logger.Named("events"))
}
if s.comp.executor == nil {
s.comp.executor = newPaymentExecutor(&s.deps, s.logger.Named("payment_executor"), s)
}
}
// Register attaches the service to the supplied gRPC router. // Register attaches the service to the supplied gRPC router.
func (s *Service) Register(router routers.GRPC) error { func (s *Service) Register(router routers.GRPC) error {
return router.Register(func(reg grpc.ServiceRegistrar) { return router.Register(func(reg grpc.ServiceRegistrar) {
@@ -80,474 +113,71 @@ func (s *Service) Register(router routers.GRPC) error {
// QuotePayment aggregates downstream quotes. // QuotePayment aggregates downstream quotes.
func (s *Service) QuotePayment(ctx context.Context, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.QuotePaymentResponse, error) { func (s *Service) QuotePayment(ctx context.Context, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.QuotePaymentResponse, error) {
return executeUnary(ctx, s, "QuotePayment", s.quotePaymentHandler, req) s.ensureHandlers()
return executeUnary(ctx, s, "QuotePayment", s.h.commands.QuotePayment().Execute, req)
}
// QuotePayments aggregates downstream quotes for multiple intents.
func (s *Service) QuotePayments(ctx context.Context, req *orchestratorv1.QuotePaymentsRequest) (*orchestratorv1.QuotePaymentsResponse, error) {
s.ensureHandlers()
return executeUnary(ctx, s, "QuotePayments", s.h.commands.QuotePayments().Execute, req)
} }
// InitiatePayment captures a payment intent and reserves funds orchestration. // InitiatePayment captures a payment intent and reserves funds orchestration.
func (s *Service) InitiatePayment(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error) { func (s *Service) InitiatePayment(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error) {
return executeUnary(ctx, s, "InitiatePayment", s.initiatePaymentHandler, req) s.ensureHandlers()
return executeUnary(ctx, s, "InitiatePayment", s.h.commands.InitiatePayment().Execute, req)
}
// InitiatePayments executes multiple payments using a stored quote reference.
func (s *Service) InitiatePayments(ctx context.Context, req *orchestratorv1.InitiatePaymentsRequest) (*orchestratorv1.InitiatePaymentsResponse, error) {
s.ensureHandlers()
return executeUnary(ctx, s, "InitiatePayments", s.h.commands.InitiatePayments().Execute, req)
} }
// CancelPayment attempts to cancel an in-flight payment. // CancelPayment attempts to cancel an in-flight payment.
func (s *Service) CancelPayment(ctx context.Context, req *orchestratorv1.CancelPaymentRequest) (*orchestratorv1.CancelPaymentResponse, error) { func (s *Service) CancelPayment(ctx context.Context, req *orchestratorv1.CancelPaymentRequest) (*orchestratorv1.CancelPaymentResponse, error) {
return executeUnary(ctx, s, "CancelPayment", s.cancelPaymentHandler, req) s.ensureHandlers()
return executeUnary(ctx, s, "CancelPayment", s.h.commands.CancelPayment().Execute, req)
} }
// GetPayment returns a stored payment record. // GetPayment returns a stored payment record.
func (s *Service) GetPayment(ctx context.Context, req *orchestratorv1.GetPaymentRequest) (*orchestratorv1.GetPaymentResponse, error) { func (s *Service) GetPayment(ctx context.Context, req *orchestratorv1.GetPaymentRequest) (*orchestratorv1.GetPaymentResponse, error) {
return executeUnary(ctx, s, "GetPayment", s.getPaymentHandler, req) s.ensureHandlers()
return executeUnary(ctx, s, "GetPayment", s.h.queries.getPayment, req)
} }
// ListPayments lists stored payment records. // ListPayments lists stored payment records.
func (s *Service) ListPayments(ctx context.Context, req *orchestratorv1.ListPaymentsRequest) (*orchestratorv1.ListPaymentsResponse, error) { func (s *Service) ListPayments(ctx context.Context, req *orchestratorv1.ListPaymentsRequest) (*orchestratorv1.ListPaymentsResponse, error) {
return executeUnary(ctx, s, "ListPayments", s.listPaymentsHandler, req) s.ensureHandlers()
return executeUnary(ctx, s, "ListPayments", s.h.queries.listPayments, req)
} }
// InitiateConversion orchestrates standalone FX conversions. // InitiateConversion orchestrates standalone FX conversions.
func (s *Service) InitiateConversion(ctx context.Context, req *orchestratorv1.InitiateConversionRequest) (*orchestratorv1.InitiateConversionResponse, error) { func (s *Service) InitiateConversion(ctx context.Context, req *orchestratorv1.InitiateConversionRequest) (*orchestratorv1.InitiateConversionResponse, error) {
return executeUnary(ctx, s, "InitiateConversion", s.initiateConversionHandler, req) s.ensureHandlers()
return executeUnary(ctx, s, "InitiateConversion", s.h.commands.InitiateConversion().Execute, req)
} }
// ProcessTransferUpdate reconciles chain events back into payment state. // ProcessTransferUpdate reconciles chain events back into payment state.
func (s *Service) ProcessTransferUpdate(ctx context.Context, req *orchestratorv1.ProcessTransferUpdateRequest) (*orchestratorv1.ProcessTransferUpdateResponse, error) { func (s *Service) ProcessTransferUpdate(ctx context.Context, req *orchestratorv1.ProcessTransferUpdateRequest) (*orchestratorv1.ProcessTransferUpdateResponse, error) {
return executeUnary(ctx, s, "ProcessTransferUpdate", s.processTransferUpdateHandler, req) s.ensureHandlers()
return executeUnary(ctx, s, "ProcessTransferUpdate", s.h.events.processTransferUpdate, req)
} }
// ProcessDepositObserved reconciles deposit events to ledger. // ProcessDepositObserved reconciles deposit events to ledger.
func (s *Service) ProcessDepositObserved(ctx context.Context, req *orchestratorv1.ProcessDepositObservedRequest) (*orchestratorv1.ProcessDepositObservedResponse, error) { func (s *Service) ProcessDepositObserved(ctx context.Context, req *orchestratorv1.ProcessDepositObservedRequest) (*orchestratorv1.ProcessDepositObservedResponse, error) {
return executeUnary(ctx, s, "ProcessDepositObserved", s.processDepositObservedHandler, req) s.ensureHandlers()
return executeUnary(ctx, s, "ProcessDepositObserved", s.h.events.processDepositObserved, req)
} }
func (s *Service) quotePaymentHandler(ctx context.Context, req *orchestratorv1.QuotePaymentRequest) gsresponse.Responder[orchestratorv1.QuotePaymentResponse] { // ProcessCardPayoutUpdate reconciles card payout events back into payment state.
if err := s.ensureRepository(ctx); err != nil { func (s *Service) ProcessCardPayoutUpdate(ctx context.Context, req *orchestratorv1.ProcessCardPayoutUpdateRequest) (*orchestratorv1.ProcessCardPayoutUpdateResponse, error) {
return gsresponse.Unavailable[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, err) s.ensureHandlers()
} return executeUnary(ctx, s, "ProcessCardPayoutUpdate", s.h.events.processCardPayoutUpdate, req)
if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
}
meta := req.GetMeta()
if meta == nil {
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("meta is required"))
}
orgRef := strings.TrimSpace(meta.GetOrganizationRef())
if orgRef == "" {
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("organization_ref is required"))
}
orgObjectID, parseErr := primitive.ObjectIDFromHex(orgRef)
if parseErr != nil {
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("organization_ref must be a valid objectID"))
}
intent := req.GetIntent()
if intent == nil {
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("intent is required"))
}
if intent.GetAmount() == nil {
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("intent.amount is required"))
}
quote, expiresAt, err := s.buildPaymentQuote(ctx, orgRef, req)
if err != nil {
return gsresponse.Auto[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
}
if !req.GetPreviewOnly() {
quotesStore := s.storage.Quotes()
if quotesStore == nil {
return gsresponse.Unavailable[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, errStorageUnavailable)
}
quoteRef := primitive.NewObjectID().Hex()
quote.QuoteRef = quoteRef
record := &model.PaymentQuoteRecord{
QuoteRef: quoteRef,
Intent: intentFromProto(intent),
Quote: quoteSnapshotToModel(quote),
ExpiresAt: expiresAt,
}
record.SetID(primitive.NewObjectID())
record.SetOrganizationRef(orgObjectID)
if err := quotesStore.Create(ctx, record); err != nil {
return gsresponse.Auto[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
}
}
return gsresponse.Success(&orchestratorv1.QuotePaymentResponse{Quote: quote})
} }
func (s *Service) initiatePaymentHandler(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) gsresponse.Responder[orchestratorv1.InitiatePaymentResponse] { func (s *Service) executePayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, quote *orchestratorv1.PaymentQuote) error {
if err := s.ensureRepository(ctx); err != nil { s.ensureHandlers()
return gsresponse.Unavailable[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, err) return s.comp.executor.executePayment(ctx, store, payment, quote)
}
if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
}
meta := req.GetMeta()
if meta == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("meta is required"))
}
orgRef := strings.TrimSpace(meta.GetOrganizationRef())
if orgRef == "" {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("organization_ref is required"))
}
orgObjectID, parseErr := primitive.ObjectIDFromHex(orgRef)
if parseErr != nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("organization_ref must be a valid objectID"))
}
intent := req.GetIntent()
if intent == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("intent is required"))
}
if intent.GetAmount() == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("intent.amount is required"))
}
idempotencyKey := strings.TrimSpace(req.GetIdempotencyKey())
if idempotencyKey == "" {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("idempotency_key is required"))
}
store := s.storage.Payments()
if store == nil {
return gsresponse.Unavailable[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, errStorageUnavailable)
}
existing, err := store.GetByIdempotencyKey(ctx, orgObjectID, idempotencyKey)
if err == nil && existing != nil {
return gsresponse.Success(&orchestratorv1.InitiatePaymentResponse{
Payment: toProtoPayment(existing),
})
}
if err != nil && err != storage.ErrPaymentNotFound {
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
}
quoteRef := strings.TrimSpace(req.GetQuoteRef())
quote := strings.TrimSpace(req.GetFeeQuoteToken())
var quoteSnapshot *orchestratorv1.PaymentQuote
if quoteRef != "" {
quotesStore := s.storage.Quotes()
if quotesStore == nil {
return gsresponse.Unavailable[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, errStorageUnavailable)
}
record, err := quotesStore.GetByRef(ctx, orgObjectID, quoteRef)
if err != nil {
if err == storage.ErrQuoteNotFound {
return gsresponse.FailedPrecondition[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, "quote_not_found", merrors.InvalidArgument("quote_ref not found or expired"))
}
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
}
if !record.ExpiresAt.IsZero() && s.clock.Now().After(record.ExpiresAt) {
return gsresponse.FailedPrecondition[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, "quote_expired", merrors.InvalidArgument("quote_ref expired"))
}
if !proto.Equal(protoIntentFromModel(record.Intent), intent) {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("quote_ref does not match intent"))
}
quoteSnapshot = modelQuoteToProto(record.Quote)
if quoteSnapshot == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("stored quote is empty"))
}
quoteSnapshot.QuoteRef = quoteRef
} else if quote == "" {
quoteSnapshot, _, err = s.buildPaymentQuote(ctx, orgRef, &orchestratorv1.QuotePaymentRequest{
Meta: req.GetMeta(),
IdempotencyKey: req.GetIdempotencyKey(),
Intent: req.GetIntent(),
PreviewOnly: false,
})
if err != nil {
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
}
} else {
quoteSnapshot = &orchestratorv1.PaymentQuote{FeeQuoteToken: quote}
}
entity := &model.Payment{}
entity.SetID(primitive.NewObjectID())
entity.SetOrganizationRef(orgObjectID)
entity.PaymentRef = entity.GetID().Hex()
entity.IdempotencyKey = idempotencyKey
entity.State = model.PaymentStateAccepted
entity.Intent = intentFromProto(intent)
entity.Metadata = cloneMetadata(req.GetMetadata())
entity.LastQuote = quoteSnapshotToModel(quoteSnapshot)
entity.Normalize()
if err = store.Create(ctx, entity); err != nil {
if err == storage.ErrDuplicatePayment {
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.DataConflict("payment already exists"))
}
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
}
if quoteSnapshot == nil {
quoteSnapshot = &orchestratorv1.PaymentQuote{}
}
if err := s.executePayment(ctx, store, entity, quoteSnapshot); err != nil {
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
}
return gsresponse.Success(&orchestratorv1.InitiatePaymentResponse{
Payment: toProtoPayment(entity),
})
}
func (s *Service) cancelPaymentHandler(ctx context.Context, req *orchestratorv1.CancelPaymentRequest) gsresponse.Responder[orchestratorv1.CancelPaymentResponse] {
if err := s.ensureRepository(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.CancelPaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
}
if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.CancelPaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
}
paymentRef := strings.TrimSpace(req.GetPaymentRef())
if paymentRef == "" {
return gsresponse.InvalidArgument[orchestratorv1.CancelPaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("payment_ref is required"))
}
store := s.storage.Payments()
if store == nil {
return gsresponse.Unavailable[orchestratorv1.CancelPaymentResponse](s.logger, mservice.PaymentOrchestrator, errStorageUnavailable)
}
payment, err := store.GetByPaymentRef(ctx, paymentRef)
if err != nil {
if err == storage.ErrPaymentNotFound {
return gsresponse.NotFound[orchestratorv1.CancelPaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
}
return gsresponse.Auto[orchestratorv1.CancelPaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
}
if payment.State != model.PaymentStateAccepted {
reason := merrors.InvalidArgument("payment cannot be cancelled in current state")
return gsresponse.FailedPrecondition[orchestratorv1.CancelPaymentResponse](s.logger, mservice.PaymentOrchestrator, "payment_not_cancellable", reason)
}
payment.State = model.PaymentStateCancelled
payment.FailureCode = model.PaymentFailureCodePolicy
payment.FailureReason = strings.TrimSpace(req.GetReason())
if err := store.Update(ctx, payment); err != nil {
return gsresponse.Auto[orchestratorv1.CancelPaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
}
return gsresponse.Success(&orchestratorv1.CancelPaymentResponse{Payment: toProtoPayment(payment)})
}
func (s *Service) getPaymentHandler(ctx context.Context, req *orchestratorv1.GetPaymentRequest) gsresponse.Responder[orchestratorv1.GetPaymentResponse] {
if err := s.ensureRepository(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.GetPaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
}
if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.GetPaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
}
paymentRef := strings.TrimSpace(req.GetPaymentRef())
if paymentRef == "" {
return gsresponse.InvalidArgument[orchestratorv1.GetPaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("payment_ref is required"))
}
store := s.storage.Payments()
if store == nil {
return gsresponse.Unavailable[orchestratorv1.GetPaymentResponse](s.logger, mservice.PaymentOrchestrator, errStorageUnavailable)
}
entity, err := store.GetByPaymentRef(ctx, paymentRef)
if err != nil {
if err == storage.ErrPaymentNotFound {
return gsresponse.NotFound[orchestratorv1.GetPaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
}
return gsresponse.Auto[orchestratorv1.GetPaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
}
return gsresponse.Success(&orchestratorv1.GetPaymentResponse{Payment: toProtoPayment(entity)})
}
func (s *Service) listPaymentsHandler(ctx context.Context, req *orchestratorv1.ListPaymentsRequest) gsresponse.Responder[orchestratorv1.ListPaymentsResponse] {
if err := s.ensureRepository(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.ListPaymentsResponse](s.logger, mservice.PaymentOrchestrator, err)
}
if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.ListPaymentsResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
}
store := s.storage.Payments()
if store == nil {
return gsresponse.Unavailable[orchestratorv1.ListPaymentsResponse](s.logger, mservice.PaymentOrchestrator, errStorageUnavailable)
}
filter := filterFromProto(req)
result, err := store.List(ctx, filter)
if err != nil {
return gsresponse.Auto[orchestratorv1.ListPaymentsResponse](s.logger, mservice.PaymentOrchestrator, err)
}
resp := &orchestratorv1.ListPaymentsResponse{
Page: &paginationv1.CursorPageResponse{
NextCursor: result.NextCursor,
},
}
resp.Payments = make([]*orchestratorv1.Payment, 0, len(result.Items))
for _, item := range result.Items {
resp.Payments = append(resp.Payments, toProtoPayment(item))
}
return gsresponse.Success(resp)
}
func (s *Service) initiateConversionHandler(ctx context.Context, req *orchestratorv1.InitiateConversionRequest) gsresponse.Responder[orchestratorv1.InitiateConversionResponse] {
if err := s.ensureRepository(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, err)
}
if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
}
meta := req.GetMeta()
if meta == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("meta is required"))
}
orgRef := strings.TrimSpace(meta.GetOrganizationRef())
if orgRef == "" {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("organization_ref is required"))
}
orgObjectID, parseErr := primitive.ObjectIDFromHex(orgRef)
if parseErr != nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("organization_ref must be a valid objectID"))
}
idempotencyKey := strings.TrimSpace(req.GetIdempotencyKey())
if idempotencyKey == "" {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("idempotency_key is required"))
}
if req.GetSource() == nil || req.GetSource().GetLedger() == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("source ledger endpoint is required"))
}
if req.GetDestination() == nil || req.GetDestination().GetLedger() == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("destination ledger endpoint is required"))
}
fxIntent := req.GetFx()
if fxIntent == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("fx intent is required"))
}
store := s.storage.Payments()
if store == nil {
return gsresponse.Unavailable[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, errStorageUnavailable)
}
if existing, err := store.GetByIdempotencyKey(ctx, orgObjectID, idempotencyKey); err == nil && existing != nil {
return gsresponse.Success(&orchestratorv1.InitiateConversionResponse{Conversion: toProtoPayment(existing)})
} else if err != nil && err != storage.ErrPaymentNotFound {
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, err)
}
amount, err := conversionAmountFromMetadata(req.GetMetadata(), fxIntent)
if err != nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, err)
}
intentProto := &orchestratorv1.PaymentIntent{
Kind: orchestratorv1.PaymentKind_PAYMENT_KIND_FX_CONVERSION,
Source: req.GetSource(),
Destination: req.GetDestination(),
Amount: amount,
RequiresFx: true,
Fx: fxIntent,
FeePolicy: req.GetFeePolicy(),
}
quote, _, err := s.buildPaymentQuote(ctx, orgRef, &orchestratorv1.QuotePaymentRequest{
Meta: req.GetMeta(),
IdempotencyKey: req.GetIdempotencyKey(),
Intent: intentProto,
})
if err != nil {
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, err)
}
entity := &model.Payment{}
entity.SetID(primitive.NewObjectID())
entity.SetOrganizationRef(orgObjectID)
entity.PaymentRef = entity.GetID().Hex()
entity.IdempotencyKey = idempotencyKey
entity.State = model.PaymentStateAccepted
entity.Intent = intentFromProto(intentProto)
entity.Metadata = cloneMetadata(req.GetMetadata())
entity.LastQuote = quoteSnapshotToModel(quote)
entity.Normalize()
if err = store.Create(ctx, entity); err != nil {
if err == storage.ErrDuplicatePayment {
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, merrors.DataConflict("payment already exists"))
}
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, err)
}
if err := s.executePayment(ctx, store, entity, quote); err != nil {
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, err)
}
return gsresponse.Success(&orchestratorv1.InitiateConversionResponse{
Conversion: toProtoPayment(entity),
})
}
func (s *Service) processTransferUpdateHandler(ctx context.Context, req *orchestratorv1.ProcessTransferUpdateRequest) gsresponse.Responder[orchestratorv1.ProcessTransferUpdateResponse] {
if err := s.ensureRepository(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.ProcessTransferUpdateResponse](s.logger, mservice.PaymentOrchestrator, err)
}
if req == nil || req.GetEvent() == nil || req.GetEvent().GetTransfer() == nil {
return gsresponse.InvalidArgument[orchestratorv1.ProcessTransferUpdateResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("transfer event is required"))
}
transfer := req.GetEvent().GetTransfer()
transferRef := strings.TrimSpace(transfer.GetTransferRef())
if transferRef == "" {
return gsresponse.InvalidArgument[orchestratorv1.ProcessTransferUpdateResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("transfer_ref is required"))
}
store := s.storage.Payments()
if store == nil {
return gsresponse.Unavailable[orchestratorv1.ProcessTransferUpdateResponse](s.logger, mservice.PaymentOrchestrator, errStorageUnavailable)
}
payment, err := store.GetByChainTransferRef(ctx, transferRef)
if err != nil {
if err == storage.ErrPaymentNotFound {
return gsresponse.NotFound[orchestratorv1.ProcessTransferUpdateResponse](s.logger, mservice.PaymentOrchestrator, err)
}
return gsresponse.Auto[orchestratorv1.ProcessTransferUpdateResponse](s.logger, mservice.PaymentOrchestrator, err)
}
applyTransferStatus(req.GetEvent(), payment)
if err := store.Update(ctx, payment); err != nil {
return gsresponse.Auto[orchestratorv1.ProcessTransferUpdateResponse](s.logger, mservice.PaymentOrchestrator, err)
}
return gsresponse.Success(&orchestratorv1.ProcessTransferUpdateResponse{Payment: toProtoPayment(payment)})
}
func (s *Service) processDepositObservedHandler(ctx context.Context, req *orchestratorv1.ProcessDepositObservedRequest) gsresponse.Responder[orchestratorv1.ProcessDepositObservedResponse] {
if err := s.ensureRepository(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.ProcessDepositObservedResponse](s.logger, mservice.PaymentOrchestrator, err)
}
if req == nil || req.GetEvent() == nil {
return gsresponse.InvalidArgument[orchestratorv1.ProcessDepositObservedResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("deposit event is required"))
}
event := req.GetEvent()
walletRef := strings.TrimSpace(event.GetWalletRef())
if walletRef == "" {
return gsresponse.InvalidArgument[orchestratorv1.ProcessDepositObservedResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("wallet_ref is required"))
}
store := s.storage.Payments()
if store == nil {
return gsresponse.Unavailable[orchestratorv1.ProcessDepositObservedResponse](s.logger, mservice.PaymentOrchestrator, errStorageUnavailable)
}
filter := &model.PaymentFilter{
States: []model.PaymentState{model.PaymentStateSubmitted, model.PaymentStateFundsReserved},
DestinationRef: walletRef,
}
result, err := store.List(ctx, filter)
if err != nil {
return gsresponse.Auto[orchestratorv1.ProcessDepositObservedResponse](s.logger, mservice.PaymentOrchestrator, err)
}
for _, payment := range result.Items {
if payment.Intent.Destination.Type != model.EndpointTypeManagedWallet {
continue
}
if !moneyEquals(payment.Intent.Amount, event.GetAmount()) {
continue
}
payment.State = model.PaymentStateSettled
payment.FailureCode = model.PaymentFailureCodeUnspecified
payment.FailureReason = ""
if payment.Execution == nil {
payment.Execution = &model.ExecutionRefs{}
}
if payment.Execution.ChainTransferRef == "" {
payment.Execution.ChainTransferRef = strings.TrimSpace(event.GetTransactionHash())
}
if err := store.Update(ctx, payment); err != nil {
return gsresponse.Auto[orchestratorv1.ProcessDepositObservedResponse](s.logger, mservice.PaymentOrchestrator, err)
}
return gsresponse.Success(&orchestratorv1.ProcessDepositObservedResponse{Payment: toProtoPayment(payment)})
}
return gsresponse.Success(&orchestratorv1.ProcessDepositObservedResponse{})
} }

View File

@@ -0,0 +1,165 @@
package orchestrator
import (
"context"
"errors"
"strings"
"github.com/tech/sendico/payments/orchestrator/storage"
"github.com/tech/sendico/payments/orchestrator/storage/model"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
"go.mongodb.org/mongo-driver/bson/primitive"
"google.golang.org/protobuf/proto"
)
func validateMetaAndOrgRef(meta *orchestratorv1.RequestMeta) (string, primitive.ObjectID, error) {
if meta == nil {
return "", primitive.NilObjectID, merrors.InvalidArgument("meta is required")
}
orgRef := strings.TrimSpace(meta.GetOrganizationRef())
if orgRef == "" {
return "", primitive.NilObjectID, merrors.InvalidArgument("organization_ref is required")
}
orgID, err := primitive.ObjectIDFromHex(orgRef)
if err != nil {
return "", primitive.NilObjectID, merrors.InvalidArgument("organization_ref must be a valid objectID")
}
return orgRef, orgID, nil
}
func requireIdempotencyKey(k string) (string, error) {
key := strings.TrimSpace(k)
if key == "" {
return "", merrors.InvalidArgument("idempotency_key is required")
}
return key, nil
}
func requirePaymentRef(ref string) (string, error) {
val := strings.TrimSpace(ref)
if val == "" {
return "", merrors.InvalidArgument("payment_ref is required")
}
return val, nil
}
func requireNonNilIntent(intent *orchestratorv1.PaymentIntent) error {
if intent == nil {
return merrors.InvalidArgument("intent is required")
}
if intent.GetAmount() == nil {
return merrors.InvalidArgument("intent.amount is required")
}
return nil
}
func ensurePaymentsStore(repo storage.Repository) (storage.PaymentsStore, error) {
if repo == nil {
return nil, errStorageUnavailable
}
store := repo.Payments()
if store == nil {
return nil, errStorageUnavailable
}
return store, nil
}
func ensureQuotesStore(repo storage.Repository) (storage.QuotesStore, error) {
if repo == nil {
return nil, errStorageUnavailable
}
store := repo.Quotes()
if store == nil {
return nil, errStorageUnavailable
}
return store, nil
}
func getPaymentByIdempotencyKey(ctx context.Context, store storage.PaymentsStore, orgID primitive.ObjectID, key string) (*model.Payment, error) {
payment, err := store.GetByIdempotencyKey(ctx, orgID, key)
if err != nil {
return nil, err
}
return payment, nil
}
type quoteResolutionInput struct {
OrgRef string
OrgID primitive.ObjectID
Meta *orchestratorv1.RequestMeta
Intent *orchestratorv1.PaymentIntent
QuoteRef string
IdempotencyKey string
}
type quoteResolutionError struct {
code string
err error
}
func (e quoteResolutionError) Error() string { return e.err.Error() }
func (s *Service) resolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, error) {
if ref := strings.TrimSpace(in.QuoteRef); ref != "" {
quotesStore, err := ensureQuotesStore(s.storage)
if err != nil {
return nil, err
}
record, err := quotesStore.GetByRef(ctx, in.OrgID, ref)
if err != nil {
if errors.Is(err, storage.ErrQuoteNotFound) {
return nil, quoteResolutionError{code: "quote_not_found", err: merrors.InvalidArgument("quote_ref not found or expired")}
}
return nil, err
}
if !record.ExpiresAt.IsZero() && s.clock.Now().After(record.ExpiresAt) {
return nil, quoteResolutionError{code: "quote_expired", err: merrors.InvalidArgument("quote_ref expired")}
}
if !proto.Equal(protoIntentFromModel(record.Intent), in.Intent) {
return nil, quoteResolutionError{code: "quote_intent_mismatch", err: merrors.InvalidArgument("quote_ref does not match intent")}
}
quote := modelQuoteToProto(record.Quote)
if quote == nil {
return nil, merrors.InvalidArgument("stored quote is empty")
}
quote.QuoteRef = ref
return quote, nil
}
req := &orchestratorv1.QuotePaymentRequest{
Meta: in.Meta,
IdempotencyKey: in.IdempotencyKey,
Intent: in.Intent,
PreviewOnly: false,
}
quote, _, err := s.buildPaymentQuote(ctx, in.OrgRef, req)
if err != nil {
return nil, err
}
return quote, nil
}
func newPayment(orgID primitive.ObjectID, intent *orchestratorv1.PaymentIntent, idempotencyKey string, metadata map[string]string, quote *orchestratorv1.PaymentQuote) *model.Payment {
entity := &model.Payment{}
entity.SetID(primitive.NewObjectID())
entity.SetOrganizationRef(orgID)
entity.PaymentRef = entity.GetID().Hex()
entity.IdempotencyKey = idempotencyKey
entity.State = model.PaymentStateAccepted
entity.Intent = intentFromProto(intent)
entity.Metadata = cloneMetadata(metadata)
entity.LastQuote = quoteSnapshotToModel(quote)
entity.Normalize()
return entity
}
func paymentNotFoundResponder[T any](svc mservice.Type, logger mlogger.Logger, err error) gsresponse.Responder[T] {
if errors.Is(err, storage.ErrPaymentNotFound) {
return gsresponse.NotFound[T](logger, svc, err)
}
return gsresponse.Auto[T](logger, svc, err)
}

View File

@@ -0,0 +1,236 @@
package orchestrator
import (
"context"
"testing"
"time"
"github.com/tech/sendico/payments/orchestrator/storage"
"github.com/tech/sendico/payments/orchestrator/storage/model"
clockpkg "github.com/tech/sendico/pkg/clock"
mloggerfactory "github.com/tech/sendico/pkg/mlogger/factory"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
"go.mongodb.org/mongo-driver/bson/primitive"
)
func TestValidateMetaAndOrgRef(t *testing.T) {
org := primitive.NewObjectID()
meta := &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()}
ref, id, err := validateMetaAndOrgRef(meta)
if err != nil {
t.Fatalf("expected nil error: %v", err)
}
if ref != org.Hex() || id != org {
t.Fatalf("unexpected org parsing: %s %s", ref, id.Hex())
}
if _, _, err := validateMetaAndOrgRef(nil); err == nil {
t.Fatalf("expected error on nil meta")
}
if _, _, err := validateMetaAndOrgRef(&orchestratorv1.RequestMeta{OrganizationRef: ""}); err == nil {
t.Fatalf("expected error on empty orgRef")
}
if _, _, err := validateMetaAndOrgRef(&orchestratorv1.RequestMeta{OrganizationRef: "bad"}); err == nil {
t.Fatalf("expected error on invalid orgRef")
}
}
func TestRequireIdempotencyKey(t *testing.T) {
if _, err := requireIdempotencyKey(" "); err == nil {
t.Fatalf("expected error for empty key")
}
val, err := requireIdempotencyKey(" key ")
if err != nil || val != "key" {
t.Fatalf("unexpected result %s err %v", val, err)
}
}
func TestNewPayment(t *testing.T) {
org := primitive.NewObjectID()
intent := &orchestratorv1.PaymentIntent{
Amount: &moneyv1.Money{Currency: "USD", Amount: "10"},
SettlementMode: orchestratorv1.SettlementMode_SETTLEMENT_MODE_FIX_RECEIVED,
}
quote := &orchestratorv1.PaymentQuote{QuoteRef: "q1"}
p := newPayment(org, intent, "idem", map[string]string{"k": "v"}, quote)
if p.PaymentRef == "" || p.IdempotencyKey != "idem" || p.State != model.PaymentStateAccepted {
t.Fatalf("unexpected payment fields: %+v", p)
}
if p.Intent.Amount == nil || p.Intent.Amount.GetAmount() != "10" {
t.Fatalf("intent not copied")
}
if p.Intent.SettlementMode != orchestratorv1.SettlementMode_SETTLEMENT_MODE_FIX_RECEIVED {
t.Fatalf("settlement mode not preserved")
}
if p.LastQuote == nil || p.LastQuote.QuoteRef != "q1" {
t.Fatalf("quote not copied")
}
}
func TestResolvePaymentQuote_NotFound(t *testing.T) {
org := primitive.NewObjectID()
svc := &Service{
storage: stubRepo{quotes: &helperQuotesStore{}},
clock: clockpkg.NewSystem(),
}
_, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{
OrgRef: org.Hex(),
OrgID: org,
Meta: &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()},
Intent: &orchestratorv1.PaymentIntent{Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}},
QuoteRef: "missing",
})
if qerr, ok := err.(quoteResolutionError); !ok || qerr.code != "quote_not_found" {
t.Fatalf("expected quote_not_found, got %v", err)
}
}
func TestResolvePaymentQuote_Expired(t *testing.T) {
org := primitive.NewObjectID()
intent := &orchestratorv1.PaymentIntent{Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}}
record := &model.PaymentQuoteRecord{
QuoteRef: "q1",
Intent: intentFromProto(intent),
Quote: &model.PaymentQuoteSnapshot{},
ExpiresAt: time.Now().Add(-time.Minute),
}
svc := &Service{
storage: stubRepo{quotes: &helperQuotesStore{records: map[string]*model.PaymentQuoteRecord{"q1": record}}},
clock: clockpkg.NewSystem(),
}
_, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{
OrgRef: org.Hex(),
OrgID: org,
Meta: &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()},
Intent: intent,
QuoteRef: "q1",
})
if qerr, ok := err.(quoteResolutionError); !ok || qerr.code != "quote_expired" {
t.Fatalf("expected quote_expired, got %v", err)
}
}
func TestInitiatePaymentIdempotency(t *testing.T) {
logger := mloggerfactory.NewLogger(false)
org := primitive.NewObjectID()
store := newHelperPaymentStore()
svc := NewService(logger, stubRepo{
payments: store,
}, WithClock(clockpkg.NewSystem()))
svc.ensureHandlers()
intent := &orchestratorv1.PaymentIntent{
Amount: &moneyv1.Money{Currency: "USD", Amount: "1"},
}
req := &orchestratorv1.InitiatePaymentRequest{
Meta: &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()},
Intent: intent,
IdempotencyKey: "k1",
}
resp, err := svc.h.commands.InitiatePayment().Execute(context.Background(), req)(context.Background())
if err != nil {
t.Fatalf("first call failed: %v", err)
}
resp2, err := svc.h.commands.InitiatePayment().Execute(context.Background(), req)(context.Background())
if err != nil {
t.Fatalf("second call failed: %v", err)
}
if resp == nil || resp2 == nil || resp.Payment.GetPaymentRef() != resp2.Payment.GetPaymentRef() {
t.Fatalf("idempotent call returned different payments")
}
}
// --- test doubles ---
type stubRepo struct {
payments storage.PaymentsStore
quotes storage.QuotesStore
pingErr error
}
func (s stubRepo) Ping(context.Context) error { return s.pingErr }
func (s stubRepo) Payments() storage.PaymentsStore { return s.payments }
func (s stubRepo) Quotes() storage.QuotesStore { return s.quotes }
type helperPaymentStore struct {
byRef map[string]*model.Payment
byIdem map[string]*model.Payment
byChain map[string]*model.Payment
}
func newHelperPaymentStore() *helperPaymentStore {
return &helperPaymentStore{
byRef: make(map[string]*model.Payment),
byIdem: make(map[string]*model.Payment),
byChain: make(map[string]*model.Payment),
}
}
func (s *helperPaymentStore) Create(_ context.Context, p *model.Payment) error {
if _, ok := s.byRef[p.PaymentRef]; ok {
return storage.ErrDuplicatePayment
}
s.byRef[p.PaymentRef] = p
if p.IdempotencyKey != "" {
s.byIdem[p.IdempotencyKey] = p
}
if p.Execution != nil && p.Execution.ChainTransferRef != "" {
s.byChain[p.Execution.ChainTransferRef] = p
}
return nil
}
func (s *helperPaymentStore) Update(_ context.Context, p *model.Payment) error {
if p == nil {
return storage.ErrPaymentNotFound
}
if _, ok := s.byRef[p.PaymentRef]; !ok {
return storage.ErrPaymentNotFound
}
s.byRef[p.PaymentRef] = p
if p.IdempotencyKey != "" {
s.byIdem[p.IdempotencyKey] = p
}
return nil
}
func (s *helperPaymentStore) GetByPaymentRef(_ context.Context, ref string) (*model.Payment, error) {
if p, ok := s.byRef[ref]; ok {
return p, nil
}
return nil, storage.ErrPaymentNotFound
}
func (s *helperPaymentStore) GetByIdempotencyKey(_ context.Context, _ primitive.ObjectID, key string) (*model.Payment, error) {
if p, ok := s.byIdem[key]; ok {
return p, nil
}
return nil, storage.ErrPaymentNotFound
}
func (s *helperPaymentStore) GetByChainTransferRef(_ context.Context, ref string) (*model.Payment, error) {
if p, ok := s.byChain[ref]; ok {
return p, nil
}
return nil, storage.ErrPaymentNotFound
}
func (s *helperPaymentStore) List(_ context.Context, _ *model.PaymentFilter) (*model.PaymentList, error) {
return &model.PaymentList{}, nil
}
type helperQuotesStore struct {
records map[string]*model.PaymentQuoteRecord
}
func (s *helperQuotesStore) Create(_ context.Context, _ *model.PaymentQuoteRecord) error { return nil }
func (s *helperQuotesStore) GetByRef(_ context.Context, _ primitive.ObjectID, ref string) (*model.PaymentQuoteRecord, error) {
if s.records == nil {
return nil, storage.ErrQuoteNotFound
}
if rec, ok := s.records[ref]; ok {
return rec, nil
}
return nil, storage.ErrQuoteNotFound
}

View File

@@ -32,11 +32,13 @@ func TestExecutePayment_FXConversionSettled(t *testing.T) {
logger: zap.NewNop(), logger: zap.NewNop(),
clock: testClock{now: time.Now()}, clock: testClock{now: time.Now()},
storage: repo, storage: repo,
ledger: ledgerDependency{client: &ledgerclient.Fake{ deps: serviceDependencies{
ApplyFXWithChargesFn: func(ctx context.Context, req *ledgerv1.FXRequest) (*ledgerv1.PostResponse, error) { ledger: ledgerDependency{client: &ledgerclient.Fake{
return &ledgerv1.PostResponse{JournalEntryRef: "fx-entry"}, nil ApplyFXWithChargesFn: func(ctx context.Context, req *ledgerv1.FXRequest) (*ledgerv1.PostResponse, error) {
}, return &ledgerv1.PostResponse{JournalEntryRef: "fx-entry"}, nil
}}, },
}},
},
} }
payment := &model.Payment{ payment := &model.Payment{
@@ -88,11 +90,13 @@ func TestExecutePayment_ChainFailure(t *testing.T) {
logger: zap.NewNop(), logger: zap.NewNop(),
clock: testClock{now: time.Now()}, clock: testClock{now: time.Now()},
storage: repo, storage: repo,
gateway: gatewayDependency{client: &chainclient.Fake{ deps: serviceDependencies{
SubmitTransferFn: func(ctx context.Context, req *chainv1.SubmitTransferRequest) (*chainv1.SubmitTransferResponse, error) { gateway: gatewayDependency{client: &chainclient.Fake{
return nil, errors.New("chain failure") SubmitTransferFn: func(ctx context.Context, req *chainv1.SubmitTransferRequest) (*chainv1.SubmitTransferResponse, error) {
}, return nil, errors.New("chain failure")
}}, },
}},
},
} }
payment := &model.Payment{ payment := &model.Payment{
@@ -146,6 +150,7 @@ func TestProcessTransferUpdateHandler_Settled(t *testing.T) {
clock: testClock{now: time.Now()}, clock: testClock{now: time.Now()},
storage: &stubRepository{store: store}, storage: &stubRepository{store: store},
} }
svc.h.events = newPaymentEventHandler(svc.storage, svc.ensureRepository, svc.logger)
req := &orchestratorv1.ProcessTransferUpdateRequest{ req := &orchestratorv1.ProcessTransferUpdateRequest{
Event: &chainv1.TransferStatusChangedEvent{ Event: &chainv1.TransferStatusChangedEvent{
@@ -156,7 +161,7 @@ func TestProcessTransferUpdateHandler_Settled(t *testing.T) {
}, },
} }
reSP, err := gsresponse.Execute(ctx, svc.processTransferUpdateHandler(ctx, req)) reSP, err := gsresponse.Execute(ctx, svc.h.events.processTransferUpdate(ctx, req))
if err != nil { if err != nil {
t.Fatalf("handler returned error: %v", err) t.Fatalf("handler returned error: %v", err)
} }
@@ -189,6 +194,7 @@ func TestProcessDepositObservedHandler_MatchesPayment(t *testing.T) {
clock: testClock{now: time.Now()}, clock: testClock{now: time.Now()},
storage: &stubRepository{store: store}, storage: &stubRepository{store: store},
} }
svc.h.events = newPaymentEventHandler(svc.storage, svc.ensureRepository, svc.logger)
req := &orchestratorv1.ProcessDepositObservedRequest{ req := &orchestratorv1.ProcessDepositObservedRequest{
Event: &chainv1.WalletDepositObservedEvent{ Event: &chainv1.WalletDepositObservedEvent{
@@ -197,7 +203,7 @@ func TestProcessDepositObservedHandler_MatchesPayment(t *testing.T) {
}, },
} }
reSP, err := gsresponse.Execute(ctx, svc.processDepositObservedHandler(ctx, req)) reSP, err := gsresponse.Execute(ctx, svc.h.events.processDepositObserved(ctx, req))
if err != nil { if err != nil {
t.Fatalf("handler returned error: %v", err) t.Fatalf("handler returned error: %v", err)
} }

View File

@@ -11,6 +11,7 @@ import (
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
) )
// PaymentKind captures the orchestrator intent type. // PaymentKind captures the orchestrator intent type.
@@ -57,6 +58,7 @@ const (
EndpointTypeLedger PaymentEndpointType = "ledger" EndpointTypeLedger PaymentEndpointType = "ledger"
EndpointTypeManagedWallet PaymentEndpointType = "managed_wallet" EndpointTypeManagedWallet PaymentEndpointType = "managed_wallet"
EndpointTypeExternalChain PaymentEndpointType = "external_chain" EndpointTypeExternalChain PaymentEndpointType = "external_chain"
EndpointTypeCard PaymentEndpointType = "card"
) )
// LedgerEndpoint describes ledger routing. // LedgerEndpoint describes ledger routing.
@@ -78,12 +80,36 @@ type ExternalChainEndpoint struct {
Memo string `bson:"memo,omitempty" json:"memo,omitempty"` Memo string `bson:"memo,omitempty" json:"memo,omitempty"`
} }
// CardEndpoint describes a card payout destination.
type CardEndpoint struct {
Pan string `bson:"pan,omitempty" json:"pan,omitempty"`
Token string `bson:"token,omitempty" json:"token,omitempty"`
Cardholder string `bson:"cardholder,omitempty" json:"cardholder,omitempty"`
ExpMonth uint32 `bson:"expMonth,omitempty" json:"expMonth,omitempty"`
ExpYear uint32 `bson:"expYear,omitempty" json:"expYear,omitempty"`
Country string `bson:"country,omitempty" json:"country,omitempty"`
MaskedPan string `bson:"maskedPan,omitempty" json:"maskedPan,omitempty"`
}
// CardPayout stores gateway payout tracking info.
type CardPayout struct {
PayoutRef string `bson:"payoutRef,omitempty" json:"payoutRef,omitempty"`
ProviderPaymentID string `bson:"providerPaymentId,omitempty" json:"providerPaymentId,omitempty"`
Status string `bson:"status,omitempty" json:"status,omitempty"`
FailureReason string `bson:"failureReason,omitempty" json:"failureReason,omitempty"`
CardCountry string `bson:"cardCountry,omitempty" json:"cardCountry,omitempty"`
MaskedPan string `bson:"maskedPan,omitempty" json:"maskedPan,omitempty"`
ProviderCode string `bson:"providerCode,omitempty" json:"providerCode,omitempty"`
GatewayReference string `bson:"gatewayReference,omitempty" json:"gatewayReference,omitempty"`
}
// PaymentEndpoint is a polymorphic payment destination/source. // PaymentEndpoint is a polymorphic payment destination/source.
type PaymentEndpoint struct { type PaymentEndpoint struct {
Type PaymentEndpointType `bson:"type" json:"type"` Type PaymentEndpointType `bson:"type" json:"type"`
Ledger *LedgerEndpoint `bson:"ledger,omitempty" json:"ledger,omitempty"` Ledger *LedgerEndpoint `bson:"ledger,omitempty" json:"ledger,omitempty"`
ManagedWallet *ManagedWalletEndpoint `bson:"managedWallet,omitempty" json:"managedWallet,omitempty"` ManagedWallet *ManagedWalletEndpoint `bson:"managedWallet,omitempty" json:"managedWallet,omitempty"`
ExternalChain *ExternalChainEndpoint `bson:"externalChain,omitempty" json:"externalChain,omitempty"` ExternalChain *ExternalChainEndpoint `bson:"externalChain,omitempty" json:"externalChain,omitempty"`
Card *CardEndpoint `bson:"card,omitempty" json:"card,omitempty"`
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"` Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
} }
@@ -99,14 +125,15 @@ type FXIntent struct {
// PaymentIntent models the requested payment operation. // PaymentIntent models the requested payment operation.
type PaymentIntent struct { type PaymentIntent struct {
Kind PaymentKind `bson:"kind" json:"kind"` Kind PaymentKind `bson:"kind" json:"kind"`
Source PaymentEndpoint `bson:"source" json:"source"` Source PaymentEndpoint `bson:"source" json:"source"`
Destination PaymentEndpoint `bson:"destination" json:"destination"` Destination PaymentEndpoint `bson:"destination" json:"destination"`
Amount *moneyv1.Money `bson:"amount" json:"amount"` Amount *moneyv1.Money `bson:"amount" json:"amount"`
RequiresFX bool `bson:"requiresFx,omitempty" json:"requiresFx,omitempty"` RequiresFX bool `bson:"requiresFx,omitempty" json:"requiresFx,omitempty"`
FX *FXIntent `bson:"fx,omitempty" json:"fx,omitempty"` FX *FXIntent `bson:"fx,omitempty" json:"fx,omitempty"`
FeePolicy *feesv1.PolicyOverrides `bson:"feePolicy,omitempty" json:"feePolicy,omitempty"` FeePolicy *feesv1.PolicyOverrides `bson:"feePolicy,omitempty" json:"feePolicy,omitempty"`
Attributes map[string]string `bson:"attributes,omitempty" json:"attributes,omitempty"` SettlementMode orchestratorv1.SettlementMode `bson:"settlementMode,omitempty" json:"settlementMode,omitempty"`
Attributes map[string]string `bson:"attributes,omitempty" json:"attributes,omitempty"`
} }
// PaymentQuoteSnapshot stores the latest quote info. // PaymentQuoteSnapshot stores the latest quote info.
@@ -118,7 +145,6 @@ type PaymentQuoteSnapshot struct {
FeeRules []*feesv1.AppliedRule `bson:"feeRules,omitempty" json:"feeRules,omitempty"` FeeRules []*feesv1.AppliedRule `bson:"feeRules,omitempty" json:"feeRules,omitempty"`
FXQuote *oraclev1.Quote `bson:"fxQuote,omitempty" json:"fxQuote,omitempty"` FXQuote *oraclev1.Quote `bson:"fxQuote,omitempty" json:"fxQuote,omitempty"`
NetworkFee *chainv1.EstimateTransferFeeResponse `bson:"networkFee,omitempty" json:"networkFee,omitempty"` NetworkFee *chainv1.EstimateTransferFeeResponse `bson:"networkFee,omitempty" json:"networkFee,omitempty"`
FeeQuoteToken string `bson:"feeQuoteToken,omitempty" json:"feeQuoteToken,omitempty"`
QuoteRef string `bson:"quoteRef,omitempty" json:"quoteRef,omitempty"` QuoteRef string `bson:"quoteRef,omitempty" json:"quoteRef,omitempty"`
} }
@@ -128,6 +154,8 @@ type ExecutionRefs struct {
CreditEntryRef string `bson:"creditEntryRef,omitempty" json:"creditEntryRef,omitempty"` CreditEntryRef string `bson:"creditEntryRef,omitempty" json:"creditEntryRef,omitempty"`
FXEntryRef string `bson:"fxEntryRef,omitempty" json:"fxEntryRef,omitempty"` FXEntryRef string `bson:"fxEntryRef,omitempty" json:"fxEntryRef,omitempty"`
ChainTransferRef string `bson:"chainTransferRef,omitempty" json:"chainTransferRef,omitempty"` ChainTransferRef string `bson:"chainTransferRef,omitempty" json:"chainTransferRef,omitempty"`
CardPayoutRef string `bson:"cardPayoutRef,omitempty" json:"cardPayoutRef,omitempty"`
FeeTransferRef string `bson:"feeTransferRef,omitempty" json:"feeTransferRef,omitempty"`
} }
// Payment persists orchestrated payment lifecycle. // Payment persists orchestrated payment lifecycle.
@@ -144,6 +172,7 @@ type Payment struct {
LastQuote *PaymentQuoteSnapshot `bson:"lastQuote,omitempty" json:"lastQuote,omitempty"` LastQuote *PaymentQuoteSnapshot `bson:"lastQuote,omitempty" json:"lastQuote,omitempty"`
Execution *ExecutionRefs `bson:"execution,omitempty" json:"execution,omitempty"` Execution *ExecutionRefs `bson:"execution,omitempty" json:"execution,omitempty"`
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"` Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
CardPayout *CardPayout `bson:"cardPayout,omitempty" json:"cardPayout,omitempty"`
} }
// Collection implements storable.Storable. // Collection implements storable.Storable.
@@ -223,5 +252,13 @@ func normalizeEndpoint(ep *PaymentEndpoint) {
ep.ExternalChain.Asset.ContractAddress = strings.TrimSpace(strings.ToLower(ep.ExternalChain.Asset.ContractAddress)) ep.ExternalChain.Asset.ContractAddress = strings.TrimSpace(strings.ToLower(ep.ExternalChain.Asset.ContractAddress))
} }
} }
case EndpointTypeCard:
if ep.Card != nil {
ep.Card.Pan = strings.TrimSpace(ep.Card.Pan)
ep.Card.Token = strings.TrimSpace(ep.Card.Token)
ep.Card.Cardholder = strings.TrimSpace(ep.Card.Cardholder)
ep.Card.Country = strings.TrimSpace(ep.Card.Country)
ep.Card.MaskedPan = strings.TrimSpace(ep.Card.MaskedPan)
}
} }
} }

View File

@@ -12,10 +12,12 @@ type PaymentQuoteRecord struct {
storable.Base `bson:",inline" json:",inline"` storable.Base `bson:",inline" json:",inline"`
model.OrganizationBoundBase `bson:",inline" json:",inline"` model.OrganizationBoundBase `bson:",inline" json:",inline"`
QuoteRef string `bson:"quoteRef" json:"quoteRef"` QuoteRef string `bson:"quoteRef" json:"quoteRef"`
Intent PaymentIntent `bson:"intent" json:"intent"` Intent PaymentIntent `bson:"intent,omitempty" json:"intent,omitempty"`
Quote *PaymentQuoteSnapshot `bson:"quote" json:"quote"` Intents []PaymentIntent `bson:"intents,omitempty" json:"intents,omitempty"`
ExpiresAt time.Time `bson:"expiresAt" json:"expiresAt"` Quote *PaymentQuoteSnapshot `bson:"quote,omitempty" json:"quote,omitempty"`
Quotes []*PaymentQuoteSnapshot `bson:"quotes,omitempty" json:"quotes,omitempty"`
ExpiresAt time.Time `bson:"expiresAt" json:"expiresAt"`
} }
// Collection implements storable.Storable. // Collection implements storable.Storable.

View File

@@ -73,6 +73,16 @@ func (q *Quotes) Create(ctx context.Context, quote *model.PaymentQuoteRecord) er
quote.Intent.Attributes[k] = strings.TrimSpace(v) quote.Intent.Attributes[k] = strings.TrimSpace(v)
} }
} }
if len(quote.Intents) > 0 {
for i := range quote.Intents {
if quote.Intents[i].Attributes == nil {
continue
}
for k, v := range quote.Intents[i].Attributes {
quote.Intents[i].Attributes[k] = strings.TrimSpace(v)
}
}
}
quote.Update() quote.Update()
filter := repository.OrgFilter(quote.OrganizationRef).And( filter := repository.OrgFilter(quote.OrganizationRef).And(

View File

@@ -37,7 +37,9 @@ func Create(logger mlogger.Logger, db *mongo.Database) (*RefreshTokenDB, error)
{Field: "clientId", Sort: ri.Asc}, {Field: "clientId", Sort: ri.Asc},
{Field: "deviceId", Sort: ri.Asc}, {Field: "deviceId", Sort: ri.Asc},
}, },
Unique: true, Unique: true,
Name: "unique_active_session",
PartialFilter: repository.Filter(IsRevokedField, false),
}); err != nil { }); err != nil {
p.Logger.Error("Failed to create unique account/client/device index", zap.Error(err)) p.Logger.Error("Failed to create unique account/client/device index", zap.Error(err))
return nil, err return nil, err

View File

@@ -10,23 +10,29 @@ import (
"testing" "testing"
"time" "time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tech/sendico/pkg/db/internal/mongo/refreshtokensdb" "github.com/tech/sendico/pkg/db/internal/mongo/refreshtokensdb"
"github.com/tech/sendico/pkg/db/repository" "github.com/tech/sendico/pkg/db/repository"
"github.com/tech/sendico/pkg/db/repository/builder" "github.com/tech/sendico/pkg/db/repository/builder"
"github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/merrors"
factory "github.com/tech/sendico/pkg/mlogger/factory" factory "github.com/tech/sendico/pkg/mlogger/factory"
"github.com/tech/sendico/pkg/model" "github.com/tech/sendico/pkg/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/mongodb" "github.com/testcontainers/testcontainers-go/modules/mongodb"
"github.com/testcontainers/testcontainers-go/wait" "github.com/testcontainers/testcontainers-go/wait"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive" "go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo" "go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options" "go.mongodb.org/mongo-driver/mongo/options"
) )
func setupTestDB(t *testing.T) (*refreshtokensdb.RefreshTokenDB, func()) { func setupTestDB(t *testing.T) (*refreshtokensdb.RefreshTokenDB, func()) {
db, _, cleanup := setupTestDBWithMongo(t)
return db, cleanup
}
func setupTestDBWithMongo(t *testing.T) (*refreshtokensdb.RefreshTokenDB, *mongo.Database, func()) {
// mark as helper for better test failure reporting // mark as helper for better test failure reporting
t.Helper() t.Helper()
@@ -62,7 +68,7 @@ func setupTestDB(t *testing.T) (*refreshtokensdb.RefreshTokenDB, func()) {
_ = mongoContainer.Terminate(termCtx) _ = mongoContainer.Terminate(termCtx)
} }
return db, cleanup return db, database, cleanup
} }
func createTestRefreshToken(accountRef primitive.ObjectID, clientID, deviceID, token string) *model.RefreshToken { func createTestRefreshToken(accountRef primitive.ObjectID, clientID, deviceID, token string) *model.RefreshToken {
@@ -332,6 +338,63 @@ func TestRefreshTokenDB_SessionReplacement(t *testing.T) {
_, err = db.GetByCRT(ctx, secondCRT) _, err = db.GetByCRT(ctx, secondCRT)
require.NoError(t, err) require.NoError(t, err)
}) })
t.Run("Create_After_GlobalRevocation_AllowsNewActive", func(t *testing.T) {
userID := primitive.NewObjectID()
clientID := "web-app"
deviceID := "user-laptop"
firstToken := createTestRefreshToken(userID, clientID, deviceID, "revoked_token_123")
err := db.Create(ctx, firstToken)
require.NoError(t, err)
require.NotNil(t, firstToken.GetID())
// Global revoke (deviceID empty) — all tokens should be revoked
err = db.RevokeAll(ctx, userID, "")
require.NoError(t, err)
var revoked model.RefreshToken
err = db.Get(ctx, *firstToken.GetID(), &revoked)
require.NoError(t, err)
assert.True(t, revoked.IsRevoked)
// Creating a new token for the same account/client/device must succeed and produce an active token
reissueToken := createTestRefreshToken(userID, clientID, deviceID, "new_token_after_revocation")
err = db.Create(ctx, reissueToken)
require.NoError(t, err)
newCRT := &model.ClientRefreshToken{
SessionIdentifier: model.SessionIdentifier{
ClientID: clientID,
DeviceID: deviceID,
},
RefreshToken: "new_token_after_revocation",
}
_, err = db.GetByCRT(ctx, newCRT)
require.NoError(t, err)
// Old token must remain unusable
oldCRT := &model.ClientRefreshToken{
SessionIdentifier: model.SessionIdentifier{
ClientID: clientID,
DeviceID: deviceID,
},
RefreshToken: "revoked_token_123",
}
_, err = db.GetByCRT(ctx, oldCRT)
assert.Error(t, err)
// Both records exist: revoked + new active
query := repository.Query().
Filter(repository.AccountField(), userID).
And(
repository.Query().Comparison(repository.Field("clientId"), builder.Eq, clientID),
repository.Query().Comparison(repository.Field("deviceId"), builder.Eq, deviceID),
)
ids, err := db.Repository.ListIDs(ctx, query)
require.NoError(t, err)
assert.Len(t, ids, 2)
})
} }
func TestRefreshTokenDB_ClientManagement(t *testing.T) { func TestRefreshTokenDB_ClientManagement(t *testing.T) {
@@ -637,3 +700,29 @@ func TestRefreshTokenDB_DatabaseIndexes(t *testing.T) {
assert.Len(t, ids, 5) // Should find 5 non-revoked tokens assert.Len(t, ids, 5) // Should find 5 non-revoked tokens
}) })
} }
func TestRefreshTokenDB_IndexPartialUniqueActiveSession(t *testing.T) {
db, database, cleanup := setupTestDBWithMongo(t)
defer cleanup()
ctx := context.Background()
cursor, err := database.Collection(db.Repository.Collection()).Indexes().List(ctx)
require.NoError(t, err)
defer cursor.Close(ctx)
found := false
for cursor.Next(ctx) {
var idx bson.M
require.NoError(t, cursor.Decode(&idx))
if idx["name"] == "unique_active_session" {
found = true
assert.Equal(t, true, idx["unique"])
partial, ok := idx["partialFilterExpression"].(bson.M)
require.True(t, ok)
assert.Equal(t, bson.M{"isRevoked": false}, partial)
}
}
assert.True(t, found, "unique_active_session index not found")
}

View File

@@ -41,6 +41,9 @@ func (r *MongoRepository) CreateIndex(def *ri.Definition) error {
if def.Name != "" { if def.Name != "" {
opts.SetName(def.Name) opts.SetName(def.Name)
} }
if def.PartialFilter != nil {
opts.SetPartialFilterExpression(def.PartialFilter.BuildQuery())
}
_, err := r.collection.Indexes().CreateOne( _, err := r.collection.Indexes().CreateOne(
context.Background(), context.Background(),

View File

@@ -0,0 +1,83 @@
//go:build integration
// +build integration
package repositoryimp_test
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tech/sendico/pkg/db/repository"
ri "github.com/tech/sendico/pkg/db/repository/index"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/mongodb"
"github.com/testcontainers/testcontainers-go/wait"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)
func TestCreateIndex_WithPartialFilter(t *testing.T) {
startCtx, startCancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer startCancel()
mongoContainer, err := mongodb.Run(startCtx,
"mongo:latest",
mongodb.WithUsername("root"),
mongodb.WithPassword("password"),
testcontainers.WithWaitStrategy(wait.ForListeningPort("27017/tcp").WithStartupTimeout(2*time.Minute)),
)
require.NoError(t, err)
mongoURI, err := mongoContainer.ConnectionString(startCtx)
require.NoError(t, err)
client, err := mongo.Connect(startCtx, options.Client().ApplyURI(mongoURI))
require.NoError(t, err)
defer client.Disconnect(context.Background())
database := client.Database("test_partial_index_" + t.Name())
defer database.Drop(context.Background())
repo := repository.CreateMongoRepository(database, "partial_index_items")
def := &ri.Definition{
Keys: []ri.Key{
{Field: "field", Sort: ri.Asc},
},
Unique: true,
Name: "partial_unique_field_true",
PartialFilter: repository.Filter("flag", treu),
}
require.NoError(t, repo.CreateIndex(def))
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cursor, err := database.Collection(repo.Collection()).Indexes().List(ctx)
require.NoError(t, err)
defer cursor.Close(ctx)
found := false
for cursor.Next(ctx) {
var idx bson.M
require.NoError(t, cursor.Decode(&idx))
if idx["name"] == def.Name {
found = true
assert.Equal(t, true, idx["unique"])
assert.Equal(t, bson.M{"field": int32(1)}, idx["key"])
partial, ok := idx["partialFilterExpression"].(bson.M)
require.True(t, ok)
assert.Equal(t, bson.M{"flag": true}, partial)
}
}
assert.True(t, found, "partial unique index was not created")
termCtx, termCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer termCancel()
_ = mongoContainer.Terminate(termCtx)
}

View File

@@ -1,5 +1,7 @@
package repository package repository
import "github.com/tech/sendico/pkg/db/repository/builder"
type Sort int8 type Sort int8
const ( const (
@@ -14,8 +16,9 @@ type Key struct {
} }
type Definition struct { type Definition struct {
Keys []Key // mandatory, at least one element Keys []Key // mandatory, at least one element
Unique bool // unique constraint? Unique bool // unique constraint?
TTL *int32 // seconds; nil means “no TTL” TTL *int32 // seconds; nil means “no TTL”
Name string // optional explicit name Name string // optional explicit name
PartialFilter builder.Query // optional: partialFilterExpression for conditional indexes
} }

View File

@@ -9,7 +9,7 @@ require (
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/mattn/go-colorable v0.1.14 github.com/mattn/go-colorable v0.1.14
github.com/mitchellh/mapstructure v1.5.0 github.com/mitchellh/mapstructure v1.5.0
github.com/nats-io/nats.go v1.47.0 github.com/nats-io/nats.go v1.48.0
github.com/prometheus/client_golang v1.23.2 github.com/prometheus/client_golang v1.23.2
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
github.com/testcontainers/testcontainers-go v0.33.0 github.com/testcontainers/testcontainers-go v0.33.0
@@ -17,8 +17,8 @@ require (
go.mongodb.org/mongo-driver v1.17.6 go.mongodb.org/mongo-driver v1.17.6
go.uber.org/zap v1.27.1 go.uber.org/zap v1.27.1
golang.org/x/crypto v0.46.0 golang.org/x/crypto v0.46.0
google.golang.org/grpc v1.77.0 google.golang.org/grpc v1.78.0
google.golang.org/protobuf v1.36.10 google.golang.org/protobuf v1.36.11
) )
require ( require (
@@ -93,6 +93,6 @@ require (
golang.org/x/sys v0.39.0 // indirect golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect golang.org/x/text v0.32.0 // indirect
golang.org/x/time v0.5.0 // indirect golang.org/x/time v0.5.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )

View File

@@ -106,8 +106,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nats-io/nats.go v1.47.0 h1:YQdADw6J/UfGUd2Oy6tn4Hq6YHxCaJrVKayxxFqYrgM= github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
github.com/nats-io/nats.go v1.47.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc= github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg= github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
@@ -267,14 +267,14 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 h1:mepRgnBZa07I4TRuomDE4sTIYieg/osKmzIf4USdWS4= google.golang.org/genproto/googleapis/api v0.0.0-20251029180050-ab9386a59fda h1:+2XxjfsAu6vqFxwGBRcHiMaDCuZiqXGDUDVWVtrFAnE=
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo= google.golang.org/genproto/googleapis/api v0.0.0-20251029180050-ab9386a59fda/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

View File

@@ -8,9 +8,10 @@ import (
) )
type CryptoAddressPaymentData struct { type CryptoAddressPaymentData struct {
Address string `bson:"address" json:"address"` Currency Currency `bson:"currency" json:"currency"`
Network string `bson:"network" json:"network"` Address string `bson:"address" json:"address"`
DestinationTag *string `bson:"destinationTag,omitempty" json:"destinationTag,omitempty"` Network string `bson:"network" json:"network"`
DestinationTag *string `bson:"destinationTag,omitempty" json:"destinationTag,omitempty"`
} }
func (m *PaymentMethod) AsCryptoAddress() (*CryptoAddressPaymentData, error) { func (m *PaymentMethod) AsCryptoAddress() (*CryptoAddressPaymentData, error) {

View File

@@ -3,12 +3,13 @@ package model
type Currency string type Currency string
const ( const (
CurrencyEUR Currency = "EUR" // Euro CurrencyEUR Currency = "EUR" // Euro
CurrencyUSD Currency = "USD" // US Dollar CurrencyUSD Currency = "USD" // US Dollar
CurrencyRUB Currency = "RUB" // Russian Ruble CurrencyRUB Currency = "RUB" // Russian Ruble
CurrencyUAH Currency = "UAH" // Ukrainian Hryvnia CurrencyUAH Currency = "UAH" // Ukrainian Hryvnia
CurrencyPLN Currency = "PLN" // Polish Złoty CurrencyPLN Currency = "PLN" // Polish Złoty
CurrencyCZK Currency = "CZK" // Czech Koruna CurrencyCZK Currency = "CZK" // Czech Koruna
CurrencyUSDT Currency = "USDT" // Czech Koruna
) )
// All supported currencies // All supported currencies
@@ -19,6 +20,7 @@ var SupportedCurrencies = []Currency{
CurrencyUAH, CurrencyUAH,
CurrencyPLN, CurrencyPLN,
CurrencyCZK, CurrencyCZK,
CurrencyUSDT,
} }
type Amount struct { type Amount struct {

6
api/pkg/model/money.go Normal file
View File

@@ -0,0 +1,6 @@
package model
type Money struct {
Currency string `bson:"currency" json:"currency"`
Amount string `bson:"amount" json:"amount"`
}

View File

@@ -19,6 +19,7 @@ const (
PaymentTypeBankAccount PaymentTypeBankAccount
PaymentTypeWallet PaymentTypeWallet
PaymentTypeCryptoAddress PaymentTypeCryptoAddress
PaymentTypeLedger
) )
var paymentTypeToString = map[PaymentType]string{ var paymentTypeToString = map[PaymentType]string{
@@ -28,6 +29,7 @@ var paymentTypeToString = map[PaymentType]string{
PaymentTypeBankAccount: "bankAccount", PaymentTypeBankAccount: "bankAccount",
PaymentTypeWallet: "wallet", PaymentTypeWallet: "wallet",
PaymentTypeCryptoAddress: "cryptoAddress", PaymentTypeCryptoAddress: "cryptoAddress",
PaymentTypeLedger: "ledger",
} }
var paymentTypeFromString = map[string]PaymentType{ var paymentTypeFromString = map[string]PaymentType{
@@ -37,6 +39,7 @@ var paymentTypeFromString = map[string]PaymentType{
"bankAccount": PaymentTypeBankAccount, "bankAccount": PaymentTypeBankAccount,
"wallet": PaymentTypeWallet, "wallet": PaymentTypeWallet,
"cryptoAddress": PaymentTypeCryptoAddress, "cryptoAddress": PaymentTypeCryptoAddress,
"ledger": PaymentTypeLedger,
} }
func (t PaymentType) String() string { func (t PaymentType) String() string {

View File

@@ -0,0 +1,11 @@
syntax = "proto3";
package common.describable.v1;
option go_package = "github.com/tech/sendico/pkg/proto/common/describable/v1;describablev1";
// Describable captures a name/description pair reusable across resources.
message Describable {
string name = 1;
optional string description = 2;
}

View File

@@ -7,6 +7,7 @@ option go_package = "github.com/tech/sendico/pkg/proto/gateway/chain/v1;chainv1"
import "google/protobuf/timestamp.proto"; import "google/protobuf/timestamp.proto";
import "common/money/v1/money.proto"; import "common/money/v1/money.proto";
import "common/pagination/v1/cursor.proto"; import "common/pagination/v1/cursor.proto";
import "common/describable/v1/describable.proto";
// Supported blockchain networks for the managed wallets. // Supported blockchain networks for the managed wallets.
enum ChainNetwork { enum ChainNetwork {
@@ -57,6 +58,7 @@ message ManagedWallet {
map<string, string> metadata = 7; map<string, string> metadata = 7;
google.protobuf.Timestamp created_at = 8; google.protobuf.Timestamp created_at = 8;
google.protobuf.Timestamp updated_at = 9; google.protobuf.Timestamp updated_at = 9;
common.describable.v1.Describable describable = 10;
} }
message CreateManagedWalletRequest { message CreateManagedWalletRequest {
@@ -65,6 +67,7 @@ message CreateManagedWalletRequest {
string owner_ref = 3; string owner_ref = 3;
Asset asset = 4; Asset asset = 4;
map<string, string> metadata = 5; map<string, string> metadata = 5;
common.describable.v1.Describable describable = 6;
} }
message CreateManagedWalletResponse { message CreateManagedWalletResponse {

View File

@@ -20,17 +20,18 @@ message RateSnapshot {
} }
message RequestMeta { message RequestMeta {
string request_ref = 1 [deprecated = true]; reserved 1, 4, 5;
reserved "request_ref", "idempotency_key", "trace_ref";
string tenant_ref = 2; string tenant_ref = 2;
string organization_ref = 3; string organization_ref = 3;
string idempotency_key = 4 [deprecated = true];
string trace_ref = 5 [deprecated = true];
common.trace.v1.TraceContext trace = 6; common.trace.v1.TraceContext trace = 6;
} }
message ResponseMeta { message ResponseMeta {
string request_ref = 1 [deprecated = true]; reserved 1, 2;
string trace_ref = 2 [deprecated = true]; reserved "request_ref", "trace_ref";
common.trace.v1.TraceContext trace = 3; common.trace.v1.TraceContext trace = 3;
} }

View File

@@ -11,6 +11,7 @@ import "common/trace/v1/trace.proto";
import "common/pagination/v1/cursor.proto"; import "common/pagination/v1/cursor.proto";
import "billing/fees/v1/fees.proto"; import "billing/fees/v1/fees.proto";
import "gateway/chain/v1/chain.proto"; import "gateway/chain/v1/chain.proto";
import "gateway/mntx/v1/mntx.proto";
import "oracle/v1/oracle.proto"; import "oracle/v1/oracle.proto";
enum PaymentKind { enum PaymentKind {
@@ -20,6 +21,13 @@ enum PaymentKind {
PAYMENT_KIND_FX_CONVERSION = 3; PAYMENT_KIND_FX_CONVERSION = 3;
} }
// SettlementMode defines how to treat fees/FX variance for payouts.
enum SettlementMode {
SETTLEMENT_MODE_UNSPECIFIED = 0;
SETTLEMENT_MODE_FIX_SOURCE = 1; // customer pays fees; sent amount fixed
SETTLEMENT_MODE_FIX_RECEIVED = 2; // receiver gets fixed amount; source flexes
}
enum PaymentState { enum PaymentState {
PAYMENT_STATE_UNSPECIFIED = 0; PAYMENT_STATE_UNSPECIFIED = 0;
PAYMENT_STATE_ACCEPTED = 1; PAYMENT_STATE_ACCEPTED = 1;
@@ -61,11 +69,26 @@ message ExternalChainEndpoint {
string memo = 3; string memo = 3;
} }
// Card payout destination.
message CardEndpoint {
oneof card {
string pan = 1; // raw PAN
string token = 2; // network or gateway-issued token
}
string cardholder_name = 3;
string cardholder_surname = 4;
uint32 exp_month = 5;
uint32 exp_year = 6;
string country = 7;
string masked_pan = 8;
}
message PaymentEndpoint { message PaymentEndpoint {
oneof endpoint { oneof endpoint {
LedgerEndpoint ledger = 1; LedgerEndpoint ledger = 1;
ManagedWalletEndpoint managed_wallet = 2; ManagedWalletEndpoint managed_wallet = 2;
ExternalChainEndpoint external_chain = 3; ExternalChainEndpoint external_chain = 3;
CardEndpoint card = 4;
} }
map<string, string> metadata = 10; map<string, string> metadata = 10;
} }
@@ -88,6 +111,7 @@ message PaymentIntent {
FXIntent fx = 6; FXIntent fx = 6;
fees.v1.PolicyOverrides fee_policy = 7; fees.v1.PolicyOverrides fee_policy = 7;
map<string, string> attributes = 8; map<string, string> attributes = 8;
SettlementMode settlement_mode = 9;
} }
message PaymentQuote { message PaymentQuote {
@@ -98,8 +122,14 @@ message PaymentQuote {
repeated fees.v1.AppliedRule fee_rules = 5; repeated fees.v1.AppliedRule fee_rules = 5;
oracle.v1.Quote fx_quote = 6; oracle.v1.Quote fx_quote = 6;
chain.gateway.v1.EstimateTransferFeeResponse network_fee = 7; chain.gateway.v1.EstimateTransferFeeResponse network_fee = 7;
string fee_quote_token = 8; string quote_ref = 8;
string quote_ref = 9; }
message PaymentQuoteAggregate {
repeated common.money.v1.Money debit_amounts = 1;
repeated common.money.v1.Money expected_settlement_amounts = 2;
repeated common.money.v1.Money expected_fee_totals = 3;
repeated common.money.v1.Money network_fee_totals = 4;
} }
message ExecutionRefs { message ExecutionRefs {
@@ -107,6 +137,20 @@ message ExecutionRefs {
string credit_entry_ref = 2; string credit_entry_ref = 2;
string fx_entry_ref = 3; string fx_entry_ref = 3;
string chain_transfer_ref = 4; string chain_transfer_ref = 4;
string card_payout_ref = 5;
string fee_transfer_ref = 6;
}
// Card payout gateway tracking info.
message CardPayout {
string payout_ref = 1;
string provider_payment_id = 2;
string status = 3;
string failure_reason = 4;
string card_country = 5;
string masked_pan = 6;
string provider_code = 7;
string gateway_reference = 8;
} }
message Payment { message Payment {
@@ -121,6 +165,7 @@ message Payment {
map<string, string> metadata = 9; map<string, string> metadata = 9;
google.protobuf.Timestamp created_at = 10; google.protobuf.Timestamp created_at = 10;
google.protobuf.Timestamp updated_at = 11; google.protobuf.Timestamp updated_at = 11;
CardPayout card_payout = 12;
} }
message QuotePaymentRequest { message QuotePaymentRequest {
@@ -134,14 +179,36 @@ message QuotePaymentResponse {
PaymentQuote quote = 1; PaymentQuote quote = 1;
} }
message QuotePaymentsRequest {
RequestMeta meta = 1;
string idempotency_key = 2;
repeated PaymentIntent intents = 3;
bool preview_only = 4;
}
message QuotePaymentsResponse {
string quote_ref = 1;
PaymentQuoteAggregate aggregate = 2;
repeated PaymentQuote quotes = 3;
}
message InitiatePaymentsRequest {
RequestMeta meta = 1;
string idempotency_key = 2;
string quote_ref = 3;
map<string, string> metadata = 4;
}
message InitiatePaymentsResponse {
repeated Payment payments = 1;
}
message InitiatePaymentRequest { message InitiatePaymentRequest {
RequestMeta meta = 1; RequestMeta meta = 1;
string idempotency_key = 2; string idempotency_key = 2;
PaymentIntent intent = 3; PaymentIntent intent = 3;
string fee_quote_token = 4; map<string, string> metadata = 4;
string fx_quote_ref = 5; string quote_ref = 5;
map<string, string> metadata = 6;
string quote_ref = 7;
} }
message InitiatePaymentResponse { message InitiatePaymentResponse {
@@ -198,6 +265,15 @@ message ProcessDepositObservedResponse {
Payment payment = 1; Payment payment = 1;
} }
message ProcessCardPayoutUpdateRequest {
RequestMeta meta = 1;
mntx.gateway.v1.CardPayoutStatusChangedEvent event = 2;
}
message ProcessCardPayoutUpdateResponse {
Payment payment = 1;
}
message InitiateConversionRequest { message InitiateConversionRequest {
RequestMeta meta = 1; RequestMeta meta = 1;
string idempotency_key = 2; string idempotency_key = 2;
@@ -214,6 +290,8 @@ message InitiateConversionResponse {
service PaymentOrchestrator { service PaymentOrchestrator {
rpc QuotePayment(QuotePaymentRequest) returns (QuotePaymentResponse); rpc QuotePayment(QuotePaymentRequest) returns (QuotePaymentResponse);
rpc QuotePayments(QuotePaymentsRequest) returns (QuotePaymentsResponse);
rpc InitiatePayments(InitiatePaymentsRequest) returns (InitiatePaymentsResponse);
rpc InitiatePayment(InitiatePaymentRequest) returns (InitiatePaymentResponse); rpc InitiatePayment(InitiatePaymentRequest) returns (InitiatePaymentResponse);
rpc CancelPayment(CancelPaymentRequest) returns (CancelPaymentResponse); rpc CancelPayment(CancelPaymentRequest) returns (CancelPaymentResponse);
rpc GetPayment(GetPaymentRequest) returns (GetPaymentResponse); rpc GetPayment(GetPaymentRequest) returns (GetPaymentResponse);
@@ -221,4 +299,5 @@ service PaymentOrchestrator {
rpc InitiateConversion(InitiateConversionRequest) returns (InitiateConversionResponse); rpc InitiateConversion(InitiateConversionRequest) returns (InitiateConversionResponse);
rpc ProcessTransferUpdate(ProcessTransferUpdateRequest) returns (ProcessTransferUpdateResponse); rpc ProcessTransferUpdate(ProcessTransferUpdateRequest) returns (ProcessTransferUpdateResponse);
rpc ProcessDepositObserved(ProcessDepositObservedRequest) returns (ProcessDepositObservedResponse); rpc ProcessDepositObserved(ProcessDepositObservedRequest) returns (ProcessDepositObservedResponse);
rpc ProcessCardPayoutUpdate(ProcessCardPayoutUpdateRequest) returns (ProcessCardPayoutUpdateResponse);
} }

View File

@@ -16,8 +16,7 @@ api:
CORS: CORS:
max_age: 300 max_age: 300
allowed_origins: allowed_origins:
- "http://*" - "*"
- "https://*"
allowed_methods: allowed_methods:
- "GET" - "GET"
- "POST" - "POST"
@@ -91,8 +90,8 @@ api:
call_timeout_seconds: 5 call_timeout_seconds: 5
insecure: true insecure: true
payment_orchestrator: payment_orchestrator:
address: sendico_payment_orchestrator:50062 address: sendico_payments_orchestrator:50062
address_env: PAYMENT_ORCHESTRATOR_ADDRESS address_env: PAYMENTS_ADDRESS
dial_timeout_seconds: 5 dial_timeout_seconds: 5
call_timeout_seconds: 5 call_timeout_seconds: 5
insecure: true insecure: true

View File

@@ -12,17 +12,18 @@ replace github.com/tech/sendico/gateway/chain => ../gateway/chain
require ( require (
github.com/aws/aws-sdk-go-v2 v1.41.0 github.com/aws/aws-sdk-go-v2 v1.41.0
github.com/aws/aws-sdk-go-v2/config v1.32.4 github.com/aws/aws-sdk-go-v2/config v1.32.6
github.com/aws/aws-sdk-go-v2/credentials v1.19.4 github.com/aws/aws-sdk-go-v2/credentials v1.19.6
github.com/aws/aws-sdk-go-v2/service/s3 v1.93.1 github.com/aws/aws-sdk-go-v2/service/s3 v1.94.0
github.com/go-chi/chi/v5 v5.2.3 github.com/go-chi/chi/v5 v5.2.3
github.com/go-chi/cors v1.2.2 github.com/go-chi/cors v1.2.2
github.com/go-chi/jwtauth/v5 v5.3.3 github.com/go-chi/jwtauth/v5 v5.3.3
github.com/go-chi/metrics v0.1.1 github.com/go-chi/metrics v0.1.1
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/mitchellh/mapstructure v1.5.0 github.com/mitchellh/mapstructure v1.5.0
github.com/shopspring/decimal v1.4.0
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
github.com/tech/sendico/gateway/chain v0.1.0 github.com/tech/sendico/gateway/chain v0.0.0-00010101000000-000000000000
github.com/tech/sendico/ledger v0.0.0-00010101000000-000000000000 github.com/tech/sendico/ledger v0.0.0-00010101000000-000000000000
github.com/tech/sendico/payments/orchestrator v0.0.0-00010101000000-000000000000 github.com/tech/sendico/payments/orchestrator v0.0.0-00010101000000-000000000000
github.com/tech/sendico/pkg v0.1.0 github.com/tech/sendico/pkg v0.1.0
@@ -31,7 +32,7 @@ require (
go.mongodb.org/mongo-driver v1.17.6 go.mongodb.org/mongo-driver v1.17.6
go.uber.org/zap v1.27.1 go.uber.org/zap v1.27.1
golang.org/x/net v0.48.0 golang.org/x/net v0.48.0
google.golang.org/protobuf v1.36.10 google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
moul.io/chizap v1.0.3 moul.io/chizap v1.0.3
) )
@@ -57,9 +58,9 @@ require (
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 // indirect github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 // indirect github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.7 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.4 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 // indirect
github.com/aws/smithy-go v1.24.0 // indirect github.com/aws/smithy-go v1.24.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
@@ -102,7 +103,7 @@ require (
github.com/montanaflynn/stats v0.7.1 // indirect github.com/montanaflynn/stats v0.7.1 // indirect
github.com/morikuni/aec v1.0.0 // indirect github.com/morikuni/aec v1.0.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nats-io/nats.go v1.47.0 // indirect github.com/nats-io/nats.go v1.48.0 // indirect
github.com/nats-io/nkeys v0.4.12 // indirect github.com/nats-io/nkeys v0.4.12 // indirect
github.com/nats-io/nuid v1.0.1 // indirect github.com/nats-io/nuid v1.0.1 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect
@@ -138,6 +139,6 @@ require (
golang.org/x/sync v0.19.0 // indirect golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect golang.org/x/text v0.32.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
google.golang.org/grpc v1.77.0 // indirect google.golang.org/grpc v1.78.0 // indirect
) )

View File

@@ -10,10 +10,10 @@ github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgP
github.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= github.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4=
github.com/aws/aws-sdk-go-v2/config v1.32.4 h1:gl+DxVuadpkYoaDcWllZqLkhGEbvwyqgNVRTmlaf5PI= github.com/aws/aws-sdk-go-v2/config v1.32.6 h1:hFLBGUKjmLAekvi1evLi5hVvFQtSo3GYwi+Bx4lpJf8=
github.com/aws/aws-sdk-go-v2/config v1.32.4/go.mod h1:MBUp9Og/bzMmQHjMwace4aJfyvJeadzXjoTcR/SxLV0= github.com/aws/aws-sdk-go-v2/config v1.32.6/go.mod h1:lcUL/gcd8WyjCrMnxez5OXkO3/rwcNmvfno62tnXNcI=
github.com/aws/aws-sdk-go-v2/credentials v1.19.4 h1:KeIZxHVbGWRLhPvhdPbbi/DtFBHNKm6OsVDuiuFefdQ= github.com/aws/aws-sdk-go-v2/credentials v1.19.6 h1:F9vWao2TwjV2MyiyVS+duza0NIRtAslgLUM0vTA1ZaE=
github.com/aws/aws-sdk-go-v2/credentials v1.19.4/go.mod h1:Smw5n0nCZE9PeFEguofdXyt8kUC4JNrkDTfBOioPhFA= github.com/aws/aws-sdk-go-v2/credentials v1.19.6/go.mod h1:SgHzKjEVsdQr6Opor0ihgWtkWdfRAIwxYzSJ8O85VHY=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 h1:80+uETIWS1BqjnN9uJ0dBUaETh+P1XwFy5vwHwK5r9k= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 h1:80+uETIWS1BqjnN9uJ0dBUaETh+P1XwFy5vwHwK5r9k=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16/go.mod h1:wOOsYuxYuB/7FlnVtzeBYRcjSRtQpAW0hCP7tIULMwo= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16/go.mod h1:wOOsYuxYuB/7FlnVtzeBYRcjSRtQpAW0hCP7tIULMwo=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 h1:rgGwPzb82iBYSvHMHXc8h9mRoOUBZIGFgKb9qniaZZc= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 h1:rgGwPzb82iBYSvHMHXc8h9mRoOUBZIGFgKb9qniaZZc=
@@ -32,16 +32,16 @@ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 h1:oHjJHeUy
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 h1:NSbvS17MlI2lurYgXnCOLvCFX38sBW4eiVER7+kkgsU= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 h1:NSbvS17MlI2lurYgXnCOLvCFX38sBW4eiVER7+kkgsU=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16/go.mod h1:SwT8Tmqd4sA6G1qaGdzWCJN99bUmPGHfRwwq3G5Qb+A= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16/go.mod h1:SwT8Tmqd4sA6G1qaGdzWCJN99bUmPGHfRwwq3G5Qb+A=
github.com/aws/aws-sdk-go-v2/service/s3 v1.93.1 h1:5FhzzN6JmlGQF6c04kDIb5KNGm6KnNdLISNrfivIhHg= github.com/aws/aws-sdk-go-v2/service/s3 v1.94.0 h1:SWTxh/EcUCDVqi/0s26V6pVUq0BBG7kx0tDTmF/hCgA=
github.com/aws/aws-sdk-go-v2/service/s3 v1.93.1/go.mod h1:79S2BdqCJpScXZA2y+cpZuocWsjGjJINyXnOsf5DTz8= github.com/aws/aws-sdk-go-v2/service/s3 v1.94.0/go.mod h1:79S2BdqCJpScXZA2y+cpZuocWsjGjJINyXnOsf5DTz8=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 h1:HpI7aMmJ+mm1wkSHIA2t5EaFFv5EFYXePW30p1EIrbQ= github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 h1:HpI7aMmJ+mm1wkSHIA2t5EaFFv5EFYXePW30p1EIrbQ=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4/go.mod h1:C5RdGMYGlfM0gYq/tifqgn4EbyX99V15P2V3R+VHbQU= github.com/aws/aws-sdk-go-v2/service/signin v1.0.4/go.mod h1:C5RdGMYGlfM0gYq/tifqgn4EbyX99V15P2V3R+VHbQU=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.7 h1:eYnlt6QxnFINKzwxP5/Ucs1vkG7VT3Iezmvfgc2waUw= github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 h1:aM/Q24rIlS3bRAhTyFurowU8A0SMyGDtEOY/l/s/1Uw=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.7/go.mod h1:+fWt2UHSb4kS7Pu8y+BMBvJF0EWx+4H0hzNwtDNRTrg= github.com/aws/aws-sdk-go-v2/service/sso v1.30.8/go.mod h1:+fWt2UHSb4kS7Pu8y+BMBvJF0EWx+4H0hzNwtDNRTrg=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 h1:AHDr0DaHIAo8c9t1emrzAlVDFp+iMMKnPdYy6XO4MCE= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 h1:AHDr0DaHIAo8c9t1emrzAlVDFp+iMMKnPdYy6XO4MCE=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12/go.mod h1:GQ73XawFFiWxyWXMHWfhiomvP3tXtdNar/fi8z18sx0= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12/go.mod h1:GQ73XawFFiWxyWXMHWfhiomvP3tXtdNar/fi8z18sx0=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.4 h1:YCu/iAhQer8WZ66lldyKkpvMyv+HkPufMa4dyT6wils= github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 h1:SciGFVNZ4mHdm7gpD1dgZYnCuVdX1s+lFTg4+4DOy70=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.4/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk= github.com/aws/aws-sdk-go-v2/service/sts v1.41.5/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk=
github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
@@ -175,8 +175,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nats-io/nats.go v1.47.0 h1:YQdADw6J/UfGUd2Oy6tn4Hq6YHxCaJrVKayxxFqYrgM= github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
github.com/nats-io/nats.go v1.47.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc= github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg= github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
@@ -213,6 +213,8 @@ github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFt
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -357,14 +359,14 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 h1:mepRgnBZa07I4TRuomDE4sTIYieg/osKmzIf4USdWS4= google.golang.org/genproto/googleapis/api v0.0.0-20251029180050-ab9386a59fda h1:+2XxjfsAu6vqFxwGBRcHiMaDCuZiqXGDUDVWVtrFAnE=
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo= google.golang.org/genproto/googleapis/api v0.0.0-20251029180050-ab9386a59fda/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=

View File

@@ -0,0 +1,76 @@
package srequest
// Asset represents a chain/token pair for blockchain endpoints.
type Asset struct {
Chain ChainNetwork `json:"chain"`
TokenSymbol string `json:"token_symbol"`
ContractAddress string `json:"contract_address,omitempty"`
}
// LedgerEndpoint represents a ledger account payload.
type LedgerEndpoint struct {
LedgerAccountRef string `json:"ledger_account_ref"`
ContraLedgerAccountRef string `json:"contra_ledger_account_ref,omitempty"`
}
// ManagedWalletEndpoint represents a managed wallet payload.
type ManagedWalletEndpoint struct {
ManagedWalletRef string `json:"managed_wallet_ref"`
Asset *Asset `json:"asset,omitempty"`
}
// ExternalChainEndpoint represents an external chain address payload.
type ExternalChainEndpoint struct {
Asset *Asset `json:"asset,omitempty"`
Address string `json:"address"`
Memo string `json:"memo,omitempty"`
}
// CardEndpoint represents a card payout payload (PAN or network token).
type CardEndpoint struct {
Pan string `json:"pan"`
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
ExpMonth uint32 `json:"exp_month,omitempty"`
ExpYear uint32 `json:"exp_year,omitempty"`
Country string `json:"country,omitempty"`
}
// CardTokenEndpoint represents a vaulted card token payout payload.
type CardTokenEndpoint struct {
Token string `json:"token"`
MaskedPan string `json:"masked_pan"`
}
// WalletEndpoint represents a Sendico wallet payout payload.
type WalletEndpoint struct {
WalletID string `json:"walletId"`
}
// BankAccountEndpoint represents a domestic bank account payout payload.
type BankAccountEndpoint struct {
RecipientName string `json:"recipientName"`
Inn string `json:"inn"`
Kpp string `json:"kpp"`
BankName string `json:"bankName"`
Bik string `json:"bik"`
AccountNumber string `json:"accountNumber"`
CorrespondentAccount string `json:"correspondentAccount"`
}
// IBANEndpoint represents an international bank account payout payload.
type IBANEndpoint struct {
IBAN string `json:"iban"`
AccountHolder string `json:"accountHolder"`
BIC string `json:"bic,omitempty"`
BankName string `json:"bankName,omitempty"`
}
// LegacyPaymentEndpoint mirrors the previous bag-of-pointers DTO for backward compatibility.
type LegacyPaymentEndpoint struct {
Ledger *LedgerEndpoint `json:"ledger,omitempty"`
ManagedWallet *ManagedWalletEndpoint `json:"managed_wallet,omitempty"`
ExternalChain *ExternalChainEndpoint `json:"external_chain,omitempty"`
Card *CardEndpoint `json:"card,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
}

View File

@@ -0,0 +1,266 @@
package srequest
import (
"encoding/json"
"github.com/tech/sendico/pkg/merrors"
)
type EndpointType string
const (
EndpointTypeLedger EndpointType = "ledger"
EndpointTypeManagedWallet EndpointType = "managedWallet"
EndpointTypeExternalChain EndpointType = "cryptoAddress"
EndpointTypeCard EndpointType = "card"
EndpointTypeCardToken EndpointType = "cardToken"
EndpointTypeWallet EndpointType = "wallet"
EndpointTypeBankAccount EndpointType = "bankAccount"
EndpointTypeIBAN EndpointType = "iban"
)
// Endpoint is a discriminated union for payment endpoints.
type Endpoint struct {
Type EndpointType `json:"type"`
Data json.RawMessage `json:"data"`
Metadata map[string]string `json:"metadata,omitempty"`
}
func newEndpoint(kind EndpointType, payload interface{}, metadata map[string]string) (Endpoint, error) {
data, err := json.Marshal(payload)
if err != nil {
return Endpoint{}, merrors.Internal("marshal endpoint payload failed")
}
return Endpoint{
Type: kind,
Data: data,
Metadata: cloneStringMap(metadata),
}, nil
}
func (e Endpoint) decodePayload(expected EndpointType, dst interface{}) error {
actual := normalizeEndpointType(e.Type)
if actual == "" {
return merrors.InvalidArgument("endpoint type is required")
}
if actual != expected {
return merrors.InvalidArgument("expected endpoint type " + string(expected) + ", got " + string(e.Type))
}
if len(e.Data) == 0 {
return merrors.InvalidArgument("endpoint data is required for type " + string(expected))
}
if err := json.Unmarshal(e.Data, dst); err != nil {
return merrors.InvalidArgument("decode " + string(expected) + " endpoint: " + err.Error())
}
return nil
}
func (e *Endpoint) UnmarshalJSON(data []byte) error {
var envelope struct {
Type EndpointType `json:"type"`
Data json.RawMessage `json:"data"`
Metadata map[string]string `json:"metadata"`
}
if err := json.Unmarshal(data, &envelope); err == nil {
if envelope.Type != "" || len(envelope.Data) > 0 {
if envelope.Type == "" {
return merrors.InvalidArgument("endpoint type is required")
}
*e = Endpoint{
Type: normalizeEndpointType(envelope.Type),
Data: envelope.Data,
Metadata: cloneStringMap(envelope.Metadata),
}
return nil
}
}
var legacy LegacyPaymentEndpoint
if err := json.Unmarshal(data, &legacy); err != nil {
return err
}
endpoint, err := LegacyPaymentEndpointToEndpointDTO(&legacy)
if err != nil {
return err
}
if endpoint == nil {
return merrors.InvalidArgument("endpoint payload is empty")
}
*e = *endpoint
return nil
}
func NewLedgerEndpointDTO(payload LedgerEndpoint, metadata map[string]string) (Endpoint, error) {
return newEndpoint(EndpointTypeLedger, payload, metadata)
}
func NewManagedWalletEndpointDTO(payload ManagedWalletEndpoint, metadata map[string]string) (Endpoint, error) {
return newEndpoint(EndpointTypeManagedWallet, payload, metadata)
}
func NewExternalChainEndpointDTO(payload ExternalChainEndpoint, metadata map[string]string) (Endpoint, error) {
return newEndpoint(EndpointTypeExternalChain, payload, metadata)
}
func NewCardEndpointDTO(payload CardEndpoint, metadata map[string]string) (Endpoint, error) {
return newEndpoint(EndpointTypeCard, payload, metadata)
}
func NewCardTokenEndpointDTO(payload CardTokenEndpoint, metadata map[string]string) (Endpoint, error) {
return newEndpoint(EndpointTypeCardToken, payload, metadata)
}
func NewWalletEndpointDTO(payload WalletEndpoint, metadata map[string]string) (Endpoint, error) {
return newEndpoint(EndpointTypeWallet, payload, metadata)
}
func NewBankAccountEndpointDTO(payload BankAccountEndpoint, metadata map[string]string) (Endpoint, error) {
return newEndpoint(EndpointTypeBankAccount, payload, metadata)
}
func NewIBANEndpointDTO(payload IBANEndpoint, metadata map[string]string) (Endpoint, error) {
return newEndpoint(EndpointTypeIBAN, payload, metadata)
}
func (e Endpoint) DecodeLedger() (LedgerEndpoint, error) {
var payload LedgerEndpoint
return payload, e.decodePayload(EndpointTypeLedger, &payload)
}
func (e Endpoint) DecodeManagedWallet() (ManagedWalletEndpoint, error) {
var payload ManagedWalletEndpoint
return payload, e.decodePayload(EndpointTypeManagedWallet, &payload)
}
func (e Endpoint) DecodeExternalChain() (ExternalChainEndpoint, error) {
var payload ExternalChainEndpoint
return payload, e.decodePayload(EndpointTypeExternalChain, &payload)
}
func (e Endpoint) DecodeCard() (CardEndpoint, error) {
var payload CardEndpoint
return payload, e.decodePayload(EndpointTypeCard, &payload)
}
func (e Endpoint) DecodeCardToken() (CardTokenEndpoint, error) {
var payload CardTokenEndpoint
return payload, e.decodePayload(EndpointTypeCardToken, &payload)
}
func (e Endpoint) DecodeWallet() (WalletEndpoint, error) {
var payload WalletEndpoint
return payload, e.decodePayload(EndpointTypeWallet, &payload)
}
func (e Endpoint) DecodeBankAccount() (BankAccountEndpoint, error) {
var payload BankAccountEndpoint
return payload, e.decodePayload(EndpointTypeBankAccount, &payload)
}
func (e Endpoint) DecodeIBAN() (IBANEndpoint, error) {
var payload IBANEndpoint
return payload, e.decodePayload(EndpointTypeIBAN, &payload)
}
func LegacyPaymentEndpointToEndpointDTO(old *LegacyPaymentEndpoint) (*Endpoint, error) {
if old == nil {
return nil, nil
}
count := 0
var endpoint Endpoint
var err error
if old.Ledger != nil {
count++
endpoint, err = NewLedgerEndpointDTO(*old.Ledger, old.Metadata)
}
if old.ManagedWallet != nil {
count++
endpoint, err = NewManagedWalletEndpointDTO(*old.ManagedWallet, old.Metadata)
}
if old.ExternalChain != nil {
count++
endpoint, err = NewExternalChainEndpointDTO(*old.ExternalChain, old.Metadata)
}
if old.Card != nil {
count++
endpoint, err = NewCardEndpointDTO(*old.Card, old.Metadata)
}
if err != nil {
return nil, err
}
if count == 0 {
return nil, merrors.InvalidArgument("exactly one endpoint must be set")
}
if count > 1 {
return nil, merrors.InvalidArgument("only one endpoint can be set")
}
return &endpoint, nil
}
func EndpointDTOToLegacyPaymentEndpoint(new *Endpoint) (*LegacyPaymentEndpoint, error) {
if new == nil {
return nil, nil
}
legacy := &LegacyPaymentEndpoint{
Metadata: cloneStringMap(new.Metadata),
}
switch normalizeEndpointType(new.Type) {
case EndpointTypeLedger:
payload, err := new.DecodeLedger()
if err != nil {
return nil, err
}
legacy.Ledger = &payload
case EndpointTypeManagedWallet:
payload, err := new.DecodeManagedWallet()
if err != nil {
return nil, err
}
legacy.ManagedWallet = &payload
case EndpointTypeExternalChain:
payload, err := new.DecodeExternalChain()
if err != nil {
return nil, err
}
legacy.ExternalChain = &payload
case EndpointTypeCard:
payload, err := new.DecodeCard()
if err != nil {
return nil, err
}
legacy.Card = &payload
default:
return nil, merrors.InvalidArgument("unsupported endpoint type: " + string(new.Type))
}
return legacy, nil
}
var endpointTypeAliases = map[EndpointType]EndpointType{
"managed_wallet": EndpointTypeManagedWallet,
"external_chain": EndpointTypeExternalChain,
"card_token": EndpointTypeCardToken,
"bank_account": EndpointTypeBankAccount,
}
func normalizeEndpointType(t EndpointType) EndpointType {
if canonical, ok := endpointTypeAliases[t]; ok {
return canonical
}
return t
}
func cloneStringMap(src map[string]string) map[string]string {
if len(src) == 0 {
return nil
}
dst := make(map[string]string, len(src))
for k, v := range src {
dst[k] = v
}
return dst
}

View File

@@ -1,17 +1,106 @@
package srequest package srequest
type QuotePayment struct { import (
"github.com/tech/sendico/pkg/merrors"
)
type PaymentBase struct {
IdempotencyKey string `json:"idempotencyKey"` IdempotencyKey string `json:"idempotencyKey"`
Intent *PaymentIntent `json:"intent"`
PreviewOnly bool `json:"previewOnly"`
Metadata map[string]string `json:"metadata,omitempty"` Metadata map[string]string `json:"metadata,omitempty"`
} }
func (b *PaymentBase) Validate() error {
if b.IdempotencyKey == "" {
return merrors.InvalidArgument("idempotencyKey is required", "idempotencyKey")
}
return nil
}
type QuotePayment struct {
PaymentBase `json:",inline"`
Intent PaymentIntent `json:"intent"`
PreviewOnly bool `json:"previewOnly"`
}
func (r *QuotePayment) Validate() error {
// base checks
if err := r.PaymentBase.Validate(); err != nil {
return err
}
// intent is mandatory, so validate always
if err := r.Intent.Validate(); err != nil {
return err
}
return nil
}
type QuotePayments struct {
PaymentBase `json:",inline"`
Intents []PaymentIntent `json:"intents"`
PreviewOnly bool `json:"previewOnly"`
}
func (r *QuotePayments) Validate() error {
if err := r.PaymentBase.Validate(); err != nil {
return err
}
if len(r.Intents) == 0 {
return merrors.InvalidArgument("intents are required", "intents")
}
for i := range r.Intents {
if err := r.Intents[i].Validate(); err != nil {
return err
}
}
return nil
}
type InitiatePayment struct { type InitiatePayment struct {
IdempotencyKey string `json:"idempotencyKey"` PaymentBase `json:",inline"`
Intent *PaymentIntent `json:"intent"` Intent *PaymentIntent `json:"intent,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"` QuoteRef string `json:"quoteRef,omitempty"`
FeeQuoteToken string `json:"feeQuoteToken,omitempty"` }
FxQuoteRef string `json:"fxQuoteRef,omitempty"`
QuoteRef string `json:"quoteRef,omitempty"` func (r InitiatePayment) Validate() error {
// base checks
if err := r.PaymentBase.Validate(); err != nil {
return err
}
hasIntent := r.Intent != nil
hasQuote := r.QuoteRef != ""
// must be exactly one
switch {
case !hasIntent && !hasQuote:
return merrors.NoData("either intent or quoteRef must be provided")
case hasIntent && hasQuote:
return merrors.DataConflict("intent and quoteRef are mutually exclusive")
}
// if intent provided → validate it
if hasIntent {
if err := r.Intent.Validate(); err != nil {
return err
}
}
return nil
}
type InitiatePayments struct {
PaymentBase `json:",inline"`
QuoteRef string `json:"quoteRef,omitempty"`
}
func (r InitiatePayments) Validate() error {
if err := r.PaymentBase.Validate(); err != nil {
return err
}
if r.QuoteRef == "" {
return merrors.InvalidArgument("quoteRef is required", "quoteRef")
}
return nil
} }

View File

@@ -0,0 +1,50 @@
package srequest
// PaymentKind mirrors the orchestrator payment kinds without importing generated proto types.
// Strings keep JSON readable; conversion helpers map these to proto enums.
type PaymentKind string
const (
PaymentKindUnspecified PaymentKind = "unspecified"
PaymentKindPayout PaymentKind = "payout"
PaymentKindInternalTransfer PaymentKind = "internal_transfer"
PaymentKindFxConversion PaymentKind = "fx_conversion"
)
// SettlementMode matches orchestrator settlement behavior.
type SettlementMode string
const (
SettlementModeUnspecified SettlementMode = "unspecified"
SettlementModeFixSource SettlementMode = "fix_source"
SettlementModeFixReceived SettlementMode = "fix_received"
)
// FXSide mirrors the common FX side enum.
type FXSide string
const (
FXSideUnspecified FXSide = "unspecified"
FXSideBuyBaseSellQuote FXSide = "buy_base_sell_quote"
FXSideSellBaseBuyQuote FXSide = "sell_base_buy_quote"
)
// ChainNetwork mirrors the chain network enum used by managed wallets.
type ChainNetwork string
const (
ChainNetworkUnspecified ChainNetwork = "unspecified"
ChainNetworkEthereumMainnet ChainNetwork = "ethereum_mainnet"
ChainNetworkArbitrumOne ChainNetwork = "arbitrum_one"
ChainNetworkOtherEVM ChainNetwork = "other_evm"
)
// InsufficientNetPolicy mirrors the fee engine policy override.
type InsufficientNetPolicy string
const (
InsufficientNetPolicyUnspecified InsufficientNetPolicy = "unspecified"
InsufficientNetPolicyBlockPosting InsufficientNetPolicy = "block_posting"
InsufficientNetPolicySweepOrgCash InsufficientNetPolicy = "sweep_org_cash"
InsufficientNetPolicyInvoiceLater InsufficientNetPolicy = "invoice_later"
)

View File

@@ -0,0 +1,54 @@
package srequest
import (
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model"
)
type PaymentIntent struct {
Kind PaymentKind `json:"kind,omitempty"`
Source *Endpoint `json:"source,omitempty"`
Destination *Endpoint `json:"destination,omitempty"`
Amount *model.Money `json:"amount,omitempty"`
FX *FXIntent `json:"fx,omitempty"`
SettlementMode SettlementMode `json:"settlement_mode,omitempty"`
Attributes map[string]string `json:"attributes,omitempty"`
}
type AssetResolverStub struct{}
func (a *AssetResolverStub) IsSupported(_ string) bool {
return true
}
func (p *PaymentIntent) Validate() error {
// Kind must be set (non-zero)
var zeroKind PaymentKind
if p.Kind == zeroKind {
return merrors.InvalidArgument("kind is required", "intent.kind")
}
if p.Source == nil {
return merrors.InvalidArgument("source is required", "intent.source")
}
if p.Destination == nil {
return merrors.InvalidArgument("destination is required", "intent.destination")
}
if p.Amount == nil {
return merrors.InvalidArgument("amount is required", "intent.amount")
}
//TODO: collect supported currencies and validate against them
if err := ValidateMoney(p.Amount, &AssetResolverStub{}); err != nil {
return err
}
if p.FX != nil {
if err := p.FX.Validate(); err != nil {
return err
}
}
return nil
}

View File

@@ -1,103 +0,0 @@
package srequest
// PaymentKind mirrors the orchestrator payment kinds without importing generated proto types.
type PaymentKind int32
const (
PaymentKindUnspecified PaymentKind = 0
PaymentKindPayout PaymentKind = 1
PaymentKindInternalTransfer PaymentKind = 2
PaymentKindFxConversion PaymentKind = 3
)
// FXSide mirrors the common FX side enum.
type FXSide int32
const (
FXSideUnspecified FXSide = 0
FXSideBuyBaseSellQuote FXSide = 1
FXSideSellBaseBuyQuote FXSide = 2
)
// ChainNetwork mirrors the chain network enum used by managed wallets.
type ChainNetwork int32
const (
ChainNetworkUnspecified ChainNetwork = 0
ChainNetworkEthereumMainnet ChainNetwork = 1
ChainNetworkArbitrumOne ChainNetwork = 2
ChainNetworkOtherEVM ChainNetwork = 3
)
// InsufficientNetPolicy mirrors the fee engine policy override.
type InsufficientNetPolicy int32
const (
InsufficientNetPolicyUnspecified InsufficientNetPolicy = 0
InsufficientNetPolicyBlockPosting InsufficientNetPolicy = 1
InsufficientNetPolicySweepOrgCash InsufficientNetPolicy = 2
InsufficientNetPolicyInvoiceLater InsufficientNetPolicy = 3
)
type PaymentIntent struct {
Kind PaymentKind `json:"kind,omitempty"`
Source *PaymentEndpoint `json:"source,omitempty"`
Destination *PaymentEndpoint `json:"destination,omitempty"`
Amount *Money `json:"amount,omitempty"`
RequiresFX bool `json:"requires_fx,omitempty"`
FX *FXIntent `json:"fx,omitempty"`
FeePolicy *PolicyOverrides `json:"fee_policy,omitempty"`
Attributes map[string]string `json:"attributes,omitempty"`
}
type PaymentEndpoint struct {
Ledger *LedgerEndpoint `json:"ledger,omitempty"`
ManagedWallet *ManagedWalletEndpoint `json:"managed_wallet,omitempty"`
ExternalChain *ExternalChainEndpoint `json:"external_chain,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
}
type LedgerEndpoint struct {
LedgerAccountRef string `json:"ledger_account_ref"`
ContraLedgerAccountRef string `json:"contra_ledger_account_ref,omitempty"`
}
type ManagedWalletEndpoint struct {
ManagedWalletRef string `json:"managed_wallet_ref"`
Asset *Asset `json:"asset,omitempty"`
}
type ExternalChainEndpoint struct {
Asset *Asset `json:"asset,omitempty"`
Address string `json:"address"`
Memo string `json:"memo,omitempty"`
}
type Asset struct {
Chain ChainNetwork `json:"chain"`
TokenSymbol string `json:"token_symbol"`
ContractAddress string `json:"contract_address,omitempty"`
}
type Money struct {
Amount string `json:"amount"`
Currency string `json:"currency"`
}
type CurrencyPair struct {
Base string `json:"base"`
Quote string `json:"quote"`
}
type FXIntent struct {
Pair *CurrencyPair `json:"pair,omitempty"`
Side FXSide `json:"side,omitempty"`
Firm bool `json:"firm,omitempty"`
TTLms int64 `json:"ttl_ms,omitempty"`
PreferredProvider string `json:"preferred_provider,omitempty"`
MaxAgeMs int32 `json:"max_age_ms,omitempty"`
}
type PolicyOverrides struct {
InsufficientNet InsufficientNetPolicy `json:"insufficient_net,omitempty"`
}

View File

@@ -0,0 +1,427 @@
package srequest
import (
"encoding/json"
"reflect"
"strings"
"testing"
"github.com/tech/sendico/pkg/model"
)
func TestEndpointDTOBuildersAndDecoders(t *testing.T) {
meta := map[string]string{"note": "meta"}
t.Run("ledger", func(t *testing.T) {
payload := LedgerEndpoint{LedgerAccountRef: "acc-1", ContraLedgerAccountRef: "contra-1"}
endpoint, err := NewLedgerEndpointDTO(payload, meta)
if err != nil {
t.Fatalf("build ledger endpoint: %v", err)
}
if endpoint.Type != EndpointTypeLedger {
t.Fatalf("expected type %s got %s", EndpointTypeLedger, endpoint.Type)
}
if string(endpoint.Data) != `{"ledger_account_ref":"acc-1","contra_ledger_account_ref":"contra-1"}` {
t.Fatalf("unexpected data: %s", endpoint.Data)
}
decoded, err := endpoint.DecodeLedger()
if err != nil {
t.Fatalf("decode ledger: %v", err)
}
if decoded != payload {
t.Fatalf("decoded payload mismatch: %#v vs %#v", decoded, payload)
}
meta["note"] = "changed"
if endpoint.Metadata["note"] != "meta" {
t.Fatalf("metadata should be copied, got %s", endpoint.Metadata["note"])
}
})
t.Run("managed wallet", func(t *testing.T) {
payload := ManagedWalletEndpoint{
ManagedWalletRef: "mw-1",
Asset: &Asset{
Chain: ChainNetworkArbitrumOne,
TokenSymbol: "USDC",
ContractAddress: "0xabc",
},
}
endpoint, err := NewManagedWalletEndpointDTO(payload, nil)
if err != nil {
t.Fatalf("build managed wallet endpoint: %v", err)
}
if endpoint.Type != EndpointTypeManagedWallet {
t.Fatalf("expected type %s got %s", EndpointTypeManagedWallet, endpoint.Type)
}
decoded, err := endpoint.DecodeManagedWallet()
if err != nil {
t.Fatalf("decode managed wallet: %v", err)
}
if !reflect.DeepEqual(decoded, payload) {
t.Fatalf("decoded payload mismatch: %#v vs %#v", decoded, payload)
}
})
t.Run("external chain", func(t *testing.T) {
payload := ExternalChainEndpoint{
Asset: &Asset{
Chain: ChainNetworkOtherEVM,
TokenSymbol: "ETH",
},
Address: "0x123",
Memo: "memo",
}
endpoint, err := NewExternalChainEndpointDTO(payload, nil)
if err != nil {
t.Fatalf("build external chain endpoint: %v", err)
}
if endpoint.Type != EndpointTypeExternalChain {
t.Fatalf("expected type %s got %s", EndpointTypeExternalChain, endpoint.Type)
}
decoded, err := endpoint.DecodeExternalChain()
if err != nil {
t.Fatalf("decode external chain: %v", err)
}
if !reflect.DeepEqual(decoded, payload) {
t.Fatalf("decoded payload mismatch: %#v vs %#v", decoded, payload)
}
})
t.Run("card", func(t *testing.T) {
payload := CardEndpoint{
Pan: "pan",
FirstName: "Jane",
LastName: "Doe",
ExpMonth: 12,
ExpYear: 2030,
Country: "US",
}
endpoint, err := NewCardEndpointDTO(payload, map[string]string{"k": "v"})
if err != nil {
t.Fatalf("build card endpoint: %v", err)
}
if endpoint.Type != EndpointTypeCard {
t.Fatalf("expected type %s got %s", EndpointTypeCard, endpoint.Type)
}
decoded, err := endpoint.DecodeCard()
if err != nil {
t.Fatalf("decode card: %v", err)
}
if !reflect.DeepEqual(decoded, payload) {
t.Fatalf("decoded payload mismatch: %#v vs %#v", decoded, payload)
}
if endpoint.Metadata["k"] != "v" {
t.Fatalf("expected metadata copy, got %s", endpoint.Metadata["k"])
}
})
t.Run("card token", func(t *testing.T) {
payload := CardTokenEndpoint{Token: "token", MaskedPan: "****1234"}
endpoint, err := NewCardTokenEndpointDTO(payload, nil)
if err != nil {
t.Fatalf("build card token endpoint: %v", err)
}
if endpoint.Type != EndpointTypeCardToken {
t.Fatalf("expected type %s got %s", EndpointTypeCardToken, endpoint.Type)
}
decoded, err := endpoint.DecodeCardToken()
if err != nil {
t.Fatalf("decode card token: %v", err)
}
if !reflect.DeepEqual(decoded, payload) {
t.Fatalf("decoded payload mismatch: %#v vs %#v", decoded, payload)
}
})
t.Run("wallet", func(t *testing.T) {
payload := WalletEndpoint{WalletID: "wallet-1"}
endpoint, err := NewWalletEndpointDTO(payload, nil)
if err != nil {
t.Fatalf("build wallet endpoint: %v", err)
}
if endpoint.Type != EndpointTypeWallet {
t.Fatalf("expected type %s got %s", EndpointTypeWallet, endpoint.Type)
}
decoded, err := endpoint.DecodeWallet()
if err != nil {
t.Fatalf("decode wallet: %v", err)
}
if decoded != payload {
t.Fatalf("decoded payload mismatch: %#v vs %#v", decoded, payload)
}
})
t.Run("bank account", func(t *testing.T) {
payload := BankAccountEndpoint{
RecipientName: "ACME",
Inn: "inn",
Kpp: "kpp",
BankName: "bank",
Bik: "bik",
AccountNumber: "123",
CorrespondentAccount: "456",
}
endpoint, err := NewBankAccountEndpointDTO(payload, map[string]string{"note": "n"})
if err != nil {
t.Fatalf("build bank account endpoint: %v", err)
}
if endpoint.Type != EndpointTypeBankAccount {
t.Fatalf("expected type %s got %s", EndpointTypeBankAccount, endpoint.Type)
}
decoded, err := endpoint.DecodeBankAccount()
if err != nil {
t.Fatalf("decode bank account: %v", err)
}
if !reflect.DeepEqual(decoded, payload) {
t.Fatalf("decoded payload mismatch: %#v vs %#v", decoded, payload)
}
if endpoint.Metadata["note"] != "n" {
t.Fatalf("expected metadata copy, got %s", endpoint.Metadata["note"])
}
})
t.Run("iban", func(t *testing.T) {
payload := IBANEndpoint{
IBAN: "DE123",
AccountHolder: "John Doe",
BIC: "BICCODE",
BankName: "BankName",
}
endpoint, err := NewIBANEndpointDTO(payload, nil)
if err != nil {
t.Fatalf("build iban endpoint: %v", err)
}
if endpoint.Type != EndpointTypeIBAN {
t.Fatalf("expected type %s got %s", EndpointTypeIBAN, endpoint.Type)
}
decoded, err := endpoint.DecodeIBAN()
if err != nil {
t.Fatalf("decode iban: %v", err)
}
if !reflect.DeepEqual(decoded, payload) {
t.Fatalf("decoded payload mismatch: %#v vs %#v", decoded, payload)
}
})
t.Run("type mismatch", func(t *testing.T) {
endpoint, err := NewLedgerEndpointDTO(LedgerEndpoint{LedgerAccountRef: "acc"}, nil)
if err != nil {
t.Fatalf("build ledger endpoint: %v", err)
}
if _, err := endpoint.DecodeCard(); err == nil || !strings.Contains(err.Error(), "expected endpoint type") {
t.Fatalf("expected type mismatch error, got %v", err)
}
})
t.Run("invalid json data", func(t *testing.T) {
endpoint := Endpoint{Type: EndpointTypeLedger, Data: json.RawMessage("not-json")}
if _, err := endpoint.DecodeLedger(); err == nil {
t.Fatalf("expected decode error")
}
})
t.Run("legacy type alias normalizes", func(t *testing.T) {
raw := []byte(`{"type":"managed_wallet","data":{"managed_wallet_ref":"mw-legacy"}}`)
var endpoint Endpoint
if err := json.Unmarshal(raw, &endpoint); err != nil {
t.Fatalf("unmarshal with legacy type: %v", err)
}
if endpoint.Type != EndpointTypeManagedWallet {
t.Fatalf("expected normalized type %s got %s", EndpointTypeManagedWallet, endpoint.Type)
}
payload, err := endpoint.DecodeManagedWallet()
if err != nil {
t.Fatalf("decode managed wallet with alias: %v", err)
}
if payload.ManagedWalletRef != "mw-legacy" {
t.Fatalf("decoded payload mismatch from alias: %#v", payload)
}
})
}
func TestPaymentIntentJSONRoundTrip(t *testing.T) {
sourcePayload := LedgerEndpoint{LedgerAccountRef: "source"}
source, err := NewLedgerEndpointDTO(sourcePayload, map[string]string{"src": "meta"})
if err != nil {
t.Fatalf("build source endpoint: %v", err)
}
destPayload := ExternalChainEndpoint{Address: "0xabc", Asset: &Asset{Chain: ChainNetworkEthereumMainnet, TokenSymbol: "USDC"}}
dest, err := NewExternalChainEndpointDTO(destPayload, nil)
if err != nil {
t.Fatalf("build destination endpoint: %v", err)
}
intent := &PaymentIntent{
Kind: PaymentKindPayout,
Source: &source,
Destination: &dest,
Amount: &model.Money{Amount: "10", Currency: "USD"},
FX: &FXIntent{
Pair: &CurrencyPair{Base: "USD", Quote: "EUR"},
Side: FXSideBuyBaseSellQuote,
Firm: true,
TTLms: 5000,
PreferredProvider: "provider",
MaxAgeMs: 10,
},
SettlementMode: SettlementModeFixReceived,
Attributes: map[string]string{"k": "v"},
}
data, err := json.Marshal(intent)
if err != nil {
t.Fatalf("marshal intent: %v", err)
}
var decoded PaymentIntent
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("unmarshal intent: %v", err)
}
if decoded.Kind != intent.Kind || decoded.SettlementMode != intent.SettlementMode {
t.Fatalf("scalar fields changed after round trip")
}
if decoded.Amount == nil || *decoded.Amount != *intent.Amount {
t.Fatalf("amount mismatch after round trip")
}
if decoded.FX == nil || decoded.FX.PreferredProvider != intent.FX.PreferredProvider {
t.Fatalf("fx mismatch after round trip")
}
if decoded.Source == nil || decoded.Destination == nil {
t.Fatalf("source/destination missing after round trip")
}
sourceDecoded, err := decoded.Source.DecodeLedger()
if err != nil {
t.Fatalf("decode source after round trip: %v", err)
}
if sourceDecoded != sourcePayload {
t.Fatalf("source payload mismatch after round trip: %#v vs %#v", sourceDecoded, sourcePayload)
}
destDecoded, err := decoded.Destination.DecodeExternalChain()
if err != nil {
t.Fatalf("decode destination after round trip: %v", err)
}
if !reflect.DeepEqual(destDecoded, destPayload) {
t.Fatalf("destination payload mismatch after round trip: %#v vs %#v", destDecoded, destPayload)
}
if decoded.Attributes["k"] != "v" {
t.Fatalf("attributes mismatch after round trip")
}
}
func TestPaymentIntentMinimalRoundTrip(t *testing.T) {
sourcePayload := ManagedWalletEndpoint{ManagedWalletRef: "mw"}
source, err := NewManagedWalletEndpointDTO(sourcePayload, nil)
if err != nil {
t.Fatalf("build source endpoint: %v", err)
}
destPayload := LedgerEndpoint{LedgerAccountRef: "dest-ledger"}
dest, err := NewLedgerEndpointDTO(destPayload, nil)
if err != nil {
t.Fatalf("build destination endpoint: %v", err)
}
intent := &PaymentIntent{
Kind: PaymentKindInternalTransfer,
Source: &source,
Destination: &dest,
Amount: &model.Money{Amount: "1", Currency: "USD"},
}
data, err := json.Marshal(intent)
if err != nil {
t.Fatalf("marshal intent: %v", err)
}
var decoded PaymentIntent
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("unmarshal intent: %v", err)
}
if decoded.Kind != intent.Kind || decoded.FX != nil {
t.Fatalf("unexpected fx data in minimal intent: %#v", decoded)
}
if decoded.Amount == nil || *decoded.Amount != *intent.Amount {
t.Fatalf("amount mismatch after round trip")
}
if decoded.Source == nil || decoded.Destination == nil {
t.Fatalf("endpoints missing after round trip")
}
sourceDecoded, err := decoded.Source.DecodeManagedWallet()
if err != nil {
t.Fatalf("decode source: %v", err)
}
if !reflect.DeepEqual(sourceDecoded, sourcePayload) {
t.Fatalf("source payload mismatch: %#v vs %#v", sourceDecoded, sourcePayload)
}
destDecoded, err := decoded.Destination.DecodeLedger()
if err != nil {
t.Fatalf("decode destination: %v", err)
}
if destDecoded != destPayload {
t.Fatalf("destination payload mismatch: %#v vs %#v", destDecoded, destPayload)
}
}
func TestLegacyEndpointRoundTrip(t *testing.T) {
legacy := &LegacyPaymentEndpoint{
ExternalChain: &ExternalChainEndpoint{
Asset: &Asset{Chain: ChainNetworkOtherEVM, TokenSymbol: "DAI", ContractAddress: "0xdef"},
Address: "0x123",
Memo: "memo",
},
Metadata: map[string]string{"note": "legacy"},
}
endpoint, err := LegacyPaymentEndpointToEndpointDTO(legacy)
if err != nil {
t.Fatalf("convert legacy to dto: %v", err)
}
if endpoint == nil || endpoint.Type != EndpointTypeExternalChain {
t.Fatalf("unexpected endpoint result: %#v", endpoint)
}
legacy.Metadata["note"] = "changed"
if endpoint.Metadata["note"] != "legacy" {
t.Fatalf("metadata should be copied from legacy")
}
roundTrip, err := EndpointDTOToLegacyPaymentEndpoint(endpoint)
if err != nil {
t.Fatalf("convert dto back to legacy: %v", err)
}
if roundTrip == nil || roundTrip.ExternalChain == nil {
t.Fatalf("round trip legacy missing payload: %#v", roundTrip)
}
if !reflect.DeepEqual(roundTrip.ExternalChain, legacy.ExternalChain) {
t.Fatalf("round trip payload mismatch: %#v vs %#v", roundTrip.ExternalChain, legacy.ExternalChain)
}
if roundTrip.Metadata["note"] != "legacy" {
t.Fatalf("metadata mismatch after round trip: %v", roundTrip.Metadata)
}
}
func TestLegacyEndpointConversionRejectsMultiple(t *testing.T) {
_, err := LegacyPaymentEndpointToEndpointDTO(&LegacyPaymentEndpoint{
Ledger: &LedgerEndpoint{LedgerAccountRef: "a"},
Card: &CardEndpoint{Pan: "t"},
})
if err == nil {
t.Fatalf("expected error when multiple legacy endpoints are set")
}
}
func TestEndpointUnmarshalLegacyShape(t *testing.T) {
raw := []byte(`{"ledger":{"ledger_account_ref":"abc"}}`)
var endpoint Endpoint
if err := json.Unmarshal(raw, &endpoint); err != nil {
t.Fatalf("unmarshal legacy shape: %v", err)
}
if endpoint.Type != EndpointTypeLedger {
t.Fatalf("expected type %s got %s", EndpointTypeLedger, endpoint.Type)
}
payload, err := endpoint.DecodeLedger()
if err != nil {
t.Fatalf("decode ledger from legacy shape: %v", err)
}
if payload.LedgerAccountRef != "abc" {
t.Fatalf("unexpected payload from legacy shape: %#v", payload)
}
}

Some files were not shown because too many files have changed in this diff Show More