204 Commits

Author SHA1 Message Date
Stephan D
bf85ca062c restucturization of recipients payment methods
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/nats 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
2025-12-04 14:42:25 +01:00
Arseni
3b04753f4e Revert "Merge branch 'devKA' into devka (resolve conflicts)"
Some checks are pending
ci/woodpecker/push/bump_version Pipeline is pending
ci/woodpecker/push/fx_oracle Pipeline is pending
ci/woodpecker/push/ledger 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 is running
This reverts commit 5f4184760d, reversing
changes made to 5e1da9617f.

Reverting changes on main
2025-12-04 15:38:01 +03:00
Arseni
5f4184760d Merge branch 'devKA' into devka (resolve conflicts)
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 failed
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/nats 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
ci/woodpecker/push/bump_version unknown status
2025-12-04 14:08:32 +03:00
Stephan D
5e1da9617f +address book service
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/nats 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
ci/woodpecker/push/bump_version Pipeline failed
2025-12-01 21:20:10 +01:00
Arseni
9c16e27645 Fixed issue with wallet form feild and made page selecor in dashboard into a router 2025-11-27 18:07:28 +03:00
Stephan D
c4d34c5663 added wallet management localizations
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/nats 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
ci/woodpecker/push/bump_version Pipeline failed
2025-11-26 23:25:31 +01:00
Stephan D
34420ca2fb +gas estimation
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/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/bump_version unknown status
ci/woodpecker/push/bff Pipeline failed
2025-11-26 23:19:29 +01:00
Stephan D
d16703197d onchain balance getter implementation
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/nats 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
ci/woodpecker/push/bump_version Pipeline failed
2025-11-26 22:25:15 +01:00
Stephan D
35897f9aa1 impreoved commands logging
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/nats 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
ci/woodpecker/push/bump_version Pipeline failed
2025-11-26 21:45:38 +01:00
Stephan D
f59ee55084 removed untranslated.txt from repo
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/nats 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
ci/woodpecker/push/bump_version Pipeline failed
2025-11-26 19:36:47 +01:00
Stephan D
8bf86c5c93 removed obsolete files
Some checks failed
ci/woodpecker/push/bump_version unknown status
ci/woodpecker/push/billing_fees 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/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
2025-11-26 19:35:35 +01:00
Stephan D
5e8ff2adb7 removed CORS restrictions
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/nats Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/bump_version unknown status
ci/woodpecker/push/payments_orchestrator Pipeline failed
2025-11-26 19:02:30 +01:00
Stephan D
da57b1d2e0 fixed providers initialization
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/nats 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
ci/woodpecker/push/bump_version Pipeline failed
2025-11-26 18:17:30 +01:00
2ef9ac24a1 Merge pull request 'Added Localizations and ran small fixes' (#6) from devKA 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/nats Pipeline was successful
ci/woodpecker/push/bump_version Pipeline failed
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #6
2025-11-26 14:00:56 +00:00
Stephan D
44446c6ad4 conflicts resolution 2025-11-26 15:00:21 +01:00
Arseni
357af99564 Added account permissions and ui for recipient 2025-11-26 13:03:52 +03:00
Stephan D
48ccbb1c82 implemented backend wallet service connection
Some checks failed
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/frontend 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/nats 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
ci/woodpecker/push/bump_version Pipeline failed
2025-11-26 00:48:00 +01:00
Stephan D
68f0a1048f implemented backend wallets/ledger accounts listing 2025-11-25 23:38:10 +01:00
Stephan D
be913bf96c + logout connected 2025-11-25 21:37:22 +01:00
Stephan D
8e1d4bef59 fixed account provider dependencies
Some checks failed
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/nats Pipeline was successful
ci/woodpecker/push/notification Pipeline failed
ci/woodpecker/push/payments_orchestrator Pipeline failed
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/bump_version unknown status
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
2025-11-25 19:25:07 +01:00
Stephan D
c6da138184 fixed account provider dependencies
Some checks failed
ci/woodpecker/push/fx_ingestor Pipeline failed
ci/woodpecker/push/bump_version unknown status
ci/woodpecker/push/fx_oracle 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/ledger 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/frontend Pipeline failed
2025-11-25 19:04:39 +01:00
Stephan D
d78619bccf improved logging
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/nats 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
ci/woodpecker/push/bump_version Pipeline failed
2025-11-25 18:30:53 +01:00
Stephan D
85b780b57e check other approach
Some checks failed
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/bump_version 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_oracle Pipeline was successful
ci/woodpecker/push/nats 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
2025-11-25 17:45:47 +01:00
Stephan D
1f31fedc3a excessive provider removed
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 was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/ledger 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/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/bump_version Pipeline failed
2025-11-25 12:40:39 +01:00
Stephan D
bdf3a01f80 fixed paths
Some checks 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/ledger Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/bff 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/bump_version Pipeline failed
2025-11-25 11:57:27 +01:00
Stephan D
d126d5d5de fixed service name for verfication
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/ledger Pipeline was successful
ci/woodpecker/push/payments_orchestrator 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/notification Pipeline was successful
ci/woodpecker/push/bump_version Pipeline failed
2025-11-25 11:06:40 +01:00
Arseni
fcb5ab4f2c Added Localizations and ran small fixes 2025-11-25 08:20:09 +03:00
Stephan D
26a1e284b2 Rewired login confirmation
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/nats 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
ci/woodpecker/push/bump_version Pipeline failed
2025-11-25 00:46:11 +01:00
Stephan D
fc0600d6c4 fix
Some checks failed
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_oracle Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/bump_version Pipeline failed
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
2025-11-24 23:16:13 +01:00
Stephan D
b855404999 new message type
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 failed
ci/woodpecker/push/nats 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
ci/woodpecker/push/bump_version unknown status
2025-11-24 21:49:02 +01:00
Stephan D
e3a8fb4f2d mail templates
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/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 failed
ci/woodpecker/push/bump_version unknown status
ci/woodpecker/push/chain_gateway Pipeline failed
2025-11-24 21:42:06 +01:00
Stephan D
d65e442cb6 + token verification
Some checks failed
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/billing_fees 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/nats 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
ci/woodpecker/push/bump_version unknown status
2025-11-24 20:53:42 +01:00
Stephan D
b4f6f63871 fixed mail server connection string
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/nats 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
ci/woodpecker/push/bump_version Pipeline failed
2025-11-24 19:44:55 +01:00
Stephan D
803683be7c fixed change order
Some checks failed
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/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/db Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline failed
ci/woodpecker/push/bump_version unknown status
ci/woodpecker/push/frontend Pipeline failed
2025-11-24 19:32:04 +01:00
Stephan D
72271cfc9a migration to replicaset connection
Some checks failed
ci/woodpecker/push/ledger 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 was successful
ci/woodpecker/push/fx_oracle Pipeline failed
ci/woodpecker/push/bump_version unknown status
ci/woodpecker/push/frontend Pipeline failed
2025-11-24 19:10:07 +01:00
Stephan D
cd79355e69 fixed org ref setting
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 was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/nats 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
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/bump_version Pipeline failed
2025-11-24 15:18:31 +01:00
Stephan D
3f84f8c609 fixed org creation org referencing
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/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/bump_version unknown status
ci/woodpecker/push/bff Pipeline failed
2025-11-24 15:11:47 +01:00
Stephan D
ae15e1887b better error checks
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/fx_ingestor Pipeline is pending
ci/woodpecker/push/fx_oracle Pipeline is pending
ci/woodpecker/push/ledger 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/frontend Pipeline failed
ci/woodpecker/push/bump_version unknown status
ci/woodpecker/push/chain_gateway Pipeline failed
2025-11-24 15:03:10 +01:00
Stephan D
8a41785b1d better logging in the pkg
Some checks failed
ci/woodpecker/push/billing_fees 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/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/bump_version Pipeline failed
2025-11-24 14:15:37 +01:00
Stephan D
56abc10dce version bump + loggins
Some checks failed
ci/woodpecker/push/fx_oracle Pipeline is pending
ci/woodpecker/push/ledger 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/bump_version unknown status
ci/woodpecker/push/frontend Pipeline failed
2025-11-24 13:57:50 +01:00
d8a3a5550d Merge pull request 'Multiple Wallet support, history of each wallet and updated payment page' (#5) from devKA 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/nats 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
ci/woodpecker/push/bump_version Pipeline failed
Reviewed-on: #5
2025-11-23 14:52:06 +00:00
Stephan D
72d8da1fe8 removed dev file 2025-11-23 15:50:46 +01:00
Stephan D
1fcd77cd95 fixes 2025-11-23 15:49:24 +01:00
Stephan D
f6a6be13d1 merge fixes 2025-11-23 15:45:10 +01:00
Stephan D
0b0d329b9b build script update
Some checks failed
ci/woodpecker/push/fx_oracle Pipeline is pending
ci/woodpecker/push/ledger 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/bump_version unknown status
ci/woodpecker/push/frontend Pipeline failed
2025-11-23 15:37:45 +01:00
Stephan D
d00d9275fe prod conf update
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/nats Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/bump_version Pipeline failed
ci/woodpecker/push/ledger Pipeline was successful
2025-11-21 22:56:36 +01:00
Arseni
775312d174 Fixed navigation 2025-11-21 21:09:32 +03:00
Stephan D
4d7827db61 prod setting for frontend
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/nats 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
ci/woodpecker/push/bump_version Pipeline failed
2025-11-21 18:05:01 +01:00
Arseni
acbab71a57 Multiple Wallet support, history of each wallet and updated payment page 2025-11-21 19:25:37 +03:00
Arseni
87636a7ec3 Multiple Wallet support, history of each wallet and updated payment page 2025-11-21 19:22:23 +03:00
Stephan D
e1e4c580e8 New code verification service
Some checks failed
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/billing_fees Pipeline was successful
ci/woodpecker/push/chain_gateway 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/ledger Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/bump_version Pipeline failed
2025-11-21 16:41:41 +01:00
Stephan D
ef5b3dc1a7 extended message set
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/nats 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
ci/woodpecker/push/bump_version Pipeline failed
2025-11-20 00:47:14 +01:00
Stephan D
36d1a94cf6 + call request
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/frontend Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/bump_version Pipeline failed
ci/woodpecker/push/nats 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
2025-11-19 22:19:27 +01:00
Stephan D
56d6c8caa6 fixed requests filtration
Some checks failed
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/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 failed
ci/woodpecker/push/fx_ingestor Pipeline failed
ci/woodpecker/push/bump_version unknown status
2025-11-19 22:04:27 +01:00
Stephan D
29f5a56f21 fixed notifications dispatch
Some checks failed
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/billing_fees Pipeline was successful
ci/woodpecker/push/chain_gateway 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/ledger Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/bump_version Pipeline failed
2025-11-19 20:15:36 +01:00
Stephan D
e08eb742e4 + contact requests
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/nats 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
ci/woodpecker/push/bump_version Pipeline failed
2025-11-19 14:42:38 +01:00
Stephan D
717dafc673 better message formatting
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 was successful
ci/woodpecker/push/bump_version Pipeline failed
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
2025-11-19 13:54:25 +01:00
Stephan D
62956b06ca better logging
Some checks failed
ci/woodpecker/push/fx_oracle Pipeline is pending
ci/woodpecker/push/ledger 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/bump_version unknown status
ci/woodpecker/push/frontend Pipeline failed
2025-11-19 13:32:40 +01:00
Stephan D
8710d7da53 cleanup
Some checks failed
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/bump_version unknown status
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 failed
ci/woodpecker/push/fx_oracle Pipeline failed
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
2025-11-19 12:47:45 +01:00
Stephan D
26a849e582 version bump
Some checks failed
ci/woodpecker/push/fx_oracle Pipeline is pending
ci/woodpecker/push/ledger 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 failed
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline failed
ci/woodpecker/push/bump_version unknown status
ci/woodpecker/push/frontend Pipeline failed
2025-11-19 12:34:44 +01:00
Stephan D
796e7b63b9 Config fix
Some checks failed
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/payments_orchestrator 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/ledger Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/bump_version 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
2025-11-18 19:31:08 +01:00
Stephan D
c3a4a3cc1b Config fix
Some checks failed
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline is running
ci/woodpecker/push/bff Pipeline failed
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/frontend Pipeline is running
ci/woodpecker/push/ledger 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_oracle Pipeline failed
ci/woodpecker/push/bump_version unknown status
ci/woodpecker/push/fx_ingestor Pipeline failed
2025-11-18 19:19:05 +01:00
Stephan D
020a40b90c Config fix
Some checks failed
ci/woodpecker/push/ledger 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/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor 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/frontend Pipeline failed
ci/woodpecker/push/bump_version unknown status
ci/woodpecker/push/fx_oracle Pipeline failed
2025-11-18 18:52:23 +01:00
Stephan D
515887d7f8 config fix
Some checks failed
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/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 failed
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline failed
ci/woodpecker/push/bump_version unknown status
ci/woodpecker/push/frontend Pipeline failed
2025-11-18 18:40:41 +01:00
Stephan D
71a67e1f6d removed trash
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/nats 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
ci/woodpecker/push/bump_version Pipeline failed
2025-11-18 17:33:01 +01:00
Stephan D
c5cdb7d2ae +caddy proxy fix +version bump fix 2025-11-18 17:32:23 +01:00
Stephan D
b12dbf07ea proxy app config fix
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/nats 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
ci/woodpecker/push/bump_version Pipeline failed
2025-11-18 10:33:09 +01:00
Stephan D
c8579a14f3 Caddy reconfigured
Some checks failed
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline failed
ci/woodpecker/push/frontend Pipeline failed
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline failed
ci/woodpecker/push/fx_oracle Pipeline failed
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/ledger Pipeline failed
ci/woodpecker/push/notification Pipeline failed
ci/woodpecker/push/payments_orchestrator Pipeline failed
ci/woodpecker/push/bump_version unknown status
2025-11-18 02:56:57 +01:00
Stephan D
e2e77fe409 +www site to cors
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/fx_oracle Pipeline is pending
ci/woodpecker/push/ledger 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/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline failed
ci/woodpecker/push/bump_version unknown status
ci/woodpecker/push/frontend Pipeline failed
2025-11-18 02:43:09 +01:00
Stephan D
920a7fc90a extended logging 2025-11-18 02:39:25 +01:00
Stephan D
848ff556d2 version bump 2025-11-18 02:39:17 +01:00
Stephan D
df81a93838 changed CORS permissions
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/fx_oracle Pipeline is pending
ci/woodpecker/push/ledger 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/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline failed
ci/woodpecker/push/bump_version unknown status
ci/woodpecker/push/frontend Pipeline failed
2025-11-18 02:25:50 +01:00
Stephan D
e1f6f10114 health check fix
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/nats 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
ci/woodpecker/push/bump_version Pipeline failed
2025-11-18 00:35:55 +01:00
Stephan D
922dad3dcd removed obsolete errors
Some checks failed
ci/woodpecker/push/fx_oracle Pipeline is pending
ci/woodpecker/push/ledger 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/chain_gateway 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/fx_ingestor Pipeline failed
ci/woodpecker/push/bump_version unknown status
ci/woodpecker/push/frontend Pipeline failed
2025-11-18 00:20:28 +01:00
Stephan D
363e9afc0a removed obsolete errors 2025-11-18 00:20:25 +01:00
Stephan D
ebb40c8e9b fixed chain address
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/nats 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
ci/woodpecker/push/bump_version Pipeline failed
2025-11-17 23:40:12 +01:00
Stephan D
9dbf77a9a8 +notification from site +version bump fix
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/bump_version Pipeline failed
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/nats 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
2025-11-17 22:20:17 +01:00
Stephan D
c6a56071b5 +signup +login
Some checks failed
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/billing_fees 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/nats 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
ci/woodpecker/push/bump_version Pipeline failed
2025-11-17 20:16:45 +01:00
Stephan D
1ab7f2e7d3 +signup +email availability check
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_oracle Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/bump_version Pipeline failed
ci/woodpecker/push/fx_ingestor 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
2025-11-17 18:00:38 +01:00
Stephan D
4c64a8d6e6 version bump
Some checks failed
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/bff Pipeline failed
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/db 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/notification Pipeline was successful
ci/woodpecker/push/bump_version unknown status
ci/woodpecker/push/payments_orchestrator Pipeline was successful
2025-11-15 01:18:50 +01:00
Stephan D
4c3bdc2ca6 build: +new fix
Some checks failed
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 was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/notification Pipeline failed
ci/woodpecker/push/bump_version unknown status
ci/woodpecker/push/ledger Pipeline failed
2025-11-15 01:06:23 +01:00
Stephan D
0d18da6a18 build: + frontend dependency 2025-11-15 01:05:20 +01:00
Stephan D
02d49f2502 front: fix proxy config 2025-11-15 00:50:40 +01:00
Stephan D
9a891fd523 deps version bump + frontend
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/nats 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
ci/woodpecker/push/bump_version Pipeline failed
2025-11-14 16:35:17 +01:00
Stephan D
975496ecbe deps version bump
Some checks failed
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/chain_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
ci/woodpecker/push/bump_version Pipeline failed
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
2025-11-14 15:46:24 +01:00
Stephan D
38158bec4d + version bump
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/ledger Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline failed
ci/woodpecker/push/notification Pipeline failed
ci/woodpecker/push/bump_version unknown status
2025-11-14 15:20:12 +01:00
Stephan D
cb69757aea + version bump
Some checks failed
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/bump_version 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/nats Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
2025-11-14 12:53:51 +01:00
Stephan D
c9b4bf7ceb + version bump
Some checks failed
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/bff Pipeline failed
ci/woodpecker/push/billing_fees Pipeline failed
ci/woodpecker/push/chain_gateway Pipeline failed
ci/woodpecker/push/fx_ingestor Pipeline failed
ci/woodpecker/push/fx_oracle Pipeline failed
ci/woodpecker/push/ledger Pipeline failed
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/notification Pipeline failed
ci/woodpecker/push/payments_orchestrator Pipeline failed
ci/woodpecker/push/bump_version unknown status
2025-11-14 12:44:51 +01:00
39e4aa5cb3 Merge pull request 'devKA' (#4) from devKA into main
All checks were successful
ci/woodpecker/push/fx/1 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/chain_gateway Pipeline was successful
ci/woodpecker/push/fx/2 Pipeline was successful
ci/woodpecker/push/ledger 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: #4
2025-11-14 04:09:19 +00:00
Arseni
aac6cd2b9b deletes ds_store files 2025-11-13 15:07:43 +03:00
Arseni
ddb54ddfdc Frontend first draft 2025-11-13 15:06:15 +03:00
Stephan D
96bb94d805 gitignore fix + missing storage management
All checks were successful
ci/woodpecker/push/bff 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/1 Pipeline was successful
ci/woodpecker/push/fx/2 Pipeline was successful
ci/woodpecker/push/nats 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
2025-11-11 22:41:17 +01:00
Stephan D
05652bdb41 +bff
Some checks failed
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 was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline failed
ci/woodpecker/push/fx/1 Pipeline was successful
ci/woodpecker/push/fx/2 Pipeline was successful
ci/woodpecker/push/nats Pipeline failed
ci/woodpecker/push/ledger Pipeline failed
2025-11-11 22:26:09 +01:00
Stephan D
45dcf1714f + missing file
Some checks failed
ci/woodpecker/push/fx/1 Pipeline was successful
ci/woodpecker/push/fx/2 Pipeline was successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline failed
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline failed
2025-11-11 22:04:01 +01:00
Stephan D
da600aa63d + chain gateway
Some checks failed
ci/woodpecker/push/ledger 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/db Pipeline was successful
ci/woodpecker/push/fx/1 Pipeline failed
ci/woodpecker/push/fx/2 Pipeline failed
2025-11-11 21:51:52 +01:00
Stephan D
92064dfbb4 fixed notification deps
Some checks failed
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/fx/1 Pipeline was successful
ci/woodpecker/push/fx/2 Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/notification Pipeline failed
ci/woodpecker/push/payments_orchestrator Pipeline was successful
2025-11-11 20:36:34 +01:00
Stephan D
fd56d3e8c4 versions bump
Some checks failed
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/fx/1 Pipeline was successful
ci/woodpecker/push/fx/2 Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/notification Pipeline failed
ci/woodpecker/push/payments_orchestrator Pipeline was successful
2025-11-11 20:33:56 +01:00
Stephan D
43a18e37a0 nootification build
Some checks failed
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/fx/1 Pipeline was successful
ci/woodpecker/push/fx/2 Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/notification Pipeline failed
ci/woodpecker/push/payments_orchestrator Pipeline was successful
2025-11-11 18:59:47 +01:00
Arseni
e47f343afb первый коммит 2025-11-11 18:24:58 +03:00
Stephan D
6f7ea2bf98 payment orchestrator build
All checks were successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/fx/1 Pipeline was successful
ci/woodpecker/push/fx/2 Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
2025-11-11 16:20:34 +01:00
Stephan D
40a3460e6d chain gateway build fix
All checks were successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/fx/1 Pipeline was successful
ci/woodpecker/push/fx/2 Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
2025-11-11 13:56:21 +01:00
Stephan D
052cfcea4c chain gateway build fix
All checks were successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/fx/1 Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/fx/2 Pipeline was successful
2025-11-11 13:09:33 +01:00
Stephan D
4cf7bc65ce chain gateway build fix
Some checks failed
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/billing_fees Pipeline failed
ci/woodpecker/push/fx/1 Pipeline failed
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/fx/2 Pipeline failed
2025-11-11 09:42:53 +01:00
Stephan D
4dfe6f8eac chain gateway build fix
Some checks failed
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/billing_fees Pipeline failed
ci/woodpecker/push/fx/1 Pipeline failed
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/fx/2 Pipeline failed
2025-11-11 09:26:00 +01:00
Stephan D
881ebaff5f chain gateway build fix
Some checks failed
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/billing_fees Pipeline failed
ci/woodpecker/push/fx/1 Pipeline failed
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/fx/2 Pipeline failed
2025-11-11 09:15:23 +01:00
Stephan D
d723047dd4 chain gateway build fix
Some checks failed
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/billing_fees Pipeline failed
ci/woodpecker/push/fx/1 Pipeline failed
ci/woodpecker/push/fx/2 Pipeline failed
ci/woodpecker/push/nats Pipeline was successful
2025-11-11 00:57:28 +01:00
Stephan D
a6da4d08bc chain gateway build fix
Some checks failed
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline failed
ci/woodpecker/push/fees Pipeline failed
ci/woodpecker/push/fx/1 Pipeline failed
ci/woodpecker/push/fx/2 Pipeline failed
ci/woodpecker/push/nats Pipeline was successful
2025-11-11 00:35:23 +01:00
Stephan D
c17af597d2 chain gateway build
Some checks failed
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline failed
ci/woodpecker/push/fees Pipeline was successful
ci/woodpecker/push/fx/1 Pipeline was successful
ci/woodpecker/push/fx/2 Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
2025-11-10 17:46:12 +01:00
Stephan D
b74ab81192 billing fees build
All checks were successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/fees Pipeline was successful
ci/woodpecker/push/fx/1 Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/fx/2 Pipeline was successful
2025-11-10 15:13:25 +01:00
Stephan D
751045fcc0 fx build fix
All checks were successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/fx/1 Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/fx/2 Pipeline was successful
2025-11-09 02:57:22 +01:00
Stephan D
54de5cf9d8 fx build fix
Some checks failed
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/fx/1 Pipeline failed
ci/woodpecker/push/fx/2 Pipeline failed
ci/woodpecker/push/nats Pipeline was successful
2025-11-09 02:52:09 +01:00
Stephan D
7df47cdc8e fx build fix
Some checks failed
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/fx/2 Pipeline failed
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/fx/1 Pipeline is running
2025-11-09 00:44:21 +01:00
Stephan D
64ef4fe0fe fx build fix
Some checks failed
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/fx/2 Pipeline failed
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/fx/1 Pipeline failed
2025-11-08 11:38:24 +01:00
Stephan D
88b2b63759 fx build fix
Some checks failed
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/fx/1 Pipeline failed
ci/woodpecker/push/fx/2 Pipeline failed
ci/woodpecker/push/nats Pipeline was successful
2025-11-08 11:23:49 +01:00
Stephan D
8810bc8783 fx build fix
Some checks failed
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/fx/1 Pipeline failed
ci/woodpecker/push/fx/2 Pipeline failed
ci/woodpecker/push/nats Pipeline was successful
2025-11-08 11:19:18 +01:00
Stephan D
be51fb7f8b fx build fix
Some checks failed
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/fx/1 Pipeline failed
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/fx/2 Pipeline failed
2025-11-08 03:45:32 +01:00
Stephan D
6f3d2b6769 fx build fix
Some checks failed
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/fx/1 Pipeline failed
ci/woodpecker/push/fx/2 Pipeline failed
ci/woodpecker/push/nats Pipeline was successful
2025-11-08 03:42:23 +01:00
Stephan D
0f8737a906 fx build fix
Some checks failed
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/fx/1 Pipeline failed
ci/woodpecker/push/fx/2 Pipeline failed
ci/woodpecker/push/nats Pipeline was successful
2025-11-08 03:40:49 +01:00
Stephan D
5c73106044 fx build fix
Some checks failed
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/fx/1 Pipeline failed
ci/woodpecker/push/fx/2 Pipeline failed
ci/woodpecker/push/nats Pipeline was successful
2025-11-08 03:34:12 +01:00
Stephan D
b6c795a0c8 fx build fix
Some checks failed
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/fx/1 Pipeline failed
ci/woodpecker/push/fx/2 Pipeline failed
ci/woodpecker/push/nats Pipeline was successful
2025-11-08 03:30:45 +01:00
Stephan D
677fb51a67 fx build fix
Some checks failed
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/fx/1 Pipeline failed
ci/woodpecker/push/fx/2 Pipeline failed
ci/woodpecker/push/nats Pipeline was successful
2025-11-08 03:27:14 +01:00
Stephan D
7b3ae24504 fx build fix
Some checks failed
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/fx/1 Pipeline failed
ci/woodpecker/push/fx/2 Pipeline failed
ci/woodpecker/push/nats Pipeline was successful
2025-11-08 03:21:57 +01:00
Stephan D
2f4938f304 fx build fix
Some checks failed
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/fx/1 Pipeline failed
ci/woodpecker/push/fx/2 Pipeline failed
ci/woodpecker/push/nats Pipeline was successful
2025-11-08 03:19:33 +01:00
Stephan D
1c10790149 fx build fix
Some checks failed
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/fx/1 Pipeline failed
ci/woodpecker/push/fx/2 Pipeline failed
ci/woodpecker/push/nats Pipeline was successful
2025-11-08 03:09:31 +01:00
Stephan D
36f7b785dd fx build fix
Some checks failed
ci/woodpecker/push/fx/1 Pipeline failed
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/fx/2 Pipeline failed
ci/woodpecker/push/nats Pipeline was successful
2025-11-08 03:07:40 +01:00
Stephan D
e2c370c00a fx build fix
Some checks failed
ci/woodpecker/push/fx/1 Pipeline failed
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/fx/2 Pipeline failed
ci/woodpecker/push/nats Pipeline failed
2025-11-08 03:06:35 +01:00
Stephan D
5602af501f fx build fix
Some checks failed
ci/woodpecker/push/nats Pipeline is pending
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/fx/2 Pipeline failed
ci/woodpecker/push/fx/1 Pipeline failed
2025-11-08 03:05:34 +01:00
Stephan D
74b8e02c3a fx build fix
Some checks failed
ci/woodpecker/push/fx/1 Pipeline failed
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/fx/2 Pipeline failed
ci/woodpecker/push/nats Pipeline was successful
2025-11-08 03:02:21 +01:00
Stephan D
8759b969b3 fx build fix
Some checks failed
ci/woodpecker/push/fx/1 Pipeline failed
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/fx/2 Pipeline failed
ci/woodpecker/push/nats Pipeline was successful
2025-11-08 03:00:55 +01:00
Stephan D
618f7286fe fx build fix
Some checks failed
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/fx/1 Pipeline failed
ci/woodpecker/push/fx/2 Pipeline failed
ci/woodpecker/push/nats Pipeline was successful
2025-11-08 02:58:43 +01:00
Stephan D
f9b7114a6d fx build fix
Some checks failed
ci/woodpecker/push/fx/1 Pipeline failed
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/nats Pipeline failed
ci/woodpecker/push/fx/2 Pipeline failed
2025-11-08 02:58:00 +01:00
Stephan D
d367dddbbd fx build fix
Some checks failed
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/fx/1 Pipeline failed
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/fx/2 Pipeline failed
2025-11-08 00:40:01 +01:00
Stephan D
49b86efecb fx build fix
Some checks failed
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/fx/1 Pipeline failed
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/fx/2 Pipeline failed
2025-11-08 00:30:29 +01:00
Stephan D
590fad0071 fx build fix
Some checks failed
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/fx/1 Pipeline failed
ci/woodpecker/push/fx/2 Pipeline failed
ci/woodpecker/push/nats Pipeline was successful
2025-11-07 23:55:41 +01:00
Stephan D
2ee17b0c46 fx build fix
Some checks failed
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/fx/1 Pipeline failed
ci/woodpecker/push/fx/2 Pipeline failed
ci/woodpecker/push/nats Pipeline was successful
2025-11-07 23:50:48 +01:00
Stephan D
0c0eeb27f8 build fix
Some checks failed
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/fx/1 Pipeline failed
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/fx/2 Pipeline failed
2025-11-07 23:04:57 +01:00
Stephan D
0e40af7559 build fix
Some checks failed
ci/woodpecker/push/fx/1 Pipeline failed
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/fx/2 Pipeline failed
ci/woodpecker/push/nats Pipeline was successful
2025-11-07 22:58:59 +01:00
Stephan D
1c4856f7cc build fix
Some checks failed
ci/woodpecker/push/fx/1 Pipeline failed
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/fx/2 Pipeline failed
ci/woodpecker/push/nats Pipeline was successful
2025-11-07 21:53:39 +01:00
Stephan D
19eadd1537 build fix
Some checks failed
ci/woodpecker/push/fx/1 Pipeline failed
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/fx/2 Pipeline failed
ci/woodpecker/push/nats Pipeline was successful
2025-11-07 21:34:48 +01:00
Stephan D
f102d69628 build fix
Some checks failed
ci/woodpecker/push/fx/1 Pipeline failed
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/fx/2 Pipeline failed
ci/woodpecker/push/nats Pipeline was successful
2025-11-07 20:49:19 +01:00
Stephan D
a5900232fc build fix
Some checks failed
ci/woodpecker/push/fx/1 Pipeline failed
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/fx/2 Pipeline failed
ci/woodpecker/push/nats Pipeline was successful
2025-11-07 20:47:40 +01:00
Stephan D
aca5a61cc8 build fix
Some checks failed
ci/woodpecker/push/fx/1 Pipeline failed
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/fx/2 Pipeline failed
ci/woodpecker/push/nats Pipeline was successful
2025-11-07 20:45:45 +01:00
Stephan D
cf98875299 build fix
Some checks failed
ci/woodpecker/push/fx/1 Pipeline failed
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/fx/2 Pipeline failed
ci/woodpecker/push/nats Pipeline was successful
2025-11-07 20:39:50 +01:00
Stephan D
6598ce5d3a build fix
Some checks failed
ci/woodpecker/push/fx/1 Pipeline failed
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/fx/2 Pipeline failed
ci/woodpecker/push/nats Pipeline was successful
2025-11-07 20:37:46 +01:00
Stephan D
2e23e647f8 build fix
Some checks failed
ci/woodpecker/push/fx/1 Pipeline failed
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/fx/2 Pipeline failed
ci/woodpecker/push/nats Pipeline was successful
2025-11-07 20:34:28 +01:00
Stephan D
36f48331b4 build fix
Some checks failed
ci/woodpecker/push/fx_ingestor Pipeline failed
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline failed
ci/woodpecker/push/nats Pipeline was successful
2025-11-07 20:18:16 +01:00
Stephan D
ec3019c462 + fx
Some checks failed
ci/woodpecker/push/fx_ingestor Pipeline failed
ci/woodpecker/push/fx_oracle Pipeline failed
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
2025-11-07 20:13:41 +01:00
Stephan D
62a6631b9a service backend
All checks were successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
2025-11-07 18:35:26 +01:00
Stephan D
20e8f9acc4 fixed nats healthcheck
All checks were successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
2025-11-07 16:31:50 +01:00
Stephan D
77aaea8b3d fixed nats healthcheck
All checks were successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
2025-11-07 16:19:32 +01:00
Stephan D
49cdd8cbee fixed nats healthcheck
Some checks failed
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/nats Pipeline failed
2025-11-07 16:07:31 +01:00
Stephan D
8b427e42c8 fixed nats healthcheck
Some checks failed
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/nats Pipeline failed
2025-11-07 16:02:06 +01:00
Stephan D
d1150dbf9d fixed nats healthcheck
Some checks failed
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/nats Pipeline failed
2025-11-07 16:00:39 +01:00
Stephan D
b79a6116d1 fixed nats healthcheck
Some checks failed
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/nats Pipeline failed
2025-11-07 15:57:10 +01:00
Stephan D
7bcc24a690 +project names
Some checks failed
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/nats Pipeline failed
2025-11-07 14:59:22 +01:00
Stephan D
dce6e2f618 fixed nats copy
Some checks failed
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/nats Pipeline failed
2025-11-07 14:53:29 +01:00
Stephan D
f69aeacae3 fixed pbm network
Some checks failed
ci/woodpecker/push/nats Pipeline failed
ci/woodpecker/push/db Pipeline was successful
2025-11-07 14:49:31 +01:00
Stephan D
e8f6f28880 +NATS
Some checks failed
ci/woodpecker/push/nats Pipeline failed
ci/woodpecker/push/db Pipeline failed
2025-11-07 14:45:05 +01:00
Stephan D
677017334a added pbm credentials insallation
All checks were successful
ci/woodpecker/push/db Pipeline was successful
2025-11-07 13:05:28 +01:00
Stephan D
cc92b2b1dd force recreate
All checks were successful
ci/woodpecker/push/db Pipeline was successful
2025-11-07 12:46:10 +01:00
Stephan D
0dfe32161a fixed password preparation
All checks were successful
ci/woodpecker/push/db Pipeline was successful
2025-11-07 12:41:22 +01:00
Stephan D
601fcebac5 removed manual permissions maangement
All checks were successful
ci/woodpecker/push/db Pipeline was successful
2025-11-07 12:02:58 +01:00
Stephan D
ae310632a1 fixed secrets path
Some checks failed
ci/woodpecker/push/db Pipeline failed
2025-11-07 11:36:54 +01:00
Stephan D
d17996973e missing dep
Some checks failed
ci/woodpecker/push/db Pipeline failed
2025-11-07 11:33:44 +01:00
Stephan D
ca76eb5bf9 vault app_role secrets pass
Some checks failed
ci/woodpecker/push/db Pipeline failed
2025-11-07 11:31:32 +01:00
Stephan D
0bb32ccabd vault app_role secrets pass
Some checks failed
ci/woodpecker/push/db Pipeline failed
2025-11-07 11:15:57 +01:00
Stephan D
385c98939a removed debug output 2025-11-07 11:03:18 +01:00
Stephan D
b9d356669a db deployment trace
Some checks failed
ci/woodpecker/push/db Pipeline failed
2025-11-07 10:58:09 +01:00
Stephan D
e70fc9567a db deployment trace
Some checks failed
ci/woodpecker/push/db Pipeline failed
2025-11-07 10:49:22 +01:00
Stephan D
5b58c07164 db deployment trace
Some checks failed
ci/woodpecker/push/db Pipeline failed
2025-11-07 10:43:39 +01:00
Stephan D
44b143e4c3 db deployment trace
Some checks failed
ci/woodpecker/push/db Pipeline failed
2025-11-07 10:35:48 +01:00
Stephan D
3b01d53c50 db deployment trace
All checks were successful
ci/woodpecker/push/db Pipeline was successful
2025-11-07 10:20:51 +01:00
Stephan D
9cd65b1eb1 removed db lock
All checks were successful
ci/woodpecker/push/db Pipeline was successful
2025-11-07 10:02:46 +01:00
Stephan D
9f5c1147dc fixed db lock
All checks were successful
ci/woodpecker/push/db Pipeline was successful
2025-11-07 03:16:20 +01:00
Stephan D
2cb14a57c4 fixed db lock
All checks were successful
ci/woodpecker/push/db Pipeline was successful
2025-11-07 03:13:08 +01:00
Stephan D
3f0275d006 fixed db lock
All checks were successful
ci/woodpecker/push/db Pipeline was successful
2025-11-07 03:06:45 +01:00
Stephan D
08f71ff583 fixed db lock
All checks were successful
ci/woodpecker/push/db Pipeline was successful
2025-11-07 03:00:39 +01:00
Stephan D
d68154f8ec fixed db lock
All checks were successful
ci/woodpecker/push/db Pipeline was successful
2025-11-07 02:57:23 +01:00
Stephan D
df4af6c1e8 version bump
All checks were successful
ci/woodpecker/push/db Pipeline was successful
2025-11-07 02:49:31 +01:00
Stephan D
dacd8a2115 fixed docker login
All checks were successful
ci/woodpecker/push/db Pipeline was successful
2025-11-07 02:38:09 +01:00
Stephan D
c8494da642 fixed env vars delivery
All checks were successful
ci/woodpecker/push/db Pipeline was successful
2025-11-07 02:30:47 +01:00
Stephan D
2335d2378f fixed env vars delivery
Some checks failed
ci/woodpecker/push/db Pipeline failed
2025-11-07 02:29:23 +01:00
Stephan D
eef4f01ea0 fixed env vars delivery
Some checks failed
ci/woodpecker/push/db Pipeline failed
2025-11-07 02:27:28 +01:00
Stephan D
5c5a7e14a1 fixed deployment docker file target
Some checks failed
ci/woodpecker/push/db Pipeline failed
2025-11-07 02:25:26 +01:00
Stephan D
3d7f5c4261 Added env to the remote mkdir -p list
Some checks failed
ci/woodpecker/push/db Pipeline failed
2025-11-07 02:18:28 +01:00
Stephan D
fcf4ad4d73 converted key to b64
Some checks failed
ci/woodpecker/push/db Pipeline failed
2025-11-07 02:11:23 +01:00
Stephan D
ecd43cb3cc converted key to b64
Some checks failed
ci/woodpecker/push/db Pipeline failed
2025-11-07 02:09:49 +01:00
Stephan D
86fcb9d82f fixed multiline secret handling
Some checks failed
ci/woodpecker/push/db Pipeline failed
2025-11-07 02:04:12 +01:00
Stephan D
b8c8ce7019 fixed path generation
Some checks failed
ci/woodpecker/push/db Pipeline failed
2025-11-07 01:52:38 +01:00
Stephan D
a2975ba5fb added missing bash
Some checks failed
ci/woodpecker/push/db Pipeline failed
2025-11-07 01:47:56 +01:00
Stephan D
6ea47a9c68 fixed script start
Some checks failed
ci/woodpecker/push/db Pipeline failed
2025-11-07 01:46:54 +01:00
Stephan D
035f6f74bc ensure folder target folder exists
Some checks failed
ci/woodpecker/push/db Pipeline failed
2025-11-07 01:44:39 +01:00
Stephan D
8cea7f005b reduced mongo image size
Some checks failed
ci/woodpecker/push/db Pipeline failed
2025-11-07 01:43:08 +01:00
Stephan D
2954dcde7b fixed docker registry access
Some checks failed
ci/woodpecker/push/db Pipeline failed
2025-11-07 01:32:31 +01:00
Stephan D
dbc77a7156 fixed vault paths
Some checks failed
ci/woodpecker/push/db Pipeline failed
2025-11-07 01:28:21 +01:00
Stephan D
29577230d2 migration to db deployment helper
Some checks failed
ci/woodpecker/push/db Pipeline failed
2025-11-07 01:27:19 +01:00
Stephan D
8de7c57c41 fixed package installation
Some checks failed
ci/woodpecker/push/db Pipeline failed
2025-11-07 01:21:17 +01:00
Stephan D
ed219040a9 fixed ssh_key access path
Some checks failed
ci/woodpecker/push/db Pipeline failed
2025-11-07 01:19:55 +01:00
Stephan D
34b3b089fc fixed registry access path
Some checks failed
ci/woodpecker/push/db Pipeline failed
2025-11-07 01:18:30 +01:00
Stephan D
7ad3939d77 + wp check
Some checks failed
ci/woodpecker/push/db Pipeline failed
2025-11-07 01:15:50 +01:00
Stephan D
bf3ba978d6 fixed secret id
Some checks failed
ci/woodpecker/push/db Pipeline failed
2025-11-07 01:09:45 +01:00
Stephan D
24bd7c82eb Wrapped the skopeo copy and skopeo inspect commands in folded scalars so the literal docker://… strings no longer confuse the YAML parser 2025-11-07 01:07:53 +01:00
b8287802cd Merge pull request 'first db deployment script' (#3) from db-#2 into main
Reviewed-on: #3 {fixes #2}
2025-11-07 00:00:38 +00:00
Stephan D
68707d5c62 first db deployment script 2025-11-07 00:59:08 +01:00
1476 changed files with 94446 additions and 0 deletions

11
.gitignore vendored Normal file
View File

@@ -0,0 +1,11 @@
.env.version
*.pb.go
*.pb.gw.go
pubspec.lock
.DS_Store
analysis_options.yaml
devtools_options.yaml
untranslated.txt
generate_protos.sh
update_dep.sh
.vscode/

74
.woodpecker/bff.yml Normal file
View File

@@ -0,0 +1,74 @@
matrix:
include:
- BFF_IMAGE_PATH: bff/service
BFF_DOCKERFILE: ci/prod/compose/bff.dockerfile
BFF_MONGO_SECRET_PATH: sendico/db
BFF_API_SECRET_PATH: sendico/api/endpoint
BFF_ENV: prod
when:
- event: push
branch: main
steps:
- name: version
image: alpine:latest
commands:
- set -euo pipefail 2>/dev/null || set -eu
- apk add --no-cache git
- GIT_REV="$(git rev-parse --short HEAD)"
- BUILD_BRANCH="$(git rev-parse --abbrev-ref HEAD)"
- APP_V="$(cat version)"
- BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
- BUILD_USER="${WOODPECKER_MACHINE:-woodpecker}"
- printf "GIT_REV=%s\nBUILD_BRANCH=%s\nAPP_V=%s\nBUILD_DATE=%s\nBUILD_USER=%s\n" \
"$GIT_REV" "$BUILD_BRANCH" "$APP_V" "$BUILD_DATE" "$BUILD_USER" | tee .env.version
- name: proto
image: golang:alpine
depends_on: [ version ]
commands:
- set -eu
- apk add --no-cache bash git build-base protoc protobuf-dev
- go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
- go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
- export PATH="$(go env GOPATH)/bin:$PATH"
- bash ci/scripts/proto/generate.sh
- name: secrets
image: alpine:latest
depends_on: [ version ]
environment:
VAULT_ADDR: { from_secret: VAULT_ADDR }
VAULT_ROLE_ID: { from_secret: VAULT_APP_ROLE }
VAULT_SECRET_ID: { from_secret: VAULT_SECRET_ID }
commands:
- set -euo pipefail
- apk add --no-cache bash coreutils openssh-keygen curl sed python3
- mkdir -p secrets
- ./ci/vlt kv_to_file kv ops/deploy/ssh_key private_b64 secrets/SSH_KEY.b64 600
- base64 -d secrets/SSH_KEY.b64 > secrets/SSH_KEY
- chmod 600 secrets/SSH_KEY
- ssh-keygen -y -f secrets/SSH_KEY >/dev/null
- ./ci/vlt kv_get kv registry user > secrets/REGISTRY_USER
- ./ci/vlt kv_get kv registry password > secrets/REGISTRY_PASSWORD
- name: build-image
image: gcr.io/kaniko-project/executor:debug
depends_on: [ proto, secrets ]
commands:
- sh ci/scripts/bff/build-image.sh
- name: deploy
image: alpine:latest
depends_on: [ secrets, build-image ]
environment:
VAULT_ADDR: { from_secret: VAULT_ADDR }
VAULT_ROLE_ID: { from_secret: VAULT_APP_ROLE }
VAULT_SECRET_ID: { from_secret: VAULT_SECRET_ID }
commands:
- set -euo pipefail
- apk add --no-cache bash openssh-client rsync coreutils curl sed python3
- mkdir -p /root/.ssh
- install -m 600 secrets/SSH_KEY /root/.ssh/id_rsa
- sh ci/scripts/bff/deploy.sh

View File

@@ -0,0 +1,73 @@
matrix:
include:
- FEES_IMAGE_PATH: billing/fees
FEES_DOCKERFILE: ci/prod/compose/billing_fees.dockerfile
FEES_MONGO_SECRET_PATH: sendico/db
FEES_ENV: prod
when:
- event: push
branch: main
steps:
- name: version
image: alpine:latest
commands:
- set -euo pipefail 2>/dev/null || set -eu
- apk add --no-cache git
- GIT_REV="$(git rev-parse --short HEAD)"
- BUILD_BRANCH="$(git rev-parse --abbrev-ref HEAD)"
- APP_V="$(cat version)"
- BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
- BUILD_USER="${WOODPECKER_MACHINE:-woodpecker}"
- printf "GIT_REV=%s\nBUILD_BRANCH=%s\nAPP_V=%s\nBUILD_DATE=%s\nBUILD_USER=%s\n" \
"$GIT_REV" "$BUILD_BRANCH" "$APP_V" "$BUILD_DATE" "$BUILD_USER" | tee .env.version
- name: proto
image: golang:alpine
depends_on: [ version ]
commands:
- set -eu
- apk add --no-cache bash git build-base protoc protobuf-dev
- go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
- go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
- export PATH="$(go env GOPATH)/bin:$PATH"
- bash ci/scripts/proto/generate.sh
- name: secrets
image: alpine:latest
depends_on: [ version ]
environment:
VAULT_ADDR: { from_secret: VAULT_ADDR }
VAULT_ROLE_ID: { from_secret: VAULT_APP_ROLE }
VAULT_SECRET_ID: { from_secret: VAULT_SECRET_ID }
commands:
- set -euo pipefail
- apk add --no-cache bash coreutils openssh-keygen curl sed python3
- mkdir -p secrets
- ./ci/vlt kv_to_file kv ops/deploy/ssh_key private_b64 secrets/SSH_KEY.b64 600
- base64 -d secrets/SSH_KEY.b64 > secrets/SSH_KEY
- chmod 600 secrets/SSH_KEY
- ssh-keygen -y -f secrets/SSH_KEY >/dev/null
- ./ci/vlt kv_get kv registry user > secrets/REGISTRY_USER
- ./ci/vlt kv_get kv registry password > secrets/REGISTRY_PASSWORD
- name: build-image
image: gcr.io/kaniko-project/executor:debug
depends_on: [ proto, secrets ]
commands:
- sh ci/scripts/billing_fees/build-image.sh
- name: deploy
image: alpine:latest
depends_on: [ secrets, build-image ]
environment:
VAULT_ADDR: { from_secret: VAULT_ADDR }
VAULT_ROLE_ID: { from_secret: VAULT_APP_ROLE }
VAULT_SECRET_ID: { from_secret: VAULT_SECRET_ID }
commands:
- set -euo pipefail
- apk add --no-cache bash openssh-client rsync coreutils curl sed python3
- mkdir -p /root/.ssh
- install -m 600 secrets/SSH_KEY /root/.ssh/id_rsa
- sh ci/scripts/billing_fees/deploy.sh

View File

@@ -0,0 +1,76 @@
matrix:
include:
- CHAIN_GATEWAY_IMAGE_PATH: gateway/chain
CHAIN_GATEWAY_DOCKERFILE: ci/prod/compose/chain_gateway.dockerfile
CHAIN_GATEWAY_MONGO_SECRET_PATH: sendico/db
CHAIN_GATEWAY_RPC_SECRET_PATH: sendico/gateway/chain
CHAIN_GATEWAY_WALLET_SECRET_PATH: sendico/gateway/chain/wallet
CHAIN_GATEWAY_VAULT_SECRET_PATH: sendico/gateway/chain/vault
CHAIN_GATEWAY_ENV: prod
when:
- event: push
branch: main
steps:
- name: version
image: alpine:latest
commands:
- set -euo pipefail 2>/dev/null || set -eu
- apk add --no-cache git
- GIT_REV="$(git rev-parse --short HEAD)"
- BUILD_BRANCH="$(git rev-parse --abbrev-ref HEAD)"
- APP_V="$(cat version)"
- BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
- BUILD_USER="${WOODPECKER_MACHINE:-woodpecker}"
- printf "GIT_REV=%s\nBUILD_BRANCH=%s\nAPP_V=%s\nBUILD_DATE=%s\nBUILD_USER=%s\n" \
"$GIT_REV" "$BUILD_BRANCH" "$APP_V" "$BUILD_DATE" "$BUILD_USER" | tee .env.version
- name: proto
image: golang:alpine
depends_on: [ version ]
commands:
- set -eu
- apk add --no-cache bash git build-base protoc protobuf-dev
- go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
- go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
- export PATH="$(go env GOPATH)/bin:$PATH"
- bash ci/scripts/proto/generate.sh
- name: secrets
image: alpine:latest
depends_on: [ version ]
environment:
VAULT_ADDR: { from_secret: VAULT_ADDR }
VAULT_ROLE_ID: { from_secret: VAULT_APP_ROLE }
VAULT_SECRET_ID: { from_secret: VAULT_SECRET_ID }
commands:
- set -euo pipefail
- apk add --no-cache bash coreutils openssh-keygen curl sed python3
- mkdir -p secrets
- ./ci/vlt kv_to_file kv ops/deploy/ssh_key private_b64 secrets/SSH_KEY.b64 600
- base64 -d secrets/SSH_KEY.b64 > secrets/SSH_KEY
- chmod 600 secrets/SSH_KEY
- ssh-keygen -y -f secrets/SSH_KEY >/dev/null
- ./ci/vlt kv_get kv registry user > secrets/REGISTRY_USER
- ./ci/vlt kv_get kv registry password > secrets/REGISTRY_PASSWORD
- name: build-image
image: gcr.io/kaniko-project/executor:debug
depends_on: [ proto, secrets ]
commands:
- sh ci/scripts/chain_gateway/build-image.sh
- name: deploy
image: alpine:latest
depends_on: [ secrets, build-image ]
environment:
VAULT_ADDR: { from_secret: VAULT_ADDR }
VAULT_ROLE_ID: { from_secret: VAULT_APP_ROLE }
VAULT_SECRET_ID: { from_secret: VAULT_SECRET_ID }
commands:
- set -euo pipefail
- apk add --no-cache bash openssh-client rsync coreutils curl sed python3
- mkdir -p /root/.ssh
- install -m 600 secrets/SSH_KEY /root/.ssh/id_rsa
- sh ci/scripts/chain_gateway/deploy.sh

52
.woodpecker/db.yml Normal file
View File

@@ -0,0 +1,52 @@
when:
- event: push
branch: main
steps:
- name: version
image: alpine:latest
commands:
- set -euo pipefail
- apk add --no-cache git
- GIT_REV="$(git rev-parse --short HEAD)"
- BUILD_BRANCH="$(git rev-parse --abbrev-ref HEAD)"
- APP_V="$(cat version)"
- printf "GIT_REV=%s\nBUILD_BRANCH=%s\nAPP_V=%s\n" "$GIT_REV" "$BUILD_BRANCH" "$APP_V" | tee .env.version
- name: secrets
image: alpine:latest
depends_on: [ version ]
environment:
# CI's own AppRole creds for accessing Vault to fetch the SSH key (existing names)
VAULT_ADDR: { from_secret: VAULT_ADDR }
VAULT_ROLE_ID: { from_secret: VAULT_APP_ROLE }
VAULT_SECRET_ID: { from_secret: VAULT_SECRET_ID }
commands:
- set -euo pipefail
- apk add --no-cache bash coreutils openssh-keygen curl sed python3
- mkdir -p secrets
# Retrieve SSH private key for deploy (existing helper)
- ./ci/vlt kv_to_file kv ops/deploy/ssh_key private_b64 secrets/SSH_KEY.b64 600
- base64 -d secrets/SSH_KEY.b64 > secrets/SSH_KEY
- chmod 600 secrets/SSH_KEY
- ssh-keygen -y -f secrets/SSH_KEY >/dev/null
- name: deploy
image: alpine:latest
depends_on: [ secrets ]
# Reuse the SAME Woodpecker secrets to pass AppRole to the Vault Agent at runtime
environment:
VAULT_ROLE_ID: { from_secret: VAULT_APP_ROLE }
VAULT_SECRET_ID: { from_secret: VAULT_SECRET_ID }
commands:
- set -euo pipefail
- apk add --no-cache bash openssh-client rsync coreutils
- mkdir -p /root/.ssh
- install -m 600 secrets/SSH_KEY /root/.ssh/id_rsa
- sed -i 's/\r$//' ./ci/prod/.env.runtime
- set -a
- . ./ci/prod/.env.runtime
- . ./.env.version
- set +a
- bash ci/prod/scripts/bootstrap/network.sh
- bash ci/prod/scripts/deploy/db.sh

61
.woodpecker/frontend.yml Normal file
View File

@@ -0,0 +1,61 @@
matrix:
include:
- FRONTEND_IMAGE_PATH: frontend/service
FRONTEND_DOCKERFILE: ci/prod/compose/frontend.dockerfile
FRONTEND_ENV: prod
when:
- event: push
branch: main
steps:
- name: version
image: alpine:latest
commands:
- set -euo pipefail 2>/dev/null || set -eu
- apk add --no-cache git
- GIT_REV="$(git rev-parse --short HEAD)"
- BUILD_BRANCH="$(git rev-parse --abbrev-ref HEAD)"
- APP_V="$(cat version)"
- BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
- BUILD_USER="${WOODPECKER_MACHINE:-woodpecker}"
- printf "GIT_REV=%s\nBUILD_BRANCH=%s\nAPP_V=%s\nBUILD_DATE=%s\nBUILD_USER=%s\n" \
"$GIT_REV" "$BUILD_BRANCH" "$APP_V" "$BUILD_DATE" "$BUILD_USER" | tee .env.version
- name: secrets
image: alpine:latest
depends_on: [ version ]
environment:
VAULT_ADDR: { from_secret: VAULT_ADDR }
VAULT_ROLE_ID: { from_secret: VAULT_APP_ROLE }
VAULT_SECRET_ID: { from_secret: VAULT_SECRET_ID }
commands:
- set -euo pipefail
- apk add --no-cache bash coreutils openssh-keygen curl sed python3
- mkdir -p secrets
- ./ci/vlt kv_to_file kv ops/deploy/ssh_key private_b64 secrets/SSH_KEY.b64 600
- base64 -d secrets/SSH_KEY.b64 > secrets/SSH_KEY
- chmod 600 secrets/SSH_KEY
- ssh-keygen -y -f secrets/SSH_KEY >/dev/null
- ./ci/vlt kv_get kv registry user > secrets/REGISTRY_USER
- ./ci/vlt kv_get kv registry password > secrets/REGISTRY_PASSWORD
- name: build-image
image: gcr.io/kaniko-project/executor:debug
depends_on: [ version, secrets ]
commands:
- sh ci/scripts/frontend/build-image.sh
- name: deploy
image: alpine:latest
depends_on: [ secrets, build-image ]
environment:
VAULT_ADDR: { from_secret: VAULT_ADDR }
VAULT_ROLE_ID: { from_secret: VAULT_APP_ROLE }
VAULT_SECRET_ID: { from_secret: VAULT_SECRET_ID }
commands:
- set -euo pipefail
- apk add --no-cache bash openssh-client rsync coreutils curl sed python3
- mkdir -p /root/.ssh
- install -m 600 secrets/SSH_KEY /root/.ssh/id_rsa
- sh ci/scripts/frontend/deploy.sh

View File

@@ -0,0 +1,77 @@
matrix:
include:
- FX_PIPELINE: fx_ingestor
FX_IMAGE_PATH: fx/ingestor
FX_DOCKERFILE: ci/prod/compose/fx_ingestor.dockerfile
FX_DEPLOY_TARGET: ingestor
FX_MONGO_SECRET_PATH: sendico/db
FX_NEEDS_NATS: "false"
FX_ENV: prod
when:
- event: push
branch: main
steps:
- name: version
image: alpine:latest
commands:
- set -euo pipefail 2>/dev/null || set -eu
- apk add --no-cache git
- GIT_REV="$(git rev-parse --short HEAD)"
- BUILD_BRANCH="$(git rev-parse --abbrev-ref HEAD)"
- APP_V="$(cat version)"
- BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
- BUILD_USER="${WOODPECKER_MACHINE:-woodpecker}"
- printf "GIT_REV=%s\nBUILD_BRANCH=%s\nAPP_V=%s\nBUILD_DATE=%s\nBUILD_USER=%s\n" \
"$GIT_REV" "$BUILD_BRANCH" "$APP_V" "$BUILD_DATE" "$BUILD_USER" | tee .env.version
- name: proto
image: golang:alpine
depends_on: [ version ]
commands:
- set -eu
# protoc + headers; protobuf runtime pkg is not needed for codegen
- apk add --no-cache bash git build-base protoc protobuf-dev
- go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
- go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
- export PATH="$(go env GOPATH)/bin:$PATH"
- bash ci/scripts/proto/generate.sh
- name: secrets
image: alpine:latest
depends_on: [ version ]
environment:
VAULT_ADDR: { from_secret: VAULT_ADDR }
VAULT_ROLE_ID: { from_secret: VAULT_APP_ROLE }
VAULT_SECRET_ID: { from_secret: VAULT_SECRET_ID }
commands:
- set -euo pipefail
- apk add --no-cache bash coreutils openssh-keygen curl sed python3
- mkdir -p secrets
- ./ci/vlt kv_to_file kv ops/deploy/ssh_key private_b64 secrets/SSH_KEY.b64 600
- base64 -d secrets/SSH_KEY.b64 > secrets/SSH_KEY
- chmod 600 secrets/SSH_KEY
- ssh-keygen -y -f secrets/SSH_KEY >/dev/null
- ./ci/vlt kv_get kv registry user > secrets/REGISTRY_USER
- ./ci/vlt kv_get kv registry password > secrets/REGISTRY_PASSWORD
- name: build-image
image: gcr.io/kaniko-project/executor:debug
depends_on: [ proto, secrets ]
commands:
- sh ci/scripts/fx/build-image.sh
- name: deploy
image: alpine:latest
depends_on: [ secrets, build-image ]
environment:
VAULT_ADDR: { from_secret: VAULT_ADDR }
VAULT_ROLE_ID: { from_secret: VAULT_APP_ROLE }
VAULT_SECRET_ID: { from_secret: VAULT_SECRET_ID }
commands:
- set -euo pipefail
- apk add --no-cache bash openssh-client rsync coreutils curl sed python3
- mkdir -p /root/.ssh
- install -m 600 secrets/SSH_KEY /root/.ssh/id_rsa
- sh ci/scripts/fx/deploy.sh

77
.woodpecker/fx_oracle.yml Normal file
View File

@@ -0,0 +1,77 @@
matrix:
include:
- FX_PIPELINE: fx_oracle
FX_IMAGE_PATH: fx/oracle
FX_DOCKERFILE: ci/prod/compose/fx_oracle.dockerfile
FX_DEPLOY_TARGET: oracle
FX_MONGO_SECRET_PATH: sendico/db
FX_NEEDS_NATS: "true"
FX_ENV: prod
when:
- event: push
branch: main
steps:
- name: version
image: alpine:latest
commands:
- set -euo pipefail 2>/dev/null || set -eu
- apk add --no-cache git
- GIT_REV="$(git rev-parse --short HEAD)"
- BUILD_BRANCH="$(git rev-parse --abbrev-ref HEAD)"
- APP_V="$(cat version)"
- BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
- BUILD_USER="${WOODPECKER_MACHINE:-woodpecker}"
- printf "GIT_REV=%s\nBUILD_BRANCH=%s\nAPP_V=%s\nBUILD_DATE=%s\nBUILD_USER=%s\n" \
"$GIT_REV" "$BUILD_BRANCH" "$APP_V" "$BUILD_DATE" "$BUILD_USER" | tee .env.version
- name: proto
image: golang:alpine
depends_on: [ version ]
commands:
- set -eu
# protoc + headers; protobuf runtime pkg is not needed for codegen
- apk add --no-cache bash git build-base protoc protobuf-dev
- go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
- go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
- export PATH="$(go env GOPATH)/bin:$PATH"
- bash ci/scripts/proto/generate.sh
- name: secrets
image: alpine:latest
depends_on: [ version ]
environment:
VAULT_ADDR: { from_secret: VAULT_ADDR }
VAULT_ROLE_ID: { from_secret: VAULT_APP_ROLE }
VAULT_SECRET_ID: { from_secret: VAULT_SECRET_ID }
commands:
- set -euo pipefail
- apk add --no-cache bash coreutils openssh-keygen curl sed python3
- mkdir -p secrets
- ./ci/vlt kv_to_file kv ops/deploy/ssh_key private_b64 secrets/SSH_KEY.b64 600
- base64 -d secrets/SSH_KEY.b64 > secrets/SSH_KEY
- chmod 600 secrets/SSH_KEY
- ssh-keygen -y -f secrets/SSH_KEY >/dev/null
- ./ci/vlt kv_get kv registry user > secrets/REGISTRY_USER
- ./ci/vlt kv_get kv registry password > secrets/REGISTRY_PASSWORD
- name: build-image
image: gcr.io/kaniko-project/executor:debug
depends_on: [ proto, secrets ]
commands:
- sh ci/scripts/fx/build-image.sh
- name: deploy
image: alpine:latest
depends_on: [ secrets, build-image ]
environment:
VAULT_ADDR: { from_secret: VAULT_ADDR }
VAULT_ROLE_ID: { from_secret: VAULT_APP_ROLE }
VAULT_SECRET_ID: { from_secret: VAULT_SECRET_ID }
commands:
- set -euo pipefail
- apk add --no-cache bash openssh-client rsync coreutils curl sed python3
- mkdir -p /root/.ssh
- install -m 600 secrets/SSH_KEY /root/.ssh/id_rsa
- sh ci/scripts/fx/deploy.sh

73
.woodpecker/ledger.yml Normal file
View File

@@ -0,0 +1,73 @@
matrix:
include:
- LEDGER_IMAGE_PATH: ledger/service
LEDGER_DOCKERFILE: ci/prod/compose/ledger.dockerfile
LEDGER_MONGO_SECRET_PATH: sendico/db
LEDGER_ENV: prod
when:
- event: push
branch: main
steps:
- name: version
image: alpine:latest
commands:
- set -euo pipefail 2>/dev/null || set -eu
- apk add --no-cache git
- GIT_REV="$(git rev-parse --short HEAD)"
- BUILD_BRANCH="$(git rev-parse --abbrev-ref HEAD)"
- APP_V="$(cat version)"
- BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
- BUILD_USER="${WOODPECKER_MACHINE:-woodpecker}"
- printf "GIT_REV=%s\nBUILD_BRANCH=%s\nAPP_V=%s\nBUILD_DATE=%s\nBUILD_USER=%s\n" \
"$GIT_REV" "$BUILD_BRANCH" "$APP_V" "$BUILD_DATE" "$BUILD_USER" | tee .env.version
- name: proto
image: golang:alpine
depends_on: [ version ]
commands:
- set -eu
- apk add --no-cache bash git build-base protoc protobuf-dev
- go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
- go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
- export PATH="$(go env GOPATH)/bin:$PATH"
- bash ci/scripts/proto/generate.sh
- name: secrets
image: alpine:latest
depends_on: [ version ]
environment:
VAULT_ADDR: { from_secret: VAULT_ADDR }
VAULT_ROLE_ID: { from_secret: VAULT_APP_ROLE }
VAULT_SECRET_ID: { from_secret: VAULT_SECRET_ID }
commands:
- set -euo pipefail
- apk add --no-cache bash coreutils openssh-keygen curl sed python3
- mkdir -p secrets
- ./ci/vlt kv_to_file kv ops/deploy/ssh_key private_b64 secrets/SSH_KEY.b64 600
- base64 -d secrets/SSH_KEY.b64 > secrets/SSH_KEY
- chmod 600 secrets/SSH_KEY
- ssh-keygen -y -f secrets/SSH_KEY >/dev/null
- ./ci/vlt kv_get kv registry user > secrets/REGISTRY_USER
- ./ci/vlt kv_get kv registry password > secrets/REGISTRY_PASSWORD
- name: build-image
image: gcr.io/kaniko-project/executor:debug
depends_on: [ proto, secrets ]
commands:
- sh ci/scripts/ledger/build-image.sh
- name: deploy
image: alpine:latest
depends_on: [ secrets, build-image ]
environment:
VAULT_ADDR: { from_secret: VAULT_ADDR }
VAULT_ROLE_ID: { from_secret: VAULT_APP_ROLE }
VAULT_SECRET_ID: { from_secret: VAULT_SECRET_ID }
commands:
- set -euo pipefail
- apk add --no-cache bash openssh-client rsync coreutils curl sed python3
- mkdir -p /root/.ssh
- install -m 600 secrets/SSH_KEY /root/.ssh/id_rsa
- sh ci/scripts/ledger/deploy.sh

52
.woodpecker/nats.yml Normal file
View File

@@ -0,0 +1,52 @@
when:
- event: push
branch: main
steps:
- name: version
image: alpine:latest
commands:
- set -euo pipefail
- apk add --no-cache git
- GIT_REV="$(git rev-parse --short HEAD)"
- BUILD_BRANCH="$(git rev-parse --abbrev-ref HEAD)"
- APP_V="$(cat version)"
- printf "GIT_REV=%s\nBUILD_BRANCH=%s\nAPP_V=%s\n" "$GIT_REV" "$BUILD_BRANCH" "$APP_V" | tee .env.version
- name: secrets
image: alpine:latest
depends_on: [ version ]
environment:
VAULT_ADDR: { from_secret: VAULT_ADDR }
VAULT_ROLE_ID: { from_secret: VAULT_APP_ROLE }
VAULT_SECRET_ID: { from_secret: VAULT_SECRET_ID }
commands:
- set -euo pipefail
- apk add --no-cache bash coreutils openssh-keygen curl sed python3
- mkdir -p secrets
- ./ci/vlt kv_to_file kv ops/deploy/ssh_key private_b64 secrets/SSH_KEY.b64 600
- base64 -d secrets/SSH_KEY.b64 > secrets/SSH_KEY
- chmod 600 secrets/SSH_KEY
- ssh-keygen -y -f secrets/SSH_KEY >/dev/null
- name: deploy
image: alpine:latest
depends_on: [ secrets ]
environment:
VAULT_ADDR: { from_secret: VAULT_ADDR }
VAULT_ROLE_ID: { from_secret: VAULT_APP_ROLE }
VAULT_SECRET_ID: { from_secret: VAULT_SECRET_ID }
commands:
- set -euo pipefail
- apk add --no-cache bash openssh-client rsync coreutils curl sed python3
- mkdir -p /root/.ssh
- install -m 600 secrets/SSH_KEY /root/.ssh/id_rsa
- sed -i 's/\r$//' ./ci/prod/.env.runtime
- set -a
- . ./ci/prod/.env.runtime
- . ./.env.version
- set +a
- export NATS_USER="$(./ci/vlt kv_get kv sendico/nats user)"
- export NATS_PASSWORD="$(./ci/vlt kv_get kv sendico/nats password)"
- bash ci/prod/scripts/bootstrap/network.sh
- bash ci/prod/scripts/deploy/nats.sh

View File

@@ -0,0 +1,76 @@
matrix:
include:
- NOTIFICATION_IMAGE_PATH: notification/service
NOTIFICATION_DOCKERFILE: ci/prod/compose/notification.dockerfile
NOTIFICATION_MONGO_SECRET_PATH: sendico/db
NOTIFICATION_MAIL_SECRET_PATH: sendico/notification/mail
NOTIFICATION_API_SECRET_PATH: sendico/api/endpoint
NOTIFICATION_TELEGRAM_SECRET_PATH: sendico/notification/telegram
NOTIFICATION_ENV: prod
when:
- event: push
branch: main
steps:
- name: version
image: alpine:latest
commands:
- set -euo pipefail 2>/dev/null || set -eu
- apk add --no-cache git
- GIT_REV="$(git rev-parse --short HEAD)"
- BUILD_BRANCH="$(git rev-parse --abbrev-ref HEAD)"
- APP_V="$(cat version)"
- BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
- BUILD_USER="${WOODPECKER_MACHINE:-woodpecker}"
- printf "GIT_REV=%s\nBUILD_BRANCH=%s\nAPP_V=%s\nBUILD_DATE=%s\nBUILD_USER=%s\n" \
"$GIT_REV" "$BUILD_BRANCH" "$APP_V" "$BUILD_DATE" "$BUILD_USER" | tee .env.version
- name: proto
image: golang:alpine
depends_on: [ version ]
commands:
- set -eu
- apk add --no-cache bash git build-base protoc protobuf-dev
- go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
- go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
- export PATH="$(go env GOPATH)/bin:$PATH"
- bash ci/scripts/proto/generate.sh
- name: secrets
image: alpine:latest
depends_on: [ version ]
environment:
VAULT_ADDR: { from_secret: VAULT_ADDR }
VAULT_ROLE_ID: { from_secret: VAULT_APP_ROLE }
VAULT_SECRET_ID: { from_secret: VAULT_SECRET_ID }
commands:
- set -euo pipefail
- apk add --no-cache bash coreutils openssh-keygen curl sed python3
- mkdir -p secrets
- ./ci/vlt kv_to_file kv ops/deploy/ssh_key private_b64 secrets/SSH_KEY.b64 600
- base64 -d secrets/SSH_KEY.b64 > secrets/SSH_KEY
- chmod 600 secrets/SSH_KEY
- ssh-keygen -y -f secrets/SSH_KEY >/dev/null
- ./ci/vlt kv_get kv registry user > secrets/REGISTRY_USER
- ./ci/vlt kv_get kv registry password > secrets/REGISTRY_PASSWORD
- name: build-image
image: gcr.io/kaniko-project/executor:debug
depends_on: [ proto, secrets ]
commands:
- sh ci/scripts/notification/build-image.sh
- name: deploy
image: alpine:latest
depends_on: [ secrets, build-image ]
environment:
VAULT_ADDR: { from_secret: VAULT_ADDR }
VAULT_ROLE_ID: { from_secret: VAULT_APP_ROLE }
VAULT_SECRET_ID: { from_secret: VAULT_SECRET_ID }
commands:
- set -euo pipefail
- apk add --no-cache bash openssh-client rsync coreutils curl sed python3
- mkdir -p /root/.ssh
- install -m 600 secrets/SSH_KEY /root/.ssh/id_rsa
- sh ci/scripts/notification/deploy.sh

View File

@@ -0,0 +1,73 @@
matrix:
include:
- PAYMENTS_IMAGE_PATH: payments/orchestrator
PAYMENTS_DOCKERFILE: ci/prod/compose/payments_orchestrator.dockerfile
PAYMENTS_MONGO_SECRET_PATH: sendico/db
PAYMENTS_ENV: prod
when:
- event: push
branch: main
steps:
- name: version
image: alpine:latest
commands:
- set -euo pipefail 2>/dev/null || set -eu
- apk add --no-cache git
- GIT_REV="$(git rev-parse --short HEAD)"
- BUILD_BRANCH="$(git rev-parse --abbrev-ref HEAD)"
- APP_V="$(cat version)"
- BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
- BUILD_USER="${WOODPECKER_MACHINE:-woodpecker}"
- printf "GIT_REV=%s\nBUILD_BRANCH=%s\nAPP_V=%s\nBUILD_DATE=%s\nBUILD_USER=%s\n" \
"$GIT_REV" "$BUILD_BRANCH" "$APP_V" "$BUILD_DATE" "$BUILD_USER" | tee .env.version
- name: proto
image: golang:alpine
depends_on: [ version ]
commands:
- set -eu
- apk add --no-cache bash git build-base protoc protobuf-dev
- go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
- go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
- export PATH="$(go env GOPATH)/bin:$PATH"
- bash ci/scripts/proto/generate.sh
- name: secrets
image: alpine:latest
depends_on: [ version ]
environment:
VAULT_ADDR: { from_secret: VAULT_ADDR }
VAULT_ROLE_ID: { from_secret: VAULT_APP_ROLE }
VAULT_SECRET_ID: { from_secret: VAULT_SECRET_ID }
commands:
- set -euo pipefail
- apk add --no-cache bash coreutils openssh-keygen curl sed python3
- mkdir -p secrets
- ./ci/vlt kv_to_file kv ops/deploy/ssh_key private_b64 secrets/SSH_KEY.b64 600
- base64 -d secrets/SSH_KEY.b64 > secrets/SSH_KEY
- chmod 600 secrets/SSH_KEY
- ssh-keygen -y -f secrets/SSH_KEY >/dev/null
- ./ci/vlt kv_get kv registry user > secrets/REGISTRY_USER
- ./ci/vlt kv_get kv registry password > secrets/REGISTRY_PASSWORD
- name: build-image
image: gcr.io/kaniko-project/executor:debug
depends_on: [ proto, secrets ]
commands:
- sh ci/scripts/payments_orchestrator/build-image.sh
- name: deploy
image: alpine:latest
depends_on: [ secrets, build-image ]
environment:
VAULT_ADDR: { from_secret: VAULT_ADDR }
VAULT_ROLE_ID: { from_secret: VAULT_APP_ROLE }
VAULT_SECRET_ID: { from_secret: VAULT_SECRET_ID }
commands:
- set -euo pipefail
- apk add --no-cache bash openssh-client rsync coreutils curl sed python3
- mkdir -p /root/.ssh
- install -m 600 secrets/SSH_KEY /root/.ssh/id_rsa
- sh ci/scripts/payments_orchestrator/deploy.sh

View File

@@ -0,0 +1,32 @@
# Config file for Air in TOML format
root = "./../.."
tmp_dir = "tmp"
[build]
cmd = "go build -o app -ldflags \"-X 'github.com/tech/sendico/billing/fees/internal/appversion.BuildUser=$(whoami)' -X 'github.com/tech/sendico/billing/fees/internal/appversion.Version=$APP_V' -X 'github.com/tech/sendico/billing/fees/internal/appversion.Branch=$BUILD_BRANCH' -X 'github.com/tech/sendico/billing/fees/internal/appversion.Revision=$GIT_REV' -X 'github.com/tech/sendico/billing/fees/internal/appversion.BuildDate=$(date)'\""
bin = "./app"
full_bin = "./app --debug --config.file=config.yml"
include_ext = ["go", "yaml", "yml"]
exclude_dir = ["billing/fees/tmp", "pkg/.git", "billing/fees/env"]
exclude_regex = ["_test\\.go"]
exclude_unchanged = true
follow_symlink = true
log = "air.log"
delay = 0
stop_on_error = true
send_interrupt = true
kill_delay = 500
args_bin = []
[log]
time = false
[color]
main = "magenta"
watcher = "cyan"
build = "yellow"
runner = "green"
[misc]
clean_on_exit = true

3
api/billing/fees/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
internal/generated
.gocache
/app

View File

@@ -0,0 +1,40 @@
runtime:
shutdown_timeout_seconds: 15
grpc:
network: tcp
address: ":50060"
enable_reflection: true
enable_health: true
metrics:
address: ":9402"
database:
driver: mongodb
settings:
host_env: FEES_MONGO_HOST
port_env: FEES_MONGO_PORT
database_env: FEES_MONGO_DATABASE
user_env: FEES_MONGO_USER
password_env: FEES_MONGO_PASSWORD
auth_source_env: FEES_MONGO_AUTH_SOURCE
replica_set_env: FEES_MONGO_REPLICA_SET
messaging:
driver: NATS
settings:
url_env: NATS_URL
host_env: NATS_HOST
port_env: NATS_PORT
username_env: NATS_USER
password_env: NATS_PASSWORD
broker_name: Billing Fees Service
max_reconnects: 10
reconnect_wait: 5
oracle:
address: "sendico_fx_oracle:50051"
dial_timeout_seconds: 5
call_timeout_seconds: 3
insecure: true

1
api/billing/fees/env/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
.env.api

54
api/billing/fees/go.mod Normal file
View File

@@ -0,0 +1,54 @@
module github.com/tech/sendico/billing/fees
go 1.25.3
replace github.com/tech/sendico/pkg => ../../pkg
replace github.com/tech/sendico/fx/oracle => ../../fx/oracle
require (
github.com/tech/sendico/fx/oracle v0.0.0
github.com/tech/sendico/pkg v0.1.0
go.mongodb.org/mongo-driver v1.17.6
go.uber.org/zap v1.27.1
google.golang.org/grpc v1.77.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
github.com/casbin/casbin/v2 v2.134.0 // indirect
github.com/casbin/govaluate v1.10.0 // indirect
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/go-chi/chi/v5 v5.2.3 // indirect
github.com/golang/snappy v1.0.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/compress v1.18.2 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/montanaflynn/stats v0.7.1 // 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/nkeys v0.4.12 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
github.com/prometheus/client_golang v1.23.2
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.4 // indirect
github.com/prometheus/procfs v0.19.2 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.2.0 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/protobuf v1.36.10
)

225
api/billing/fees/go.sum Normal file
View File

@@ -0,0 +1,225 @@
dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/casbin/casbin/v2 v2.134.0 h1:wyO3hZb487GzlGVAI2hUoHQT0ehFD+9B5P+HVG9BVTM=
github.com/casbin/casbin/v2 v2.134.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
github.com/casbin/mongodb-adapter/v3 v3.7.0 h1:w9c3bea1BGK4eZTAmk17JkY52yv/xSZDSHKji8q+z6E=
github.com/casbin/mongodb-adapter/v3 v3.7.0/go.mod h1:F1mu4ojoJVE/8VhIMxMedhjfwRDdIXgANYs6Sd0MgVA=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v27.3.1+incompatible h1:KttF0XoteNTicmUtBO0L2tP+J7FGRFTjaEF4k6WdhfI=
github.com/docker/docker v27.3.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54 h1:mFWunSatvkQQDhpdyuFAYwyAan3hzCuma+Pz8sqvOfg=
github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
github.com/moby/sys/user v0.3.0 h1:9ni5DlcW5an3SvRSx4MouotOygvzaXbaSrc/wGDFWPo=
github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE=
github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nats-io/nats.go v1.47.0 h1:YQdADw6J/UfGUd2Oy6tn4Hq6YHxCaJrVKayxxFqYrgM=
github.com/nats-io/nats.go v1.47.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/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc=
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/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
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/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/testcontainers/testcontainers-go v0.33.0 h1:zJS9PfXYT5O0ZFXM2xxXfk4J5UMw/kRiISng037Gxdw=
github.com/testcontainers/testcontainers-go v0.33.0/go.mod h1:W80YpTa8D5C3Yy16icheD01UTDu+LmXIA2Keo+jWtT8=
github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0 h1:iXVA84s5hKMS5gn01GWOYHE3ymy/2b+0YkpFeTxB2XY=
github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0/go.mod h1:R6tMjTojRiaoo89fh/hf7tOmfzohdqSU17R9DwSVSog=
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8=
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.mongodb.org/mongo-driver v1.17.6 h1:87JUG1wZfWsr6rIz3ZmpH90rL5tea7O3IHuSwHUpsss=
go.mongodb.org/mongo-driver v1.17.6/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 h1:UP6IpuHFkUgOQL9FFQFrZ+5LiwhhYRbi7VZSIx6Nj5s=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM=
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
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/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
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/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -0,0 +1,28 @@
package appversion
import (
"github.com/tech/sendico/pkg/version"
vf "github.com/tech/sendico/pkg/version/factory"
)
// Build information populated at build time.
var (
Version string
Revision string
Branch string
BuildUser string
BuildDate string
)
// Create initialises a version.Printer with the build details for this service.
func Create() version.Printer {
info := version.Info{
Program: "Sendico Billing Fees Service",
Revision: Revision,
Branch: Branch,
BuildUser: BuildUser,
BuildDate: BuildDate,
Version: Version,
}
return vf.Create(&info)
}

View File

@@ -0,0 +1,163 @@
package serverimp
import (
"context"
"os"
"strings"
"time"
"github.com/tech/sendico/billing/fees/internal/service/fees"
"github.com/tech/sendico/billing/fees/storage"
mongostorage "github.com/tech/sendico/billing/fees/storage/mongo"
oracleclient "github.com/tech/sendico/fx/oracle/client"
"github.com/tech/sendico/pkg/api/routers"
"github.com/tech/sendico/pkg/db"
msg "github.com/tech/sendico/pkg/messaging"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/server/grpcapp"
"go.uber.org/zap"
"gopkg.in/yaml.v3"
)
type Imp struct {
logger mlogger.Logger
file string
debug bool
config *config
app *grpcapp.App[storage.Repository]
oracleClient oracleclient.Client
}
type config struct {
*grpcapp.Config `yaml:",inline"`
Oracle OracleConfig `yaml:"oracle"`
}
type OracleConfig struct {
Address string `yaml:"address"`
DialTimeoutSecs int `yaml:"dial_timeout_seconds"`
CallTimeoutSecs int `yaml:"call_timeout_seconds"`
InsecureTransport bool `yaml:"insecure"`
}
func (c OracleConfig) dialTimeout() time.Duration {
if c.DialTimeoutSecs <= 0 {
return 5 * time.Second
}
return time.Duration(c.DialTimeoutSecs) * time.Second
}
func (c OracleConfig) callTimeout() time.Duration {
if c.CallTimeoutSecs <= 0 {
return 3 * time.Second
}
return time.Duration(c.CallTimeoutSecs) * time.Second
}
// Create initialises the billing fees server implementation.
func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) {
return &Imp{
logger: logger.Named("server"),
file: file,
debug: debug,
}, nil
}
func (i *Imp) Shutdown() {
if i.app == nil {
if i.oracleClient != nil {
_ = i.oracleClient.Close()
}
return
}
timeout := 15 * time.Second
if i.config != nil && i.config.Runtime != nil {
timeout = i.config.Runtime.ShutdownTimeout()
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
i.app.Shutdown(ctx)
cancel()
if i.oracleClient != nil {
_ = i.oracleClient.Close()
}
}
func (i *Imp) Start() error {
cfg, err := i.loadConfig()
if err != nil {
return err
}
i.config = cfg
repoFactory := func(logger mlogger.Logger, conn *db.MongoConnection) (storage.Repository, error) {
return mongostorage.New(logger, conn)
}
var oracleClient oracleclient.Client
if addr := strings.TrimSpace(cfg.Oracle.Address); addr != "" {
dialCtx, cancel := context.WithTimeout(context.Background(), cfg.Oracle.dialTimeout())
defer cancel()
oc, err := oracleclient.New(dialCtx, oracleclient.Config{
Address: addr,
DialTimeout: cfg.Oracle.dialTimeout(),
CallTimeout: cfg.Oracle.callTimeout(),
Insecure: cfg.Oracle.InsecureTransport,
})
if err != nil {
i.logger.Warn("failed to initialise oracle client", zap.String("address", addr), zap.Error(err))
} else {
oracleClient = oc
i.oracleClient = oc
i.logger.Info("connected to oracle service", zap.String("address", addr))
}
}
serviceFactory := func(logger mlogger.Logger, repo storage.Repository, producer msg.Producer) (grpcapp.Service, error) {
opts := []fees.Option{}
if oracleClient != nil {
opts = append(opts, fees.WithOracleClient(oracleClient))
}
return fees.NewService(logger, repo, producer, opts...), nil
}
app, err := grpcapp.NewApp(i.logger, "billing_fees", cfg.Config, i.debug, repoFactory, serviceFactory)
if err != nil {
return err
}
i.app = app
return i.app.Start()
}
func (i *Imp) loadConfig() (*config, error) {
data, err := os.ReadFile(i.file)
if err != nil {
i.logger.Error("Could not read configuration file", zap.String("config_file", i.file), zap.Error(err))
return nil, err
}
cfg := &config{Config: &grpcapp.Config{}}
if err := yaml.Unmarshal(data, cfg); err != nil {
i.logger.Error("Failed to parse configuration", zap.Error(err))
return nil, err
}
if cfg.Runtime == nil {
cfg.Runtime = &grpcapp.RuntimeConfig{ShutdownTimeoutSeconds: 15}
}
if cfg.GRPC == nil {
cfg.GRPC = &routers.GRPCConfig{
Network: "tcp",
Address: ":50060",
EnableReflection: true,
EnableHealth: true,
}
}
return cfg, nil
}

View File

@@ -0,0 +1,12 @@
package server
import (
serverimp "github.com/tech/sendico/billing/fees/internal/server/internal"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/server"
)
// Create constructs the billing fees server implementation.
func Create(logger mlogger.Logger, file string, debug bool) (server.Application, error) {
return serverimp.Create(logger, file, debug)
}

View File

@@ -0,0 +1,449 @@
package fees
import (
"context"
"errors"
"math/big"
"sort"
"strconv"
"strings"
"time"
"github.com/tech/sendico/billing/fees/storage/model"
oracleclient "github.com/tech/sendico/fx/oracle/client"
dmath "github.com/tech/sendico/pkg/decimal"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1"
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
"go.uber.org/zap"
)
// Calculator isolates fee rule evaluation logic so it can be reused and tested.
type Calculator interface {
Compute(ctx context.Context, plan *model.FeePlan, intent *feesv1.Intent, bookedAt time.Time, trace *tracev1.TraceContext) (*CalculationResult, error)
}
// CalculationResult contains derived fee lines and audit metadata.
type CalculationResult struct {
Lines []*feesv1.DerivedPostingLine
Applied []*feesv1.AppliedRule
FxUsed *feesv1.FXUsed
}
// quoteCalculator is the default Calculator implementation.
type fxOracle interface {
LatestRate(ctx context.Context, req oracleclient.LatestRateParams) (*oracleclient.RateSnapshot, error)
}
type quoteCalculator struct {
logger mlogger.Logger
oracle fxOracle
}
func newQuoteCalculator(logger mlogger.Logger, oracle fxOracle) Calculator {
return &quoteCalculator{
logger: logger.Named("calculator"),
oracle: oracle,
}
}
func (c *quoteCalculator) Compute(ctx context.Context, plan *model.FeePlan, intent *feesv1.Intent, bookedAt time.Time, _ *tracev1.TraceContext) (*CalculationResult, error) {
if plan == nil {
return nil, merrors.InvalidArgument("plan is required")
}
if intent == nil {
return nil, merrors.InvalidArgument("intent is required")
}
trigger := convertTrigger(intent.GetTrigger())
if trigger == model.TriggerUnspecified {
return nil, merrors.InvalidArgument("unsupported trigger")
}
baseAmount, err := dmath.RatFromString(intent.GetBaseAmount().GetAmount())
if err != nil {
return nil, merrors.InvalidArgument("invalid base amount")
}
if baseAmount.Sign() < 0 {
return nil, merrors.InvalidArgument("base amount cannot be negative")
}
baseScale := inferScale(intent.GetBaseAmount().GetAmount())
rules := make([]model.FeeRule, len(plan.Rules))
copy(rules, plan.Rules)
sort.SliceStable(rules, func(i, j int) bool {
if rules[i].Priority == rules[j].Priority {
return rules[i].RuleID < rules[j].RuleID
}
return rules[i].Priority < rules[j].Priority
})
lines := make([]*feesv1.DerivedPostingLine, 0, len(rules))
applied := make([]*feesv1.AppliedRule, 0, len(rules))
planID := ""
if planRef := plan.GetID(); planRef != nil && !planRef.IsZero() {
planID = planRef.Hex()
}
for _, rule := range rules {
if !shouldApplyRule(rule, trigger, intent.GetAttributes(), bookedAt) {
continue
}
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)
if calcErr != nil {
if !errors.Is(calcErr, merrors.ErrInvalidArg) {
c.logger.Warn("failed to calculate fee rule amount", zap.String("rule_id", rule.RuleID), zap.Error(calcErr))
}
continue
}
if amount.Sign() == 0 {
continue
}
currency := intent.GetBaseAmount().GetCurrency()
if override := strings.TrimSpace(rule.Currency); override != "" {
currency = override
}
entrySide := mapEntrySide(rule.EntrySide)
if entrySide == accountingv1.EntrySide_ENTRY_SIDE_UNSPECIFIED {
entrySide = accountingv1.EntrySide_ENTRY_SIDE_CREDIT
}
meta := map[string]string{
"fee_rule_id": rule.RuleID,
}
if planID != "" {
meta["fee_plan_id"] = planID
}
if rule.Metadata != nil {
if taxCode := strings.TrimSpace(rule.Metadata["tax_code"]); taxCode != "" {
meta["tax_code"] = taxCode
}
if taxRate := strings.TrimSpace(rule.Metadata["tax_rate"]); taxRate != "" {
meta["tax_rate"] = taxRate
}
}
lines = append(lines, &feesv1.DerivedPostingLine{
LedgerAccountRef: ledgerAccountRef,
Money: &moneyv1.Money{
Amount: dmath.FormatRat(amount, scale),
Currency: currency,
},
LineType: mapLineType(rule.LineType),
Side: entrySide,
Meta: meta,
})
applied = append(applied, &feesv1.AppliedRule{
RuleId: rule.RuleID,
RuleVersion: planID,
Formula: rule.Formula,
Rounding: mapRoundingMode(rule.Rounding),
TaxCode: metadataValue(rule.Metadata, "tax_code"),
TaxRate: metadataValue(rule.Metadata, "tax_rate"),
Parameters: cloneStringMap(rule.Metadata),
})
}
var fxUsed *feesv1.FXUsed
if trigger == model.TriggerFXConversion && c.oracle != nil {
fxUsed = c.buildFxUsed(ctx, intent)
}
return &CalculationResult{
Lines: lines,
Applied: applied,
FxUsed: fxUsed,
}, nil
}
func (c *quoteCalculator) calculateRuleAmount(baseAmount *big.Rat, baseScale uint32, rule model.FeeRule) (*big.Rat, uint32, error) {
scale, err := resolveRuleScale(rule, baseScale)
if err != nil {
return nil, 0, err
}
result := new(big.Rat)
if percentage := strings.TrimSpace(rule.Percentage); percentage != "" {
percentageRat, perr := dmath.RatFromString(percentage)
if perr != nil {
return nil, 0, merrors.InvalidArgument("invalid percentage")
}
result = dmath.AddRat(result, dmath.MulRat(baseAmount, percentageRat))
}
if fixed := strings.TrimSpace(rule.FixedAmount); fixed != "" {
fixedRat, ferr := dmath.RatFromString(fixed)
if ferr != nil {
return nil, 0, merrors.InvalidArgument("invalid fixed amount")
}
result = dmath.AddRat(result, fixedRat)
}
if minStr := strings.TrimSpace(rule.MinimumAmount); minStr != "" {
minRat, merr := dmath.RatFromString(minStr)
if merr != nil {
return nil, 0, merrors.InvalidArgument("invalid minimum amount")
}
if dmath.CmpRat(result, minRat) < 0 {
result = new(big.Rat).Set(minRat)
}
}
if maxStr := strings.TrimSpace(rule.MaximumAmount); maxStr != "" {
maxRat, merr := dmath.RatFromString(maxStr)
if merr != nil {
return nil, 0, merrors.InvalidArgument("invalid maximum amount")
}
if dmath.CmpRat(result, maxRat) > 0 {
result = new(big.Rat).Set(maxRat)
}
}
if result.Sign() < 0 {
result = new(big.Rat).Abs(result)
}
rounded, rerr := dmath.RoundRatToScale(result, scale, toDecimalRounding(rule.Rounding))
if rerr != nil {
return nil, 0, rerr
}
return rounded, scale, nil
}
const (
attrFxBaseCurrency = "fx_base_currency"
attrFxQuoteCurrency = "fx_quote_currency"
attrFxProvider = "fx_provider"
attrFxSide = "fx_side"
attrFxRateOverride = "fx_rate"
)
func (c *quoteCalculator) buildFxUsed(ctx context.Context, intent *feesv1.Intent) *feesv1.FXUsed {
if intent == nil || c.oracle == nil {
return nil
}
attrs := intent.GetAttributes()
base := strings.TrimSpace(attrs[attrFxBaseCurrency])
quote := strings.TrimSpace(attrs[attrFxQuoteCurrency])
if base == "" || quote == "" {
return nil
}
pair := &fxv1.CurrencyPair{Base: base, Quote: quote}
provider := strings.TrimSpace(attrs[attrFxProvider])
snapshot, err := c.oracle.LatestRate(ctx, oracleclient.LatestRateParams{
Meta: oracleclient.RequestMeta{},
Pair: pair,
Provider: provider,
})
if err != nil {
c.logger.Warn("fees: failed to fetch FX context", zap.Error(err))
return nil
}
if snapshot == nil {
return nil
}
rateValue := strings.TrimSpace(attrs[attrFxRateOverride])
if rateValue == "" {
rateValue = snapshot.Mid
}
if rateValue == "" {
rateValue = snapshot.Ask
}
if rateValue == "" {
rateValue = snapshot.Bid
}
return &feesv1.FXUsed{
Pair: pair,
Side: parseFxSide(strings.TrimSpace(attrs[attrFxSide])),
Rate: &moneyv1.Decimal{Value: rateValue},
AsofUnixMs: snapshot.AsOf.UnixMilli(),
Provider: snapshot.Provider,
RateRef: snapshot.RateRef,
SpreadBps: &moneyv1.Decimal{Value: snapshot.SpreadBps},
}
}
func parseFxSide(value string) fxv1.Side {
switch strings.ToLower(value) {
case "buy_base", "buy_base_sell_quote", "buy":
return fxv1.Side_BUY_BASE_SELL_QUOTE
case "sell_base", "sell_base_buy_quote", "sell":
return fxv1.Side_SELL_BASE_BUY_QUOTE
default:
return fxv1.Side_SIDE_UNSPECIFIED
}
}
func inferScale(amount string) uint32 {
value := strings.TrimSpace(amount)
if value == "" {
return 0
}
if idx := strings.IndexAny(value, "eE"); idx >= 0 {
value = value[:idx]
}
if strings.HasPrefix(value, "+") || strings.HasPrefix(value, "-") {
value = value[1:]
}
if dot := strings.IndexByte(value, '.'); dot >= 0 {
return uint32(len(value[dot+1:]))
}
return 0
}
func shouldApplyRule(rule model.FeeRule, trigger model.Trigger, attributes map[string]string, bookedAt time.Time) bool {
if rule.Trigger != trigger {
return false
}
if rule.EffectiveFrom.After(bookedAt) {
return false
}
if rule.EffectiveTo != nil && rule.EffectiveTo.Before(bookedAt) {
return false
}
return ruleMatchesAttributes(rule, attributes)
}
func resolveRuleScale(rule model.FeeRule, fallback uint32) (uint32, error) {
if rule.Metadata != nil {
for _, field := range []string{"scale", "decimals", "precision"} {
if value, ok := rule.Metadata[field]; ok && strings.TrimSpace(value) != "" {
return parseScale(field, value)
}
}
}
return fallback, nil
}
func parseScale(field, value string) (uint32, error) {
clean := strings.TrimSpace(value)
if clean == "" {
return 0, merrors.InvalidArgument(field + " is empty")
}
parsed, err := strconv.ParseUint(clean, 10, 32)
if err != nil {
return 0, merrors.InvalidArgument("invalid " + field + " value")
}
return uint32(parsed), nil
}
func metadataValue(meta map[string]string, key string) string {
if meta == nil {
return ""
}
return strings.TrimSpace(meta[key])
}
func cloneStringMap(src map[string]string) map[string]string {
if len(src) == 0 {
return nil
}
cloned := make(map[string]string, len(src))
for k, v := range src {
cloned[k] = v
}
return cloned
}
func ruleMatchesAttributes(rule model.FeeRule, attributes map[string]string) bool {
if len(rule.AppliesTo) == 0 {
return true
}
for key, value := range rule.AppliesTo {
if attributes == nil {
return false
}
if attrValue, ok := attributes[key]; !ok || attrValue != value {
return false
}
}
return true
}
func convertTrigger(trigger feesv1.Trigger) model.Trigger {
switch trigger {
case feesv1.Trigger_TRIGGER_CAPTURE:
return model.TriggerCapture
case feesv1.Trigger_TRIGGER_REFUND:
return model.TriggerRefund
case feesv1.Trigger_TRIGGER_DISPUTE:
return model.TriggerDispute
case feesv1.Trigger_TRIGGER_PAYOUT:
return model.TriggerPayout
case feesv1.Trigger_TRIGGER_FX_CONVERSION:
return model.TriggerFXConversion
default:
return model.TriggerUnspecified
}
}
func mapLineType(lineType string) accountingv1.PostingLineType {
switch strings.ToLower(lineType) {
case "tax":
return accountingv1.PostingLineType_POSTING_LINE_TAX
case "spread":
return accountingv1.PostingLineType_POSTING_LINE_SPREAD
case "reversal":
return accountingv1.PostingLineType_POSTING_LINE_REVERSAL
default:
return accountingv1.PostingLineType_POSTING_LINE_FEE
}
}
func mapEntrySide(entrySide string) accountingv1.EntrySide {
switch strings.ToLower(entrySide) {
case "debit":
return accountingv1.EntrySide_ENTRY_SIDE_DEBIT
case "credit":
return accountingv1.EntrySide_ENTRY_SIDE_CREDIT
default:
return accountingv1.EntrySide_ENTRY_SIDE_UNSPECIFIED
}
}
func toDecimalRounding(mode string) dmath.RoundingMode {
switch strings.ToLower(strings.TrimSpace(mode)) {
case "half_up":
return dmath.RoundingModeHalfUp
case "down":
return dmath.RoundingModeDown
case "half_even", "bankers":
return dmath.RoundingModeHalfEven
default:
return dmath.RoundingModeHalfEven
}
}
func mapRoundingMode(mode string) moneyv1.RoundingMode {
switch strings.ToLower(mode) {
case "half_up":
return moneyv1.RoundingMode_ROUND_HALF_UP
case "down":
return moneyv1.RoundingMode_ROUND_DOWN
default:
return moneyv1.RoundingMode_ROUND_HALF_EVEN
}
}

View File

@@ -0,0 +1,71 @@
package fees
import (
"strconv"
"strings"
"sync"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
var (
metricsOnce sync.Once
quoteRequestsTotal *prometheus.CounterVec
quoteLatency *prometheus.HistogramVec
)
func initMetrics() {
metricsOnce.Do(func() {
quoteRequestsTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Namespace: "billing",
Subsystem: "fees",
Name: "requests_total",
Help: "Total number of fee service requests processed.",
},
[]string{"call", "trigger", "status", "fx_used"},
)
quoteLatency = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Namespace: "billing",
Subsystem: "fees",
Name: "request_latency_seconds",
Help: "Latency of fee service requests.",
Buckets: prometheus.DefBuckets,
},
[]string{"call", "trigger", "status", "fx_used"},
)
})
}
func observeMetrics(call string, trigger feesv1.Trigger, statusLabel string, fxUsed bool, took time.Duration) {
triggerLabel := trigger.String()
if trigger == feesv1.Trigger_TRIGGER_UNSPECIFIED {
triggerLabel = "TRIGGER_UNSPECIFIED"
}
fxLabel := strconv.FormatBool(fxUsed)
quoteRequestsTotal.WithLabelValues(call, triggerLabel, statusLabel, fxLabel).Inc()
quoteLatency.WithLabelValues(call, triggerLabel, statusLabel, fxLabel).Observe(took.Seconds())
}
func statusFromError(err error) string {
if err == nil {
return "success"
}
st, ok := status.FromError(err)
if !ok {
return "error"
}
code := st.Code()
if code == codes.OK {
return "success"
}
return strings.ToLower(code.String())
}

View File

@@ -0,0 +1,37 @@
package fees
import (
oracleclient "github.com/tech/sendico/fx/oracle/client"
clockpkg "github.com/tech/sendico/pkg/clock"
)
// Option configures a Service instance.
type Option func(*Service)
// WithClock sets a custom clock implementation.
func WithClock(clock clockpkg.Clock) Option {
return func(s *Service) {
if clock != nil {
s.clock = clock
}
}
}
// WithCalculator sets a custom calculator implementation.
func WithCalculator(calculator Calculator) Option {
return func(s *Service) {
if calculator != nil {
s.calculator = calculator
}
}
}
// WithOracleClient wires an FX oracle client for FX trigger evaluations.
func WithOracleClient(oracle oracleclient.Client) Option {
return func(s *Service) {
s.oracle = oracle
if qc, ok := s.calculator.(*quoteCalculator); ok {
qc.oracle = oracle
}
}
}

View File

@@ -0,0 +1,322 @@
package fees
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"strings"
"time"
"github.com/tech/sendico/billing/fees/storage"
oracleclient "github.com/tech/sendico/fx/oracle/client"
"github.com/tech/sendico/pkg/api/routers"
clockpkg "github.com/tech/sendico/pkg/clock"
"github.com/tech/sendico/pkg/merrors"
msg "github.com/tech/sendico/pkg/messaging"
"github.com/tech/sendico/pkg/mlogger"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.uber.org/zap"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb"
)
type Service struct {
logger mlogger.Logger
storage storage.Repository
producer msg.Producer
clock clockpkg.Clock
calculator Calculator
oracle oracleclient.Client
feesv1.UnimplementedFeeEngineServer
}
func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Producer, opts ...Option) *Service {
svc := &Service{
logger: logger.Named("fees"),
storage: repo,
producer: producer,
clock: clockpkg.NewSystem(),
}
initMetrics()
for _, opt := range opts {
opt(svc)
}
if svc.clock == nil {
svc.clock = clockpkg.NewSystem()
}
if svc.calculator == nil {
svc.calculator = newQuoteCalculator(svc.logger, svc.oracle)
}
return svc
}
func (s *Service) Register(router routers.GRPC) error {
return router.Register(func(reg grpc.ServiceRegistrar) {
feesv1.RegisterFeeEngineServer(reg, s)
})
}
func (s *Service) QuoteFees(ctx context.Context, req *feesv1.QuoteFeesRequest) (resp *feesv1.QuoteFeesResponse, err error) {
start := s.clock.Now()
trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED
if req != nil && req.GetIntent() != nil {
trigger = req.GetIntent().GetTrigger()
}
var fxUsed bool
defer func() {
statusLabel := statusFromError(err)
if err == nil && resp != nil {
fxUsed = resp.GetFxUsed() != nil
}
observeMetrics("quote", trigger, statusLabel, fxUsed, time.Since(start))
}()
if err = s.validateQuoteRequest(req); err != nil {
return nil, err
}
orgRef, parseErr := primitive.ObjectIDFromHex(req.GetMeta().GetOrganizationRef())
if parseErr != nil {
err = status.Error(codes.InvalidArgument, "invalid organization_ref")
return nil, err
}
lines, applied, fx, computeErr := s.computeQuote(ctx, orgRef, req.GetIntent(), req.GetPolicy(), req.GetMeta().GetTrace())
if computeErr != nil {
err = computeErr
return nil, err
}
resp = &feesv1.QuoteFeesResponse{
Meta: &feesv1.ResponseMeta{Trace: req.GetMeta().GetTrace()},
Lines: lines,
Applied: applied,
FxUsed: fx,
}
return resp, nil
}
func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFeesRequest) (resp *feesv1.PrecomputeFeesResponse, err error) {
start := s.clock.Now()
trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED
if req != nil && req.GetIntent() != nil {
trigger = req.GetIntent().GetTrigger()
}
var fxUsed bool
defer func() {
statusLabel := statusFromError(err)
if err == nil && resp != nil {
fxUsed = resp.GetFxUsed() != nil
}
observeMetrics("precompute", trigger, statusLabel, fxUsed, time.Since(start))
}()
if err = s.validatePrecomputeRequest(req); err != nil {
return nil, err
}
now := s.clock.Now()
orgRef, parseErr := primitive.ObjectIDFromHex(req.GetMeta().GetOrganizationRef())
if parseErr != nil {
err = status.Error(codes.InvalidArgument, "invalid organization_ref")
return nil, err
}
lines, applied, fx, computeErr := s.computeQuoteWithTime(ctx, orgRef, req.GetIntent(), nil, req.GetMeta().GetTrace(), now)
if computeErr != nil {
err = computeErr
return nil, err
}
ttl := req.GetTtlMs()
if ttl <= 0 {
ttl = 60000
}
expiresAt := now.Add(time.Duration(ttl) * time.Millisecond)
payload := feeQuoteTokenPayload{
OrganizationRef: req.GetMeta().GetOrganizationRef(),
Intent: req.GetIntent(),
ExpiresAtUnixMs: expiresAt.UnixMilli(),
Trace: req.GetMeta().GetTrace(),
}
var token string
if token, err = encodeTokenPayload(payload); err != nil {
s.logger.Warn("failed to encode fee quote token", zap.Error(err))
err = status.Error(codes.Internal, "failed to encode fee quote token")
return nil, err
}
resp = &feesv1.PrecomputeFeesResponse{
Meta: &feesv1.ResponseMeta{Trace: req.GetMeta().GetTrace()},
FeeQuoteToken: token,
ExpiresAt: timestamppb.New(expiresAt),
Lines: lines,
Applied: applied,
FxUsed: fx,
}
return resp, nil
}
func (s *Service) ValidateFeeToken(ctx context.Context, req *feesv1.ValidateFeeTokenRequest) (resp *feesv1.ValidateFeeTokenResponse, err error) {
start := s.clock.Now()
trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED
var fxUsed bool
defer func() {
statusLabel := statusFromError(err)
if err == nil && resp != nil {
if !resp.GetValid() {
statusLabel = "invalid"
}
fxUsed = resp.GetFxUsed() != nil
if resp.GetIntent() != nil {
trigger = resp.GetIntent().GetTrigger()
}
}
observeMetrics("validate", trigger, statusLabel, fxUsed, time.Since(start))
}()
if req == nil || strings.TrimSpace(req.GetFeeQuoteToken()) == "" {
err = status.Error(codes.InvalidArgument, "fee_quote_token is required")
return nil, err
}
now := s.clock.Now()
payload, decodeErr := decodeTokenPayload(req.GetFeeQuoteToken())
if decodeErr != nil {
s.logger.Warn("failed to decode fee quote token", zap.Error(decodeErr))
resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "invalid_token"}
return resp, nil
}
trigger = payload.Intent.GetTrigger()
if now.UnixMilli() > payload.ExpiresAtUnixMs {
resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "expired"}
return resp, nil
}
orgRef, parseErr := primitive.ObjectIDFromHex(payload.OrganizationRef)
if parseErr != nil {
s.logger.Warn("token contained invalid organization reference", zap.Error(parseErr))
resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "invalid_token"}
return resp, nil
}
lines, applied, fx, computeErr := s.computeQuoteWithTime(ctx, orgRef, payload.Intent, nil, payload.Trace, now)
if computeErr != nil {
err = computeErr
return nil, err
}
resp = &feesv1.ValidateFeeTokenResponse{
Meta: &feesv1.ResponseMeta{Trace: payload.Trace},
Valid: true,
Intent: payload.Intent,
Lines: lines,
Applied: applied,
FxUsed: fx,
}
return resp, nil
}
func (s *Service) validateQuoteRequest(req *feesv1.QuoteFeesRequest) error {
if req == nil {
return status.Error(codes.InvalidArgument, "request is required")
}
if req.GetMeta() == nil || strings.TrimSpace(req.GetMeta().GetOrganizationRef()) == "" {
return status.Error(codes.InvalidArgument, "meta.organization_ref is required")
}
if req.GetIntent() == nil {
return status.Error(codes.InvalidArgument, "intent is required")
}
if req.GetIntent().GetTrigger() == feesv1.Trigger_TRIGGER_UNSPECIFIED {
return status.Error(codes.InvalidArgument, "intent.trigger is required")
}
if req.GetIntent().GetBaseAmount() == nil {
return status.Error(codes.InvalidArgument, "intent.base_amount is required")
}
if strings.TrimSpace(req.GetIntent().GetBaseAmount().GetAmount()) == "" {
return status.Error(codes.InvalidArgument, "intent.base_amount.amount is required")
}
if strings.TrimSpace(req.GetIntent().GetBaseAmount().GetCurrency()) == "" {
return status.Error(codes.InvalidArgument, "intent.base_amount.currency is required")
}
return nil
}
func (s *Service) validatePrecomputeRequest(req *feesv1.PrecomputeFeesRequest) error {
if req == nil {
return status.Error(codes.InvalidArgument, "request is required")
}
return s.validateQuoteRequest(&feesv1.QuoteFeesRequest{Meta: req.GetMeta(), Intent: req.GetIntent()})
}
func (s *Service) computeQuote(ctx context.Context, orgRef primitive.ObjectID, intent *feesv1.Intent, overrides *feesv1.PolicyOverrides, trace *tracev1.TraceContext) ([]*feesv1.DerivedPostingLine, []*feesv1.AppliedRule, *feesv1.FXUsed, error) {
return s.computeQuoteWithTime(ctx, orgRef, intent, overrides, trace, s.clock.Now())
}
func (s *Service) computeQuoteWithTime(ctx context.Context, orgRef primitive.ObjectID, intent *feesv1.Intent, overrides *feesv1.PolicyOverrides, trace *tracev1.TraceContext, now time.Time) ([]*feesv1.DerivedPostingLine, []*feesv1.AppliedRule, *feesv1.FXUsed, error) {
bookedAt := now
if intent.GetBookedAt() != nil && intent.GetBookedAt().IsValid() {
bookedAt = intent.GetBookedAt().AsTime()
}
plan, err := s.storage.Plans().GetActivePlan(ctx, orgRef, bookedAt)
if err != nil {
if errors.Is(err, storage.ErrFeePlanNotFound) {
return nil, nil, nil, status.Error(codes.NotFound, "fee plan not found")
}
s.logger.Warn("failed to load active fee plan", zap.Error(err))
return nil, nil, nil, status.Error(codes.Internal, "failed to load fee plan")
}
result, calcErr := s.calculator.Compute(ctx, plan, intent, bookedAt, trace)
if calcErr != nil {
if errors.Is(calcErr, merrors.ErrInvalidArg) {
return nil, nil, nil, status.Error(codes.InvalidArgument, calcErr.Error())
}
s.logger.Warn("failed to compute fee quote", zap.Error(calcErr))
return nil, nil, nil, status.Error(codes.Internal, "failed to compute fee quote")
}
return result.Lines, result.Applied, result.FxUsed, nil
}
type feeQuoteTokenPayload struct {
OrganizationRef string `json:"organization_ref"`
Intent *feesv1.Intent `json:"intent"`
ExpiresAtUnixMs int64 `json:"expires_at_unix_ms"`
Trace *tracev1.TraceContext `json:"trace,omitempty"`
}
func encodeTokenPayload(payload feeQuoteTokenPayload) (string, error) {
data, err := json.Marshal(payload)
if err != nil {
return "", merrors.Internal("fees: failed to serialize token payload")
}
return base64.StdEncoding.EncodeToString(data), nil
}
func decodeTokenPayload(token string) (feeQuoteTokenPayload, error) {
var payload feeQuoteTokenPayload
data, err := base64.StdEncoding.DecodeString(token)
if err != nil {
return payload, merrors.InvalidArgument("fees: invalid token encoding")
}
if err := json.Unmarshal(data, &payload); err != nil {
return payload, merrors.InvalidArgument("fees: invalid token payload")
}
return payload, nil
}

View File

@@ -0,0 +1,476 @@
package fees
import (
"context"
"testing"
"time"
"github.com/tech/sendico/billing/fees/storage"
"github.com/tech/sendico/billing/fees/storage/model"
oracleclient "github.com/tech/sendico/fx/oracle/client"
me "github.com/tech/sendico/pkg/messaging/envelope"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.uber.org/zap"
"google.golang.org/protobuf/types/known/timestamppb"
)
func TestQuoteFees_ComputesDerivedLines(t *testing.T) {
t.Helper()
now := time.Date(2024, 1, 10, 16, 0, 0, 0, time.UTC)
orgRef := primitive.NewObjectID()
plan := &model.FeePlan{
Active: true,
EffectiveFrom: now.Add(-time.Hour),
Rules: []model.FeeRule{
{
RuleID: "capture_default",
Trigger: model.TriggerCapture,
Priority: 10,
Percentage: "0.029",
FixedAmount: "0.30",
LedgerAccountRef: "acct:fees",
LineType: "fee",
EntrySide: "credit",
Rounding: "half_up",
Metadata: map[string]string{
"scale": "2",
"tax_code": "VAT",
"tax_rate": "0.20",
},
EffectiveFrom: now.Add(-time.Hour),
},
},
}
plan.SetID(primitive.NewObjectID())
plan.SetOrganizationRef(orgRef)
service := NewService(
zap.NewNop(),
&stubRepository{plans: &stubPlansStore{plan: plan}},
noopProducer{},
WithClock(fixedClock{now: now}),
)
req := &feesv1.QuoteFeesRequest{
Meta: &feesv1.RequestMeta{
OrganizationRef: orgRef.Hex(),
Trace: &tracev1.TraceContext{
TraceRef: "trace-capture",
},
},
Intent: &feesv1.Intent{
Trigger: feesv1.Trigger_TRIGGER_CAPTURE,
BaseAmount: &moneyv1.Money{
Amount: "100.00",
Currency: "USD",
},
BookedAt: timestamppb.New(now),
Attributes: map[string]string{"channel": "card"},
},
}
resp, err := service.QuoteFees(context.Background(), req)
if err != nil {
t.Fatalf("QuoteFees returned error: %v", err)
}
if resp.GetMeta().GetTrace().GetTraceRef() != "trace-capture" {
t.Fatalf("expected trace_ref to round-trip, got %q", resp.GetMeta().GetTrace().GetTraceRef())
}
if len(resp.GetLines()) != 1 {
t.Fatalf("expected 1 derived line, got %d", len(resp.GetLines()))
}
line := resp.GetLines()[0]
if got := line.GetMoney().GetAmount(); got != "3.20" {
t.Fatalf("expected fee amount 3.20, got %s", got)
}
if line.GetMoney().GetCurrency() != "USD" {
t.Fatalf("expected currency USD, got %s", line.GetMoney().GetCurrency())
}
if line.GetLedgerAccountRef() != "acct:fees" {
t.Fatalf("unexpected ledger account ref %s", line.GetLedgerAccountRef())
}
if meta := line.GetMeta(); meta["fee_rule_id"] != "capture_default" || meta["fee_plan_id"] != plan.GetID().Hex() || meta["tax_code"] != "VAT" {
t.Fatalf("unexpected derived line metadata: %#v", meta)
}
if len(resp.GetApplied()) != 1 {
t.Fatalf("expected 1 applied rule, got %d", len(resp.GetApplied()))
}
applied := resp.GetApplied()[0]
if applied.GetTaxCode() != "VAT" || applied.GetTaxRate() != "0.20" {
t.Fatalf("applied rule metadata mismatch: %+v", applied)
}
if applied.GetRounding() != moneyv1.RoundingMode_ROUND_HALF_UP {
t.Fatalf("expected rounding HALF_UP, got %v", applied.GetRounding())
}
if applied.GetParameters()["scale"] != "2" {
t.Fatalf("expected parameters to carry metadata scale, got %+v", applied.GetParameters())
}
}
func TestQuoteFees_FiltersByAttributesAndDates(t *testing.T) {
t.Helper()
now := time.Date(2024, 5, 20, 9, 30, 0, 0, time.UTC)
orgRef := primitive.NewObjectID()
plan := &model.FeePlan{
Active: true,
EffectiveFrom: now.Add(-24 * time.Hour),
Rules: []model.FeeRule{
{
RuleID: "base",
Trigger: model.TriggerCapture,
Priority: 1,
Percentage: "0.10",
LedgerAccountRef: "acct:base",
Metadata: map[string]string{"scale": "2"},
Rounding: "half_even",
EffectiveFrom: now.Add(-time.Hour),
},
{
RuleID: "future",
Trigger: model.TriggerCapture,
Priority: 2,
Percentage: "0.50",
LedgerAccountRef: "acct:future",
Metadata: map[string]string{"scale": "2"},
Rounding: "half_even",
EffectiveFrom: now.Add(time.Hour),
},
{
RuleID: "attr",
Trigger: model.TriggerCapture,
Priority: 3,
Percentage: "0.30",
LedgerAccountRef: "acct:attr",
Metadata: map[string]string{"scale": "2"},
AppliesTo: map[string]string{"region": "eu"},
Rounding: "half_even",
EffectiveFrom: now.Add(-time.Hour),
},
},
}
plan.SetID(primitive.NewObjectID())
plan.SetOrganizationRef(orgRef)
service := NewService(
zap.NewNop(),
&stubRepository{plans: &stubPlansStore{plan: plan}},
noopProducer{},
WithClock(fixedClock{now: now}),
)
req := &feesv1.QuoteFeesRequest{
Meta: &feesv1.RequestMeta{OrganizationRef: orgRef.Hex()},
Intent: &feesv1.Intent{
Trigger: feesv1.Trigger_TRIGGER_CAPTURE,
BaseAmount: &moneyv1.Money{
Amount: "50.00",
Currency: "EUR",
},
BookedAt: timestamppb.New(now),
Attributes: map[string]string{"region": "us"},
},
}
resp, err := service.QuoteFees(context.Background(), req)
if err != nil {
t.Fatalf("QuoteFees returned error: %v", err)
}
if len(resp.GetLines()) != 1 {
t.Fatalf("expected only base rule to fire, got %d lines", len(resp.GetLines()))
}
line := resp.GetLines()[0]
if line.GetLedgerAccountRef() != "acct:base" {
t.Fatalf("expected base rule to apply, got %s", line.GetLedgerAccountRef())
}
if line.GetMoney().GetAmount() != "5.00" {
t.Fatalf("expected 5.00 amount, got %s", line.GetMoney().GetAmount())
}
}
func TestQuoteFees_RoundingDown(t *testing.T) {
t.Helper()
now := time.Date(2024, 3, 15, 12, 0, 0, 0, time.UTC)
orgRef := primitive.NewObjectID()
plan := &model.FeePlan{
Active: true,
EffectiveFrom: now.Add(-time.Hour),
Rules: []model.FeeRule{
{
RuleID: "round_down",
Trigger: model.TriggerCapture,
Priority: 1,
FixedAmount: "0.015",
LedgerAccountRef: "acct:round",
Metadata: map[string]string{"scale": "2"},
Rounding: "down",
EffectiveFrom: now.Add(-time.Hour),
},
},
}
plan.SetID(primitive.NewObjectID())
plan.SetOrganizationRef(orgRef)
service := NewService(
zap.NewNop(),
&stubRepository{plans: &stubPlansStore{plan: plan}},
noopProducer{},
WithClock(fixedClock{now: now}),
)
req := &feesv1.QuoteFeesRequest{
Meta: &feesv1.RequestMeta{OrganizationRef: orgRef.Hex()},
Intent: &feesv1.Intent{
Trigger: feesv1.Trigger_TRIGGER_CAPTURE,
BaseAmount: &moneyv1.Money{
Amount: "1.00",
Currency: "USD",
},
BookedAt: timestamppb.New(now),
},
}
resp, err := service.QuoteFees(context.Background(), req)
if err != nil {
t.Fatalf("QuoteFees returned error: %v", err)
}
if len(resp.GetLines()) != 1 {
t.Fatalf("expected single derived line, got %d", len(resp.GetLines()))
}
if resp.GetLines()[0].GetMoney().GetAmount() != "0.01" {
t.Fatalf("expected rounding down to 0.01, got %s", resp.GetLines()[0].GetMoney().GetAmount())
}
}
func TestQuoteFees_UsesInjectedCalculator(t *testing.T) {
t.Helper()
now := time.Date(2024, 6, 1, 8, 0, 0, 0, time.UTC)
orgRef := primitive.NewObjectID()
plan := &model.FeePlan{
Active: true,
EffectiveFrom: now.Add(-time.Hour),
}
plan.SetID(primitive.NewObjectID())
plan.SetOrganizationRef(orgRef)
result := &CalculationResult{
Lines: []*feesv1.DerivedPostingLine{
{
LedgerAccountRef: "acct:stub",
Money: &moneyv1.Money{
Amount: "1.23",
Currency: "USD",
},
},
},
Applied: []*feesv1.AppliedRule{
{RuleId: "stub"},
},
}
calc := &stubCalculator{result: result}
service := NewService(
zap.NewNop(),
&stubRepository{plans: &stubPlansStore{plan: plan}},
noopProducer{},
WithClock(fixedClock{now: now}),
WithCalculator(calc),
)
resp, err := service.QuoteFees(context.Background(), &feesv1.QuoteFeesRequest{
Meta: &feesv1.RequestMeta{OrganizationRef: orgRef.Hex()},
Intent: &feesv1.Intent{
Trigger: feesv1.Trigger_TRIGGER_CAPTURE,
BaseAmount: &moneyv1.Money{
Amount: "10.00",
Currency: "USD",
},
},
})
if err != nil {
t.Fatalf("QuoteFees returned error: %v", err)
}
if !calc.called {
t.Fatalf("expected calculator to be invoked")
}
if calc.gotPlan != plan {
t.Fatalf("expected calculator to receive plan pointer")
}
if len(resp.GetLines()) != len(result.Lines) {
t.Fatalf("expected %d lines, got %d", len(result.Lines), len(resp.GetLines()))
}
if resp.GetLines()[0].GetLedgerAccountRef() != "acct:stub" {
t.Fatalf("unexpected ledger account in response: %s", resp.GetLines()[0].GetLedgerAccountRef())
}
}
func TestQuoteFees_PopulatesFxUsed(t *testing.T) {
t.Helper()
now := time.Date(2024, 7, 1, 9, 30, 0, 0, time.UTC)
orgRef := primitive.NewObjectID()
plan := &model.FeePlan{
Active: true,
EffectiveFrom: now.Add(-time.Hour),
Rules: []model.FeeRule{
{
RuleID: "fx_mark_up",
Trigger: model.TriggerFXConversion,
Priority: 1,
Percentage: "0.03",
LedgerAccountRef: "acct:fx",
Metadata: map[string]string{"scale": "2"},
Rounding: "half_even",
EffectiveFrom: now.Add(-time.Hour),
},
},
}
plan.SetID(primitive.NewObjectID())
plan.SetOrganizationRef(orgRef)
fakeOracle := &oracleclient.Fake{
LatestRateFn: func(ctx context.Context, req oracleclient.LatestRateParams) (*oracleclient.RateSnapshot, error) {
return &oracleclient.RateSnapshot{
Pair: req.Pair,
Mid: "1.2300",
SpreadBps: "12",
Provider: "TestProvider",
RateRef: "rate-ref-123",
AsOf: now.Add(-2 * time.Minute),
}, nil
},
}
service := NewService(
zap.NewNop(),
&stubRepository{plans: &stubPlansStore{plan: plan}},
noopProducer{},
WithClock(fixedClock{now: now}),
WithOracleClient(fakeOracle),
)
resp, err := service.QuoteFees(context.Background(), &feesv1.QuoteFeesRequest{
Meta: &feesv1.RequestMeta{OrganizationRef: orgRef.Hex()},
Intent: &feesv1.Intent{
Trigger: feesv1.Trigger_TRIGGER_FX_CONVERSION,
BaseAmount: &moneyv1.Money{
Amount: "100.00",
Currency: "USD",
},
Attributes: map[string]string{
"fx_base_currency": "USD",
"fx_quote_currency": "EUR",
"fx_provider": "TestProvider",
"fx_side": "buy_base",
},
},
})
if err != nil {
t.Fatalf("QuoteFees returned error: %v", err)
}
if resp.GetFxUsed() == nil {
t.Fatalf("expected FxUsed to be populated")
}
fx := resp.GetFxUsed()
if fx.GetProvider() != "TestProvider" || fx.GetRate().GetValue() != "1.2300" {
t.Fatalf("unexpected FxUsed payload: %+v", fx)
}
if fx.GetPair().GetBase() != "USD" || fx.GetPair().GetQuote() != "EUR" {
t.Fatalf("unexpected currency pair: %+v", fx.GetPair())
}
}
type stubRepository struct {
plans storage.PlansStore
}
func (s *stubRepository) Ping(context.Context) error {
return nil
}
func (s *stubRepository) Plans() storage.PlansStore {
return s.plans
}
type stubPlansStore struct {
plan *model.FeePlan
}
func (s *stubPlansStore) Create(context.Context, *model.FeePlan) error {
return nil
}
func (s *stubPlansStore) Update(context.Context, *model.FeePlan) error {
return nil
}
func (s *stubPlansStore) Get(context.Context, primitive.ObjectID) (*model.FeePlan, error) {
return nil, storage.ErrFeePlanNotFound
}
func (s *stubPlansStore) GetActivePlan(_ context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error) {
if s.plan == nil {
return nil, storage.ErrFeePlanNotFound
}
if s.plan.GetOrganizationRef() != orgRef {
return nil, storage.ErrFeePlanNotFound
}
if !s.plan.Active {
return nil, storage.ErrFeePlanNotFound
}
if !s.plan.EffectiveFrom.Before(at) && !s.plan.EffectiveFrom.Equal(at) {
return nil, storage.ErrFeePlanNotFound
}
if s.plan.EffectiveTo != nil && s.plan.EffectiveTo.Before(at) {
return nil, storage.ErrFeePlanNotFound
}
return s.plan, nil
}
type noopProducer struct{}
func (noopProducer) SendMessage(me.Envelope) error {
return nil
}
type fixedClock struct {
now time.Time
}
func (f fixedClock) Now() time.Time {
return f.now
}
type stubCalculator struct {
result *CalculationResult
err error
called bool
gotPlan *model.FeePlan
bookedAt time.Time
}
func (s *stubCalculator) Compute(_ context.Context, plan *model.FeePlan, _ *feesv1.Intent, bookedAt time.Time, _ *tracev1.TraceContext) (*CalculationResult, error) {
s.called = true
s.gotPlan = plan
s.bookedAt = bookedAt
if s.err != nil {
return nil, s.err
}
return s.result, nil
}

17
api/billing/fees/main.go Normal file
View File

@@ -0,0 +1,17 @@
package main
import (
"github.com/tech/sendico/billing/fees/internal/appversion"
si "github.com/tech/sendico/billing/fees/internal/server"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/server"
smain "github.com/tech/sendico/pkg/server/main"
)
func factory(logger mlogger.Logger, file string, debug bool) (server.Application, error) {
return si.Create(logger, file, debug)
}
func main() {
smain.RunServer("main", appversion.Create(), factory)
}

View File

@@ -0,0 +1,62 @@
package model
import (
"time"
"github.com/tech/sendico/pkg/db/storable"
"github.com/tech/sendico/pkg/model"
)
const (
FeePlansCollection = "fee_plans"
)
// Trigger represents the event that causes a fee rule to apply.
type Trigger string
const (
TriggerUnspecified Trigger = "unspecified"
TriggerCapture Trigger = "capture"
TriggerRefund Trigger = "refund"
TriggerDispute Trigger = "dispute"
TriggerPayout Trigger = "payout"
TriggerFXConversion Trigger = "fx_conversion"
)
// FeePlan describes a collection of fee rules for an organisation.
type FeePlan struct {
storable.Base `bson:",inline" json:",inline"`
model.OrganizationBoundBase `bson:",inline" json:",inline"`
model.Describable `bson:",inline" json:",inline"`
Active bool `bson:"active" json:"active"`
EffectiveFrom time.Time `bson:"effectiveFrom" json:"effectiveFrom"`
EffectiveTo *time.Time `bson:"effectiveTo,omitempty" json:"effectiveTo,omitempty"`
Rules []FeeRule `bson:"rules,omitempty" json:"rules,omitempty"`
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
}
// Collection implements storable.Storable.
func (*FeePlan) Collection() string {
return FeePlansCollection
}
// FeeRule represents a single pricing rule within a plan.
type FeeRule struct {
RuleID string `bson:"ruleId" json:"ruleId"`
Trigger Trigger `bson:"trigger" json:"trigger"`
Priority int `bson:"priority" json:"priority"`
Percentage string `bson:"percentage,omitempty" json:"percentage,omitempty"`
FixedAmount string `bson:"fixedAmount,omitempty" json:"fixedAmount,omitempty"`
Currency string `bson:"currency,omitempty" json:"currency,omitempty"`
MinimumAmount string `bson:"minimumAmount,omitempty" json:"minimumAmount,omitempty"`
MaximumAmount string `bson:"maximumAmount,omitempty" json:"maximumAmount,omitempty"`
AppliesTo map[string]string `bson:"appliesTo,omitempty" json:"appliesTo,omitempty"`
Formula string `bson:"formula,omitempty" json:"formula,omitempty"`
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
LedgerAccountRef string `bson:"ledgerAccountRef,omitempty" json:"ledgerAccountRef,omitempty"`
LineType string `bson:"lineType,omitempty" json:"lineType,omitempty"`
EntrySide string `bson:"entrySide,omitempty" json:"entrySide,omitempty"`
Rounding string `bson:"rounding,omitempty" json:"rounding,omitempty"`
EffectiveFrom time.Time `bson:"effectiveFrom" json:"effectiveFrom"`
EffectiveTo *time.Time `bson:"effectiveTo,omitempty" json:"effectiveTo,omitempty"`
}

View File

@@ -0,0 +1,69 @@
package mongo
import (
"context"
"time"
"github.com/tech/sendico/billing/fees/storage"
"github.com/tech/sendico/billing/fees/storage/mongo/store"
"github.com/tech/sendico/pkg/db"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"go.mongodb.org/mongo-driver/mongo"
"go.uber.org/zap"
)
type Store struct {
logger mlogger.Logger
conn *db.MongoConnection
db *mongo.Database
plans storage.PlansStore
}
// New creates a repository backed by MongoDB for the billing fees service.
func New(logger mlogger.Logger, conn *db.MongoConnection) (*Store, error) {
if conn == nil {
return nil, merrors.InvalidArgument("mongo connection is nil")
}
client := conn.Client()
if client == nil {
return nil, merrors.Internal("mongo client not initialised")
}
database := conn.Database()
result := &Store{
logger: logger.Named("storage").Named("mongo"),
conn: conn,
db: database,
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := result.Ping(ctx); err != nil {
result.logger.Error("mongo ping failed during store init", zap.Error(err))
return nil, err
}
plansStore, err := store.NewPlans(result.logger, database)
if err != nil {
result.logger.Error("failed to initialise plans store", zap.Error(err))
return nil, err
}
result.plans = plansStore
result.logger.Info("Billing fees MongoDB storage initialised")
return result, nil
}
func (s *Store) Ping(ctx context.Context) error {
return s.conn.Ping(ctx)
}
func (s *Store) Plans() storage.PlansStore {
return s.plans
}
var _ storage.Repository = (*Store)(nil)

View File

@@ -0,0 +1,144 @@
package store
import (
"context"
"errors"
"time"
"github.com/tech/sendico/billing/fees/storage"
"github.com/tech/sendico/billing/fees/storage/model"
"github.com/tech/sendico/pkg/db/repository"
"github.com/tech/sendico/pkg/db/repository/builder"
ri "github.com/tech/sendico/pkg/db/repository/index"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
m "github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo"
"go.uber.org/zap"
)
type plansStore struct {
logger mlogger.Logger
repo repository.Repository
}
// NewPlans constructs a Mongo-backed PlansStore.
func NewPlans(logger mlogger.Logger, db *mongo.Database) (storage.PlansStore, error) {
repo := repository.CreateMongoRepository(db, mservice.FeePlans)
// Index for organisation lookups.
orgIndex := &ri.Definition{
Keys: []ri.Key{
{Field: m.OrganizationRefField, Sort: ri.Asc},
{Field: "effectiveFrom", Sort: ri.Desc},
},
}
if err := repo.CreateIndex(orgIndex); err != nil {
logger.Error("failed to ensure fee plan organization index", zap.Error(err))
return nil, err
}
// Unique index for plan versions (per organisation + effectiveFrom).
uniqueIndex := &ri.Definition{
Keys: []ri.Key{
{Field: m.OrganizationRefField, Sort: ri.Asc},
{Field: "effectiveFrom", Sort: ri.Asc},
},
Unique: true,
}
if err := repo.CreateIndex(uniqueIndex); err != nil {
logger.Error("failed to ensure fee plan uniqueness index", zap.Error(err))
return nil, err
}
return &plansStore{
logger: logger.Named("plans"),
repo: repo,
}, nil
}
func (p *plansStore) Create(ctx context.Context, plan *model.FeePlan) error {
if plan == nil {
return merrors.InvalidArgument("plansStore: nil fee plan")
}
if err := p.repo.Insert(ctx, plan, nil); err != nil {
if errors.Is(err, merrors.ErrDataConflict) {
return storage.ErrDuplicateFeePlan
}
p.logger.Warn("failed to create fee plan", zap.Error(err))
return err
}
return nil
}
func (p *plansStore) Update(ctx context.Context, plan *model.FeePlan) error {
if plan == nil || plan.GetID() == nil || plan.GetID().IsZero() {
return merrors.InvalidArgument("plansStore: invalid fee plan reference")
}
if err := p.repo.Update(ctx, plan); err != nil {
p.logger.Warn("failed to update fee plan", zap.Error(err))
return err
}
return nil
}
func (p *plansStore) Get(ctx context.Context, planRef primitive.ObjectID) (*model.FeePlan, error) {
if planRef.IsZero() {
return nil, merrors.InvalidArgument("plansStore: zero plan reference")
}
result := &model.FeePlan{}
if err := p.repo.Get(ctx, planRef, result); err != nil {
if errors.Is(err, merrors.ErrNoData) {
return nil, storage.ErrFeePlanNotFound
}
return nil, err
}
return result, nil
}
func (p *plansStore) GetActivePlan(ctx context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error) {
if orgRef.IsZero() {
return nil, merrors.InvalidArgument("plansStore: zero organization reference")
}
limit := int64(1)
query := repository.Query().
Filter(repository.OrgField(), orgRef).
Filter(repository.Field("active"), true).
Comparison(repository.Field("effectiveFrom"), builder.Lte, at).
Sort(repository.Field("effectiveFrom"), false).
Limit(&limit)
query = query.And(
repository.Query().Or(
repository.Query().Filter(repository.Field("effectiveTo"), nil),
repository.Query().Comparison(repository.Field("effectiveTo"), builder.Gte, at),
),
)
var plan *model.FeePlan
decoder := func(cursor *mongo.Cursor) error {
target := &model.FeePlan{}
if err := cursor.Decode(target); err != nil {
return err
}
plan = target
return nil
}
if err := p.repo.FindManyByFilter(ctx, query, decoder); err != nil {
if errors.Is(err, merrors.ErrNoData) {
return nil, storage.ErrFeePlanNotFound
}
return nil, err
}
if plan == nil {
return nil, storage.ErrFeePlanNotFound
}
return plan, nil
}
var _ storage.PlansStore = (*plansStore)(nil)

View File

@@ -0,0 +1,36 @@
package storage
import (
"context"
"time"
"github.com/tech/sendico/billing/fees/storage/model"
"go.mongodb.org/mongo-driver/bson/primitive"
)
type storageError string
func (e storageError) Error() string {
return string(e)
}
var (
// ErrFeePlanNotFound indicates that a requested fee plan does not exist.
ErrFeePlanNotFound = storageError("billing.fees.storage: fee plan not found")
// ErrDuplicateFeePlan indicates that a unique plan constraint was violated.
ErrDuplicateFeePlan = storageError("billing.fees.storage: duplicate fee plan")
)
// Repository defines the root storage contract for the fees service.
type Repository interface {
Ping(ctx context.Context) error
Plans() PlansStore
}
// PlansStore exposes persistence operations for fee plans.
type PlansStore interface {
Create(ctx context.Context, plan *model.FeePlan) error
Update(ctx context.Context, plan *model.FeePlan) error
Get(ctx context.Context, planRef primitive.ObjectID) (*model.FeePlan, error)
GetActivePlan(ctx context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error)
}

32
api/fx/ingestor/.air.toml Normal file
View File

@@ -0,0 +1,32 @@
# Config file for Air in TOML format
root = "./../.."
tmp_dir = "tmp"
[build]
cmd = "go build -o app -ldflags \"-X 'github.com/tech/sendico/fx/ingestor/internal/appversion.BuildUser=$(whoami)' -X 'github.com/tech/sendico/fx/ingestor/internal/appversion.Version=$APP_V' -X 'github.com/tech/sendico/fx/ingestor/internal/appversion.Branch=$BUILD_BRANCH' -X 'github.com/tech/sendico/fx/ingestor/internal/appversion.Revision=$GIT_REV' -X 'github.com/tech/sendico/fx/ingestor/internal/appversion.BuildDate=$(date)'\""
bin = "./app"
full_bin = "./app --debug --config.file=config.yml"
include_ext = ["go", "yaml", "yml"]
exclude_dir = ["fx/ingestor/tmp", "pkg/.git", "fx/ingestor/env"]
exclude_regex = ["_test\\.go"]
exclude_unchanged = true
follow_symlink = true
log = "air.log"
delay = 0
stop_on_error = true
send_interrupt = true
kill_delay = 500
args_bin = []
[log]
time = false
[color]
main = "magenta"
watcher = "cyan"
build = "yellow"
runner = "green"
[misc]
clean_on_exit = true

3
api/fx/ingestor/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
internal/generated
.gocache
/app

View File

@@ -0,0 +1,43 @@
poll_interval_seconds: 30
market:
sources:
- driver: BINANCE
settings:
base_url: "https://api.binance.com"
- driver: COINGECKO
settings:
base_url: "https://api.coingecko.com/api/v3"
pairs:
BINANCE:
- base: "USDT"
quote: "EUR"
symbol: "EURUSDT"
invert: true
- base: "UAH"
quote: "USDT"
symbol: "USDTUAH"
invert: true
- base: "USDC"
quote: "EUR"
symbol: "EURUSDC"
invert: true
COINGECKO:
- base: "USDT"
quote: "RUB"
symbol: "tether:rub"
metrics:
enabled: true
address: ":9102"
database:
driver: mongodb
settings:
host_env: FX_MONGO_HOST
port_env: FX_MONGO_PORT
database_env: FX_MONGO_DATABASE
user_env: FX_MONGO_USER
password_env: FX_MONGO_PASSWORD
auth_source_env: FX_MONGO_AUTH_SOURCE
replica_set_env: FX_MONGO_REPLICA_SET

1
api/fx/ingestor/env/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
.env.api

55
api/fx/ingestor/go.mod Normal file
View File

@@ -0,0 +1,55 @@
module github.com/tech/sendico/fx/ingestor
go 1.25.3
replace github.com/tech/sendico/pkg => ../../pkg
replace github.com/tech/sendico/fx/storage => ../storage
require (
github.com/go-chi/chi/v5 v5.2.3
github.com/google/go-cmp v0.7.0
github.com/prometheus/client_golang v1.23.2
github.com/tech/sendico/fx/storage v0.0.0
github.com/tech/sendico/pkg v0.1.0
go.uber.org/zap v1.27.1
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
github.com/casbin/casbin/v2 v2.134.0 // indirect
github.com/casbin/govaluate v1.10.0 // indirect
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/golang/snappy v1.0.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/compress v1.18.2 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/montanaflynn/stats v0.7.1 // 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/nkeys v0.4.12 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.4 // indirect
github.com/prometheus/procfs v0.19.2 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.2.0 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
go.mongodb.org/mongo-driver v1.17.6 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/grpc v1.77.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
)

225
api/fx/ingestor/go.sum Normal file
View File

@@ -0,0 +1,225 @@
dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/casbin/casbin/v2 v2.134.0 h1:wyO3hZb487GzlGVAI2hUoHQT0ehFD+9B5P+HVG9BVTM=
github.com/casbin/casbin/v2 v2.134.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
github.com/casbin/mongodb-adapter/v3 v3.7.0 h1:w9c3bea1BGK4eZTAmk17JkY52yv/xSZDSHKji8q+z6E=
github.com/casbin/mongodb-adapter/v3 v3.7.0/go.mod h1:F1mu4ojoJVE/8VhIMxMedhjfwRDdIXgANYs6Sd0MgVA=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v27.3.1+incompatible h1:KttF0XoteNTicmUtBO0L2tP+J7FGRFTjaEF4k6WdhfI=
github.com/docker/docker v27.3.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54 h1:mFWunSatvkQQDhpdyuFAYwyAan3hzCuma+Pz8sqvOfg=
github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
github.com/moby/sys/user v0.3.0 h1:9ni5DlcW5an3SvRSx4MouotOygvzaXbaSrc/wGDFWPo=
github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE=
github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nats-io/nats.go v1.47.0 h1:YQdADw6J/UfGUd2Oy6tn4Hq6YHxCaJrVKayxxFqYrgM=
github.com/nats-io/nats.go v1.47.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/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc=
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/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
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/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/testcontainers/testcontainers-go v0.33.0 h1:zJS9PfXYT5O0ZFXM2xxXfk4J5UMw/kRiISng037Gxdw=
github.com/testcontainers/testcontainers-go v0.33.0/go.mod h1:W80YpTa8D5C3Yy16icheD01UTDu+LmXIA2Keo+jWtT8=
github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0 h1:iXVA84s5hKMS5gn01GWOYHE3ymy/2b+0YkpFeTxB2XY=
github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0/go.mod h1:R6tMjTojRiaoo89fh/hf7tOmfzohdqSU17R9DwSVSog=
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8=
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.mongodb.org/mongo-driver v1.17.6 h1:87JUG1wZfWsr6rIz3ZmpH90rL5tea7O3IHuSwHUpsss=
go.mongodb.org/mongo-driver v1.17.6/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 h1:UP6IpuHFkUgOQL9FFQFrZ+5LiwhhYRbi7VZSIx6Nj5s=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM=
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
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/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
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/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -0,0 +1,82 @@
package app
import (
"context"
"errors"
"strings"
"github.com/tech/sendico/fx/ingestor/internal/appversion"
"github.com/tech/sendico/fx/ingestor/internal/config"
"github.com/tech/sendico/fx/ingestor/internal/fmerrors"
"github.com/tech/sendico/fx/ingestor/internal/ingestor"
"github.com/tech/sendico/fx/ingestor/internal/metrics"
mongostorage "github.com/tech/sendico/fx/storage/mongo"
"github.com/tech/sendico/pkg/api/routers/health"
"github.com/tech/sendico/pkg/db"
"github.com/tech/sendico/pkg/mlogger"
"go.uber.org/zap"
)
const DefaultConfigPath = "config.yml"
type App struct {
logger mlogger.Logger
cfg *config.Config
}
func New(logger mlogger.Logger, cfgPath string) (*App, error) {
if logger == nil {
return nil, fmerrors.New("app: logger is nil")
}
path := strings.TrimSpace(cfgPath)
if path == "" {
path = DefaultConfigPath
}
cfg, err := config.Load(path)
if err != nil {
return nil, err
}
return &App{
logger: logger,
cfg: cfg,
}, nil
}
func (a *App) Run(ctx context.Context) error {
metricsSrv, err := metrics.NewServer(a.logger, a.cfg.MetricsConfig())
if err != nil {
return err
}
a.logger.Debug("Metrics server initialised")
defer metricsSrv.Close(context.Background())
conn, err := db.ConnectMongo(a.logger, a.cfg.Database)
if err != nil {
return err
}
defer conn.Disconnect(context.Background())
a.logger.Debug("MongoDB connection established")
repo, err := mongostorage.New(a.logger, conn)
if err != nil {
return err
}
a.logger.Debug("Storage repository initialised")
service, err := ingestor.New(a.logger, a.cfg, repo)
if err != nil {
return err
}
a.logger.Info("Starting FX ingestor service", zap.String("version", appversion.Create().Info()))
metricsSrv.SetStatus(health.SSRunning)
if err := service.Run(ctx); err != nil {
if !errors.Is(err, context.Canceled) { // ignore termination reques error
a.logger.Error("Ingestor service exited with error", zap.Error(err))
}
return err
}
a.logger.Info("Ingestor service stopped")
return nil
}

View File

@@ -0,0 +1,27 @@
package appversion
import (
"github.com/tech/sendico/pkg/version"
vf "github.com/tech/sendico/pkg/version/factory"
)
// Build information. Populated at build-time.
var (
Version string
Revision string
Branch string
BuildUser string
BuildDate string
)
func Create() version.Printer {
vi := version.Info{
Program: "Sendico FX Ingestor Service",
Revision: Revision,
Branch: Branch,
BuildUser: BuildUser,
BuildDate: BuildDate,
Version: Version,
}
return vf.Create(&vi)
}

View File

@@ -0,0 +1,147 @@
package config
import (
"os"
"strings"
"time"
"github.com/tech/sendico/fx/ingestor/internal/fmerrors"
mmodel "github.com/tech/sendico/fx/ingestor/internal/model"
"github.com/tech/sendico/pkg/db"
"gopkg.in/yaml.v3"
)
const defaultPollInterval = 30 * time.Second
type Config struct {
PollIntervalSeconds int `yaml:"poll_interval_seconds"`
Market MarketConfig `yaml:"market"`
Database *db.Config `yaml:"database"`
Metrics *MetricsConfig `yaml:"metrics"`
pairs []Pair
pairsBySource map[mmodel.Driver][]PairConfig
}
func Load(path string) (*Config, error) {
if path == "" {
return nil, fmerrors.New("config: path is empty")
}
data, err := os.ReadFile(path)
if err != nil {
return nil, fmerrors.Wrap("config: failed to read file", err)
}
cfg := &Config{}
if err := yaml.Unmarshal(data, cfg); err != nil {
return nil, fmerrors.Wrap("config: failed to parse yaml", err)
}
if len(cfg.Market.Sources) == 0 {
return nil, fmerrors.New("config: no market sources configured")
}
sourceSet := make(map[mmodel.Driver]struct{}, len(cfg.Market.Sources))
for idx := range cfg.Market.Sources {
src := &cfg.Market.Sources[idx]
if src.Driver.IsEmpty() {
return nil, fmerrors.New("config: market source driver is empty")
}
sourceSet[src.Driver] = struct{}{}
}
if len(cfg.Market.Pairs) == 0 {
return nil, fmerrors.New("config: no pairs configured")
}
normalizedPairs := make(map[string][]PairConfig, len(cfg.Market.Pairs))
pairsBySource := make(map[mmodel.Driver][]PairConfig, len(cfg.Market.Pairs))
var flattened []Pair
for rawSource, pairList := range cfg.Market.Pairs {
driver := mmodel.Driver(rawSource)
if driver.IsEmpty() {
return nil, fmerrors.New("config: pair source is empty")
}
if _, ok := sourceSet[driver]; !ok {
return nil, fmerrors.New("config: pair references unknown source: " + driver.String())
}
processed := make([]PairConfig, len(pairList))
for idx := range pairList {
pair := pairList[idx]
pair.Base = strings.ToUpper(strings.TrimSpace(pair.Base))
pair.Quote = strings.ToUpper(strings.TrimSpace(pair.Quote))
pair.Symbol = strings.TrimSpace(pair.Symbol)
if pair.Base == "" || pair.Quote == "" || pair.Symbol == "" {
return nil, fmerrors.New("config: pair entries must define base, quote, and symbol")
}
if strings.TrimSpace(pair.Provider) == "" {
pair.Provider = strings.ToLower(driver.String())
}
processed[idx] = pair
flattened = append(flattened, Pair{
PairConfig: pair,
Source: driver,
})
}
pairsBySource[driver] = processed
normalizedPairs[driver.String()] = processed
}
cfg.Market.Pairs = normalizedPairs
cfg.pairsBySource = pairsBySource
cfg.pairs = flattened
if cfg.Database == nil {
return nil, fmerrors.New("config: database configuration is required")
}
if cfg.Metrics != nil && cfg.Metrics.Enabled {
cfg.Metrics.Address = strings.TrimSpace(cfg.Metrics.Address)
if cfg.Metrics.Address == "" {
cfg.Metrics.Address = ":9102"
}
}
return cfg, nil
}
func (c *Config) PollInterval() time.Duration {
if c == nil {
return defaultPollInterval
}
if c.PollIntervalSeconds <= 0 {
return defaultPollInterval
}
return time.Duration(c.PollIntervalSeconds) * time.Second
}
func (c *Config) Pairs() []Pair {
if c == nil {
return nil
}
out := make([]Pair, len(c.pairs))
copy(out, c.pairs)
return out
}
func (c *Config) PairsBySource() map[mmodel.Driver][]PairConfig {
if c == nil {
return nil
}
out := make(map[mmodel.Driver][]PairConfig, len(c.pairsBySource))
for driver, pairs := range c.pairsBySource {
cp := make([]PairConfig, len(pairs))
copy(cp, pairs)
out[driver] = cp
}
return out
}
func (c *Config) MetricsConfig() *MetricsConfig {
if c == nil || c.Metrics == nil {
return nil
}
cp := *c.Metrics
return &cp
}

View File

@@ -0,0 +1,24 @@
package config
import (
mmodel "github.com/tech/sendico/fx/ingestor/internal/model"
pmodel "github.com/tech/sendico/pkg/model"
)
type PairConfig struct {
Base string `yaml:"base"`
Quote string `yaml:"quote"`
Symbol string `yaml:"symbol"`
Provider string `yaml:"provider"`
Invert bool `yaml:"invert"`
}
type Pair struct {
PairConfig `yaml:",inline"`
Source mmodel.Driver `yaml:"-"`
}
type MarketConfig struct {
Sources []pmodel.DriverConfig[mmodel.Driver] `yaml:"sources"`
Pairs map[string][]PairConfig `yaml:"pairs"`
}

View File

@@ -0,0 +1,6 @@
package config
type MetricsConfig struct {
Enabled bool `yaml:"enabled"`
Address string `yaml:"address"`
}

View File

@@ -0,0 +1,35 @@
package fmerrors
type Error struct {
message string
cause error
}
func (e *Error) Error() string {
if e == nil {
return ""
}
if e.cause == nil {
return e.message
}
return e.message + ": " + e.cause.Error()
}
func (e *Error) Unwrap() error {
if e == nil {
return nil
}
return e.cause
}
func New(message string) error {
return &Error{message: message}
}
func Wrap(message string, cause error) error {
return &Error{message: message, cause: cause}
}
func NewDecimal(value string) error {
return &Error{message: "invalid decimal \"" + value + "\""}
}

View File

@@ -0,0 +1,84 @@
package ingestor
import (
"sync"
"time"
"github.com/tech/sendico/fx/ingestor/internal/config"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
type serviceMetrics struct {
pollDuration *prometheus.HistogramVec
pollTotal *prometheus.CounterVec
pairDuration *prometheus.HistogramVec
pairTotal *prometheus.CounterVec
pairLastUpdate *prometheus.GaugeVec
}
var (
metricsOnce sync.Once
globalMetricsRef *serviceMetrics
)
func getServiceMetrics() *serviceMetrics {
metricsOnce.Do(func() {
reg := prometheus.DefaultRegisterer
globalMetricsRef = &serviceMetrics{
pollDuration: promauto.With(reg).NewHistogramVec(prometheus.HistogramOpts{
Name: "fx_ingestor_poll_duration_seconds",
Help: "Duration of a polling cycle.",
Buckets: prometheus.DefBuckets,
}, []string{"result"}),
pollTotal: promauto.With(reg).NewCounterVec(prometheus.CounterOpts{
Name: "fx_ingestor_poll_total",
Help: "Total polling cycles executed.",
}, []string{"result"}),
pairDuration: promauto.With(reg).NewHistogramVec(prometheus.HistogramOpts{
Name: "fx_ingestor_pair_duration_seconds",
Help: "Duration of individual pair ingestion.",
Buckets: prometheus.DefBuckets,
}, []string{"source", "provider", "symbol", "result"}),
pairTotal: promauto.With(reg).NewCounterVec(prometheus.CounterOpts{
Name: "fx_ingestor_pair_total",
Help: "Total ingestion attempts per pair.",
}, []string{"source", "provider", "symbol", "result"}),
pairLastUpdate: promauto.With(reg).NewGaugeVec(prometheus.GaugeOpts{
Name: "fx_ingestor_pair_last_success_unix",
Help: "Unix timestamp of the last successful ingestion per pair.",
}, []string{"source", "provider", "symbol"}),
}
})
return globalMetricsRef
}
func (m *serviceMetrics) observePoll(duration time.Duration, err error) {
if m == nil {
return
}
result := labelForError(err)
m.pollDuration.WithLabelValues(result).Observe(duration.Seconds())
m.pollTotal.WithLabelValues(result).Inc()
}
func (m *serviceMetrics) observePair(pair config.Pair, duration time.Duration, err error) {
if m == nil {
return
}
result := labelForError(err)
labels := []string{pair.Source.String(), pair.Provider, pair.Symbol, result}
m.pairDuration.WithLabelValues(labels...).Observe(duration.Seconds())
m.pairTotal.WithLabelValues(labels...).Inc()
if err == nil {
m.pairLastUpdate.WithLabelValues(pair.Source.String(), pair.Provider, pair.Symbol).
Set(float64(time.Now().Unix()))
}
}
func labelForError(err error) string {
if err != nil {
return "error"
}
return "success"
}

View File

@@ -0,0 +1,207 @@
package ingestor
import (
"context"
"math/big"
"time"
"github.com/tech/sendico/fx/ingestor/internal/config"
"github.com/tech/sendico/fx/ingestor/internal/fmerrors"
"github.com/tech/sendico/fx/ingestor/internal/market"
mmodel "github.com/tech/sendico/fx/ingestor/internal/model"
"github.com/tech/sendico/fx/storage"
"github.com/tech/sendico/fx/storage/model"
"github.com/tech/sendico/pkg/mlogger"
"go.uber.org/zap"
)
type Service struct {
logger mlogger.Logger
cfg *config.Config
rates storage.RatesStore
pairs []config.Pair
connectors map[mmodel.Driver]mmodel.Connector
metrics *serviceMetrics
}
func New(logger mlogger.Logger, cfg *config.Config, repo storage.Repository) (*Service, error) {
if logger == nil {
return nil, fmerrors.New("ingestor: nil logger")
}
if cfg == nil {
return nil, fmerrors.New("ingestor: nil config")
}
if repo == nil {
return nil, fmerrors.New("ingestor: nil repository")
}
connectors, err := market.BuildConnectors(logger, cfg.Market.Sources)
if err != nil {
return nil, fmerrors.Wrap("build connectors", err)
}
return &Service{
logger: logger.Named("ingestor"),
cfg: cfg,
rates: repo.Rates(),
pairs: cfg.Pairs(),
connectors: connectors,
metrics: getServiceMetrics(),
}, nil
}
func (s *Service) Run(ctx context.Context) error {
interval := s.cfg.PollInterval()
ticker := time.NewTicker(interval)
defer ticker.Stop()
s.logger.Info("FX ingestion service started", zap.Duration("poll_interval", interval), zap.Int("pairs", len(s.pairs)))
if err := s.executePoll(ctx); err != nil {
s.logger.Warn("Initial poll completed with errors", zap.Error(err))
}
for {
select {
case <-ctx.Done():
s.logger.Info("Context cancelled, stopping ingestor")
return ctx.Err()
case <-ticker.C:
if err := s.executePoll(ctx); err != nil {
s.logger.Warn("Polling cycle completed with errors", zap.Error(err))
}
}
}
}
func (s *Service) executePoll(ctx context.Context) error {
start := time.Now()
err := s.pollOnce(ctx)
if s.metrics != nil {
s.metrics.observePoll(time.Since(start), err)
}
return err
}
func (s *Service) pollOnce(ctx context.Context) error {
var firstErr error
for _, pair := range s.pairs {
start := time.Now()
err := s.upsertPair(ctx, pair)
elapsed := time.Since(start)
if s.metrics != nil {
s.metrics.observePair(pair, elapsed, err)
}
if err != nil {
if firstErr == nil {
firstErr = err
}
s.logger.Warn("Failed to ingest pair",
zap.String("symbol", pair.Symbol),
zap.String("source", pair.Source.String()),
zap.Duration("elapsed", elapsed),
zap.Error(err),
)
}
}
return firstErr
}
func (s *Service) upsertPair(ctx context.Context, pair config.Pair) error {
connector, ok := s.connectors[pair.Source]
if !ok {
return fmerrors.Wrap("connector not configured for source "+pair.Source.String(), nil)
}
ticker, err := connector.FetchTicker(ctx, pair.Symbol)
if err != nil {
return fmerrors.Wrap("fetch ticker", err)
}
bid, err := parseDecimal(ticker.BidPrice)
if err != nil {
return fmerrors.Wrap("parse bid price", err)
}
ask, err := parseDecimal(ticker.AskPrice)
if err != nil {
return fmerrors.Wrap("parse ask price", err)
}
if pair.Invert {
bid, ask = invertPrices(bid, ask)
}
if ask.Cmp(bid) < 0 {
// Ensure bid <= ask to keep downstream logic predictable.
bid, ask = ask, bid
}
mid := new(big.Rat).Add(bid, ask)
mid.Quo(mid, big.NewRat(2, 1))
spread := big.NewRat(0, 1)
if mid.Sign() != 0 {
spread.Sub(ask, bid)
if spread.Sign() < 0 {
spread.Neg(spread)
}
spread.Quo(spread, mid)
spread.Mul(spread, big.NewRat(10000, 1)) // basis points
}
now := time.Now().UTC()
asOf := now
snapshot := &model.RateSnapshot{
RateRef: market.BuildRateReference(pair.Provider, pair.Symbol, now),
Pair: model.CurrencyPair{Base: pair.Base, Quote: pair.Quote},
Provider: pair.Provider,
Mid: formatDecimal(mid),
Bid: formatDecimal(bid),
Ask: formatDecimal(ask),
SpreadBps: formatDecimal(spread),
AsOfUnixMs: now.UnixMilli(),
AsOf: &asOf,
Source: ticker.Provider,
ProviderRef: ticker.Symbol,
}
if err := s.rates.UpsertSnapshot(ctx, snapshot); err != nil {
return fmerrors.Wrap("upsert snapshot", err)
}
s.logger.Debug("Snapshot ingested",
zap.String("pair", pair.Base+"/"+pair.Quote),
zap.String("provider", pair.Provider),
zap.String("bid", snapshot.Bid),
zap.String("ask", snapshot.Ask),
zap.String("mid", snapshot.Mid),
)
return nil
}
func parseDecimal(value string) (*big.Rat, error) {
r := new(big.Rat)
if _, ok := r.SetString(value); !ok {
return nil, fmerrors.NewDecimal(value)
}
return r, nil
}
func invertPrices(bid, ask *big.Rat) (*big.Rat, *big.Rat) {
if bid.Sign() == 0 || ask.Sign() == 0 {
return bid, ask
}
one := big.NewRat(1, 1)
invBid := new(big.Rat).Quo(one, ask) // invert ask to get bid
invAsk := new(big.Rat).Quo(one, bid) // invert bid to get ask
return invBid, invAsk
}
func formatDecimal(r *big.Rat) string {
if r == nil {
return "0"
}
// Format with 8 decimal places, trimming trailing zeros.
return r.FloatString(8)
}

View File

@@ -0,0 +1,237 @@
package ingestor
import (
"context"
"errors"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/tech/sendico/fx/ingestor/internal/config"
"github.com/tech/sendico/fx/ingestor/internal/fmerrors"
mmarket "github.com/tech/sendico/fx/ingestor/internal/model"
"github.com/tech/sendico/fx/storage"
"github.com/tech/sendico/fx/storage/model"
"go.uber.org/zap"
)
func TestParseDecimal(t *testing.T) {
got, err := parseDecimal("123.456")
if err != nil {
t.Fatalf("parseDecimal returned error: %v", err)
}
if got.String() != "15432/125" { // 123.456 expressed as a rational
t.Fatalf("unexpected rational value: %s", got.String())
}
if _, err := parseDecimal("not-a-number"); err == nil {
t.Fatalf("parseDecimal should fail on invalid decimal string")
}
}
func TestInvertPrices(t *testing.T) {
bid, err := parseDecimal("2")
if err != nil {
t.Fatalf("parseDecimal: %v", err)
}
ask, err := parseDecimal("4")
if err != nil {
t.Fatalf("parseDecimal: %v", err)
}
invBid, invAsk := invertPrices(bid, ask)
if diff := cmp.Diff("0.5", invAsk.FloatString(1)); diff != "" {
t.Fatalf("unexpected inverted ask (-want +got):\n%s", diff)
}
if diff := cmp.Diff("0.25", invBid.FloatString(2)); diff != "" {
t.Fatalf("unexpected inverted bid (-want +got):\n%s", diff)
}
}
func TestServiceUpsertPairStoresSnapshot(t *testing.T) {
store := &ratesStoreStub{}
svc := testService(store, map[mmarket.Driver]mmarket.Connector{
mmarket.DriverBinance: &connectorStub{
id: mmarket.DriverBinance,
ticker: &mmarket.Ticker{
Symbol: "EURUSDT",
BidPrice: "1.0000",
AskPrice: "1.2000",
Provider: "binance",
},
},
})
pair := config.Pair{
PairConfig: config.PairConfig{
Base: "USDT",
Quote: "EUR",
Symbol: "EURUSDT",
Provider: "binance",
},
Source: mmarket.DriverBinance,
}
if err := svc.upsertPair(context.Background(), pair); err != nil {
t.Fatalf("upsertPair returned error: %v", err)
}
if len(store.snapshots) != 1 {
t.Fatalf("expected 1 snapshot stored, got %d", len(store.snapshots))
}
snap := store.snapshots[0]
if snap.Pair.Base != "USDT" || snap.Pair.Quote != "EUR" {
t.Fatalf("unexpected currency pair stored: %+v", snap.Pair)
}
if snap.Provider != "binance" {
t.Fatalf("unexpected provider: %s", snap.Provider)
}
if snap.Bid != "1.00000000" || snap.Ask != "1.20000000" {
t.Fatalf("unexpected bid/ask: %s / %s", snap.Bid, snap.Ask)
}
if snap.Mid != "1.10000000" {
t.Fatalf("unexpected mid price: %s", snap.Mid)
}
if snap.SpreadBps != "1818.18181818" {
t.Fatalf("unexpected spread bps: %s", snap.SpreadBps)
}
}
func TestServiceUpsertPairInvertsPrices(t *testing.T) {
store := &ratesStoreStub{}
svc := testService(store, map[mmarket.Driver]mmarket.Connector{
mmarket.DriverCoinGecko: &connectorStub{
id: mmarket.DriverCoinGecko,
ticker: &mmarket.Ticker{
Symbol: "RUBUSDT",
BidPrice: "2",
AskPrice: "4",
Provider: "coingecko",
},
},
})
pair := config.Pair{
PairConfig: config.PairConfig{
Base: "RUB",
Quote: "USDT",
Symbol: "RUBUSDT",
Provider: "coingecko",
Invert: true,
},
Source: mmarket.DriverCoinGecko,
}
if err := svc.upsertPair(context.Background(), pair); err != nil {
t.Fatalf("upsertPair returned error: %v", err)
}
snap := store.snapshots[0]
if snap.Bid != "0.25000000" || snap.Ask != "0.50000000" {
t.Fatalf("unexpected inverted bid/ask: %s / %s", snap.Bid, snap.Ask)
}
}
func TestServicePollOnceReturnsFirstError(t *testing.T) {
errFetch := fmerrors.New("fetch failed")
connectorSuccess := &connectorStub{
id: mmarket.DriverBinance,
ticker: &mmarket.Ticker{
Symbol: "EURUSDT",
BidPrice: "1",
AskPrice: "1",
Provider: "binance",
},
}
connectorFail := &connectorStub{
id: mmarket.DriverCoinGecko,
err: errFetch,
}
store := &ratesStoreStub{}
svc := testService(store, map[mmarket.Driver]mmarket.Connector{
mmarket.DriverBinance: connectorSuccess,
mmarket.DriverCoinGecko: connectorFail,
})
svc.pairs = []config.Pair{
{PairConfig: config.PairConfig{Base: "USDT", Quote: "EUR", Symbol: "EURUSDT"}, Source: mmarket.DriverBinance},
{PairConfig: config.PairConfig{Base: "USDT", Quote: "RUB", Symbol: "RUBUSDT"}, Source: mmarket.DriverCoinGecko},
}
err := svc.pollOnce(context.Background())
if err == nil {
t.Fatalf("pollOnce expected to return error")
}
if !errors.Is(err, errFetch) {
t.Fatalf("pollOnce returned unexpected error: %v", err)
}
if connectorSuccess.calls != 1 {
t.Fatalf("expected success connector called once, got %d", connectorSuccess.calls)
}
if connectorFail.calls != 1 {
t.Fatalf("expected failing connector called once, got %d", connectorFail.calls)
}
if len(store.snapshots) != 1 {
t.Fatalf("expected snapshot stored only for successful pair, got %d", len(store.snapshots))
}
}
// -- test helpers -----------------------------------------------------------------
type ratesStoreStub struct {
snapshots []*model.RateSnapshot
err error
}
func (r *ratesStoreStub) UpsertSnapshot(_ context.Context, snapshot *model.RateSnapshot) error {
if r.err != nil {
return r.err
}
cp := *snapshot
r.snapshots = append(r.snapshots, &cp)
return nil
}
func (r *ratesStoreStub) LatestSnapshot(context.Context, model.CurrencyPair, string) (*model.RateSnapshot, error) {
return nil, nil
}
type repositoryStub struct {
rates storage.RatesStore
}
func (r *repositoryStub) Ping(context.Context) error { return nil }
func (r *repositoryStub) Rates() storage.RatesStore { return r.rates }
func (r *repositoryStub) Quotes() storage.QuotesStore { return nil }
func (r *repositoryStub) Pairs() storage.PairStore { return nil }
func (r *repositoryStub) Currencies() storage.CurrencyStore { return nil }
type connectorStub struct {
id mmarket.Driver
ticker *mmarket.Ticker
err error
calls int
}
func (c *connectorStub) ID() mmarket.Driver {
return c.id
}
func (c *connectorStub) FetchTicker(_ context.Context, symbol string) (*mmarket.Ticker, error) {
c.calls++
if c.ticker != nil {
cp := *c.ticker
cp.Symbol = symbol
return &cp, c.err
}
return nil, c.err
}
func testService(store storage.RatesStore, connectors map[mmarket.Driver]mmarket.Connector) *Service {
return &Service{
logger: zap.NewNop(),
cfg: &config.Config{},
rates: store,
connectors: connectors,
pairs: nil,
metrics: nil,
}
}

View File

@@ -0,0 +1,139 @@
package binance
import (
"context"
"encoding/json"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/tech/sendico/fx/ingestor/internal/fmerrors"
"github.com/tech/sendico/fx/ingestor/internal/market/common"
mmodel "github.com/tech/sendico/fx/ingestor/internal/model"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
"go.uber.org/zap"
)
type binanceConnector struct {
id mmodel.Driver
provider string
client *http.Client
base string
logger mlogger.Logger
}
const defaultBinanceBaseURL = "https://api.binance.com"
const (
defaultDialTimeoutSeconds = 5 * time.Second
defaultDialKeepAliveSeconds = 30 * time.Second
defaultTLSHandshakeTimeoutSeconds = 5 * time.Second
defaultResponseHeaderTimeoutSeconds = 10 * time.Second
defaultRequestTimeoutSeconds = 10 * time.Second
)
func NewConnector(logger mlogger.Logger, settings model.SettingsT) (mmodel.Connector, error) {
baseURL := defaultBinanceBaseURL
provider := strings.ToLower(mmodel.DriverBinance.String())
dialTimeout := defaultDialTimeoutSeconds
dialKeepAlive := defaultDialKeepAliveSeconds
tlsHandshakeTimeout := defaultTLSHandshakeTimeoutSeconds
responseHeaderTimeout := defaultResponseHeaderTimeoutSeconds
requestTimeout := defaultRequestTimeoutSeconds
if settings != nil {
if value, ok := settings["base_url"].(string); ok && strings.TrimSpace(value) != "" {
baseURL = strings.TrimSpace(value)
}
if value, ok := settings["provider"].(string); ok && strings.TrimSpace(value) != "" {
provider = strings.TrimSpace(value)
}
dialTimeout = common.DurationSetting(settings, "dial_timeout_seconds", dialTimeout)
dialKeepAlive = common.DurationSetting(settings, "dial_keep_alive_seconds", dialKeepAlive)
tlsHandshakeTimeout = common.DurationSetting(settings, "tls_handshake_timeout_seconds", tlsHandshakeTimeout)
responseHeaderTimeout = common.DurationSetting(settings, "response_header_timeout_seconds", responseHeaderTimeout)
requestTimeout = common.DurationSetting(settings, "request_timeout_seconds", requestTimeout)
}
parsed, err := url.Parse(baseURL)
if err != nil {
return nil, fmerrors.Wrap("binance: invalid base url", err)
}
transport := &http.Transport{
DialContext: (&net.Dialer{Timeout: dialTimeout, KeepAlive: dialKeepAlive}).DialContext,
TLSHandshakeTimeout: tlsHandshakeTimeout,
ResponseHeaderTimeout: responseHeaderTimeout,
}
connector := &binanceConnector{
id: mmodel.DriverBinance,
provider: provider,
client: &http.Client{
Timeout: requestTimeout,
Transport: transport,
},
base: parsed.String(),
logger: logger.Named("binance"),
}
return connector, nil
}
func (c *binanceConnector) ID() mmodel.Driver {
return c.id
}
func (c *binanceConnector) FetchTicker(ctx context.Context, symbol string) (*mmodel.Ticker, error) {
if strings.TrimSpace(symbol) == "" {
return nil, fmerrors.New("binance: symbol is empty")
}
endpoint, err := url.Parse(c.base)
if err != nil {
return nil, fmerrors.Wrap("binance: parse base url", err)
}
endpoint.Path = "/api/v3/ticker/bookTicker"
query := endpoint.Query()
query.Set("symbol", strings.ToUpper(strings.TrimSpace(symbol)))
endpoint.RawQuery = query.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
if err != nil {
return nil, fmerrors.Wrap("binance: build request", err)
}
resp, err := c.client.Do(req)
if err != nil {
c.logger.Warn("Binance request failed", zap.String("symbol", symbol), zap.Error(err))
return nil, fmerrors.Wrap("binance: request failed", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
c.logger.Warn("Binance returned non-OK status", zap.String("symbol", symbol), zap.Int("status", resp.StatusCode))
return nil, fmerrors.New("binance: unexpected status " + strconv.Itoa(resp.StatusCode))
}
var payload struct {
Symbol string `json:"symbol"`
BidPrice string `json:"bidPrice"`
AskPrice string `json:"askPrice"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
c.logger.Warn("Binance decode failed", zap.String("symbol", symbol), zap.Error(err))
return nil, fmerrors.Wrap("binance: decode response", err)
}
return &mmodel.Ticker{
Symbol: payload.Symbol,
BidPrice: payload.BidPrice,
AskPrice: payload.AskPrice,
Provider: c.provider,
Timestamp: time.Now().UnixMilli(),
}, nil
}

View File

@@ -0,0 +1,222 @@
package coingecko
import (
"context"
"encoding/json"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/tech/sendico/fx/ingestor/internal/fmerrors"
"github.com/tech/sendico/fx/ingestor/internal/market/common"
mmodel "github.com/tech/sendico/fx/ingestor/internal/model"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
"go.uber.org/zap"
)
type coingeckoConnector struct {
id mmodel.Driver
provider string
client *http.Client
base string
logger mlogger.Logger
}
const defaultCoinGeckoBaseURL = "https://api.coingecko.com/api/v3"
const (
defaultDialTimeoutSeconds = 5 * time.Second
defaultDialKeepAliveSeconds = 30 * time.Second
defaultTLSHandshakeTimeoutSeconds = 5 * time.Second
defaultResponseHeaderTimeoutSeconds = 10 * time.Second
defaultRequestTimeoutSeconds = 10 * time.Second
)
func NewConnector(logger mlogger.Logger, settings model.SettingsT) (mmodel.Connector, error) {
baseURL := defaultCoinGeckoBaseURL
provider := strings.ToLower(mmodel.DriverCoinGecko.String())
dialTimeout := defaultDialTimeoutSeconds
dialKeepAlive := defaultDialKeepAliveSeconds
tlsHandshakeTimeout := defaultTLSHandshakeTimeoutSeconds
responseHeaderTimeout := defaultResponseHeaderTimeoutSeconds
requestTimeout := defaultRequestTimeoutSeconds
if settings != nil {
if value, ok := settings["base_url"].(string); ok && strings.TrimSpace(value) != "" {
baseURL = strings.TrimSpace(value)
}
if value, ok := settings["provider"].(string); ok && strings.TrimSpace(value) != "" {
provider = strings.TrimSpace(value)
}
dialTimeout = common.DurationSetting(settings, "dial_timeout_seconds", dialTimeout)
dialKeepAlive = common.DurationSetting(settings, "dial_keep_alive_seconds", dialKeepAlive)
tlsHandshakeTimeout = common.DurationSetting(settings, "tls_handshake_timeout_seconds", tlsHandshakeTimeout)
responseHeaderTimeout = common.DurationSetting(settings, "response_header_timeout_seconds", responseHeaderTimeout)
requestTimeout = common.DurationSetting(settings, "request_timeout_seconds", requestTimeout)
}
parsed, err := url.Parse(baseURL)
if err != nil {
return nil, fmerrors.Wrap("coingecko: invalid base url", err)
}
transport := &http.Transport{
DialContext: (&net.Dialer{Timeout: dialTimeout, KeepAlive: dialKeepAlive}).DialContext,
TLSHandshakeTimeout: tlsHandshakeTimeout,
ResponseHeaderTimeout: responseHeaderTimeout,
}
connector := &coingeckoConnector{
id: mmodel.DriverCoinGecko,
provider: provider,
client: &http.Client{
Timeout: requestTimeout,
Transport: transport,
},
base: strings.TrimRight(parsed.String(), "/"),
logger: logger.Named("coingecko"),
}
return connector, nil
}
func (c *coingeckoConnector) ID() mmodel.Driver {
return c.id
}
func (c *coingeckoConnector) FetchTicker(ctx context.Context, symbol string) (*mmodel.Ticker, error) {
coinID, vsCurrency, err := parseSymbol(symbol)
if err != nil {
return nil, err
}
endpoint, err := url.Parse(c.base)
if err != nil {
return nil, fmerrors.Wrap("coingecko: parse base url", err)
}
endpoint.Path = strings.TrimRight(endpoint.Path, "/") + "/simple/price"
query := endpoint.Query()
query.Set("ids", coinID)
query.Set("vs_currencies", vsCurrency)
query.Set("include_last_updated_at", "true")
endpoint.RawQuery = query.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
if err != nil {
return nil, fmerrors.Wrap("coingecko: build request", err)
}
resp, err := c.client.Do(req)
if err != nil {
c.logger.Warn("CoinGecko request failed", zap.String("symbol", symbol), zap.Error(err))
return nil, fmerrors.Wrap("coingecko: request failed", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
c.logger.Warn("CoinGecko returned non-OK status", zap.String("symbol", symbol), zap.Int("status", resp.StatusCode))
return nil, fmerrors.New("coingecko: unexpected status " + strconv.Itoa(resp.StatusCode))
}
decoder := json.NewDecoder(resp.Body)
decoder.UseNumber()
var payload map[string]map[string]interface{}
if err := decoder.Decode(&payload); err != nil {
c.logger.Warn("CoinGecko decode failed", zap.String("symbol", symbol), zap.Error(err))
return nil, fmerrors.Wrap("coingecko: decode response", err)
}
coinData, ok := payload[coinID]
if !ok {
return nil, fmerrors.New("coingecko: coin id not found in response")
}
priceValue, ok := coinData[vsCurrency]
if !ok {
return nil, fmerrors.New("coingecko: vs currency not found in response")
}
price, ok := toFloat(priceValue)
if !ok || price <= 0 {
return nil, fmerrors.New("coingecko: invalid price value in response")
}
priceStr := strconv.FormatFloat(price, 'f', -1, 64)
timestamp := time.Now().UnixMilli()
if tsValue, ok := coinData["last_updated_at"]; ok {
if tsFloat, ok := toFloat(tsValue); ok && tsFloat > 0 {
tsMillis := int64(tsFloat * 1000)
if tsMillis > 0 {
timestamp = tsMillis
}
}
}
refSymbol := coinID + "_" + vsCurrency
return &mmodel.Ticker{
Symbol: refSymbol,
BidPrice: priceStr,
AskPrice: priceStr,
Provider: c.provider,
Timestamp: timestamp,
}, nil
}
func parseSymbol(symbol string) (string, string, error) {
trimmed := strings.TrimSpace(symbol)
if trimmed == "" {
return "", "", fmerrors.New("coingecko: symbol is empty")
}
parts := strings.FieldsFunc(strings.ToLower(trimmed), func(r rune) bool {
switch r {
case ':', '/', '-', '_':
return true
}
return false
})
if len(parts) != 2 {
return "", "", fmerrors.New("coingecko: symbol must be <coin_id>/<vs_currency>")
}
coinID := strings.TrimSpace(parts[0])
vsCurrency := strings.TrimSpace(parts[1])
if coinID == "" || vsCurrency == "" {
return "", "", fmerrors.New("coingecko: symbol contains empty segments")
}
return coinID, vsCurrency, nil
}
func toFloat(value interface{}) (float64, bool) {
switch v := value.(type) {
case json.Number:
f, err := v.Float64()
if err != nil {
return 0, false
}
return f, true
case float64:
return v, true
case float32:
return float64(v), true
case int:
return float64(v), true
case int64:
return float64(v), true
case uint64:
return float64(v), true
case string:
if parsed, err := strconv.ParseFloat(v, 64); err == nil {
return parsed, true
}
}
return 0, false
}

View File

@@ -0,0 +1,46 @@
package common
import (
"strconv"
"time"
"github.com/tech/sendico/pkg/model"
)
// DurationSetting reads a positive duration override from settings or returns def when the value is missing or invalid.
func DurationSetting(settings model.SettingsT, key string, def time.Duration) time.Duration {
if settings == nil {
return def
}
value, ok := settings[key]
if !ok {
return def
}
switch v := value.(type) {
case time.Duration:
if v > 0 {
return v
}
case int:
if v > 0 {
return time.Duration(v) * time.Second
}
case int64:
if v > 0 {
return time.Duration(v) * time.Second
}
case float64:
if v > 0 {
return time.Duration(v * float64(time.Second))
}
case string:
if parsed, err := time.ParseDuration(v); err == nil && parsed > 0 {
return parsed
}
if seconds, err := strconv.ParseFloat(v, 64); err == nil && seconds > 0 {
return time.Duration(seconds * float64(time.Second))
}
}
return def
}

View File

@@ -0,0 +1,55 @@
package market
import (
"strconv"
"strings"
"time"
"github.com/tech/sendico/fx/ingestor/internal/fmerrors"
"github.com/tech/sendico/fx/ingestor/internal/market/binance"
"github.com/tech/sendico/fx/ingestor/internal/market/coingecko"
mmodel "github.com/tech/sendico/fx/ingestor/internal/model"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
)
type ConnectorFactory func(logger mlogger.Logger, settings model.SettingsT) (mmodel.Connector, error)
func BuildConnectors(logger mlogger.Logger, configs []model.DriverConfig[mmodel.Driver]) (map[mmodel.Driver]mmodel.Connector, error) {
connectors := make(map[mmodel.Driver]mmodel.Connector, len(configs))
for _, cfg := range configs {
driver := mmodel.NormalizeDriver(cfg.Driver)
if driver.IsEmpty() {
return nil, fmerrors.New("market: connector driver is empty")
}
var (
conn mmodel.Connector
err error
)
switch driver {
case mmodel.DriverBinance:
conn, err = binance.NewConnector(logger, cfg.Settings)
case mmodel.DriverCoinGecko:
conn, err = coingecko.NewConnector(logger, cfg.Settings)
default:
err = fmerrors.New("market: unsupported driver " + driver.String())
}
if err != nil {
return nil, fmerrors.Wrap("market: build connector "+driver.String(), err)
}
connectors[driver] = conn
}
return connectors, nil
}
func BuildRateReference(provider, symbol string, now time.Time) string {
if strings.TrimSpace(provider) == "" {
provider = "unknown"
}
return provider + ":" + symbol + ":" + strconv.FormatInt(now.UnixMilli(), 10)
}

View File

@@ -0,0 +1,134 @@
package metrics
import (
"context"
"errors"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/tech/sendico/fx/ingestor/internal/config"
"github.com/tech/sendico/fx/ingestor/internal/fmerrors"
"github.com/tech/sendico/pkg/api/routers"
"github.com/tech/sendico/pkg/api/routers/health"
"github.com/tech/sendico/pkg/mlogger"
"github.com/prometheus/client_golang/prometheus/promhttp"
"go.uber.org/zap"
)
const (
defaultAddress = ":9102"
readHeaderTimeout = 5 * time.Second
defaultShutdownWindow = 5 * time.Second
)
type Server interface {
SetStatus(health.ServiceStatus)
Close(context.Context)
}
func NewServer(logger mlogger.Logger, cfg *config.MetricsConfig) (Server, error) {
if logger == nil {
return nil, fmerrors.New("metrics: logger is nil")
}
if cfg == nil || !cfg.Enabled {
logger.Debug("Metrics disabled; using noop server")
return noopServer{}, nil
}
address := strings.TrimSpace(cfg.Address)
if address == "" {
address = defaultAddress
}
metricsLogger := logger.Named("metrics")
router := chi.NewRouter()
router.Handle("/metrics", promhttp.Handler())
var healthRouter routers.Health
if hr, err := routers.NewHealthRouter(metricsLogger, router, ""); err != nil {
metricsLogger.Warn("Failed to initialise health router", zap.Error(err))
} else {
hr.SetStatus(health.SSStarting)
healthRouter = hr
}
httpServer := &http.Server{
Addr: address,
Handler: router,
ReadHeaderTimeout: readHeaderTimeout,
}
ms := &httpServerWrapper{
logger: metricsLogger,
server: httpServer,
health: healthRouter,
timeout: defaultShutdownWindow,
}
go func() {
metricsLogger.Info("Prometheus endpoint listening", zap.String("address", address))
if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
metricsLogger.Error("Prometheus endpoint stopped unexpectedly", zap.Error(err))
if healthRouter != nil {
healthRouter.SetStatus(health.SSTerminating)
}
}
}()
return ms, nil
}
type httpServerWrapper struct {
logger mlogger.Logger
server *http.Server
health routers.Health
timeout time.Duration
}
func (s *httpServerWrapper) SetStatus(status health.ServiceStatus) {
if s == nil || s.health == nil {
return
}
s.logger.Debug("Updating metrics health status", zap.String("status", string(status)))
s.health.SetStatus(status)
}
func (s *httpServerWrapper) Close(ctx context.Context) {
if s == nil {
return
}
if s.health != nil {
s.health.SetStatus(health.SSTerminating)
s.health.Finish()
s.health = nil
}
if s.server == nil {
return
}
shutdownCtx := ctx
if shutdownCtx == nil {
shutdownCtx = context.Background()
}
if s.timeout > 0 {
var cancel context.CancelFunc
shutdownCtx, cancel = context.WithTimeout(shutdownCtx, s.timeout)
defer cancel()
}
if err := s.server.Shutdown(shutdownCtx); err != nil && !errors.Is(err, http.ErrServerClosed) {
s.logger.Warn("Failed to stop metrics server", zap.Error(err))
} else {
s.logger.Info("Metrics server stopped")
}
}
type noopServer struct{}
func (noopServer) SetStatus(health.ServiceStatus) {}
func (noopServer) Close(context.Context) {}

View File

@@ -0,0 +1,30 @@
package model
import (
"context"
"strings"
)
type Driver string
const (
DriverBinance Driver = "BINANCE"
DriverCoinGecko Driver = "COINGECKO"
)
func (d Driver) String() string {
return string(d)
}
func (d Driver) IsEmpty() bool {
return strings.TrimSpace(string(d)) == ""
}
func NormalizeDriver(d Driver) Driver {
return Driver(strings.ToUpper(strings.TrimSpace(string(d))))
}
type Connector interface {
ID() Driver
FetchTicker(ctx context.Context, symbol string) (*Ticker, error)
}

View File

@@ -0,0 +1,9 @@
package model
type Ticker struct {
Symbol string
BidPrice string
AskPrice string
Provider string
Timestamp int64
}

View File

@@ -0,0 +1,14 @@
package signalctx
import (
"context"
"os"
"os/signal"
)
func WithSignals(parent context.Context, sig ...os.Signal) (context.Context, context.CancelFunc) {
if parent == nil {
parent = context.Background()
}
return signal.NotifyContext(parent, sig...)
}

55
api/fx/ingestor/main.go Normal file
View File

@@ -0,0 +1,55 @@
package main
import (
"context"
"errors"
"flag"
"fmt"
"os"
"syscall"
"github.com/tech/sendico/fx/ingestor/internal/app"
"github.com/tech/sendico/fx/ingestor/internal/appversion"
"github.com/tech/sendico/fx/ingestor/internal/signalctx"
lf "github.com/tech/sendico/pkg/mlogger/factory"
"go.uber.org/zap"
)
var (
configFile = flag.String("config.file", app.DefaultConfigPath, "Path to the configuration file.")
debugFlag = flag.Bool("debug", false, "Enable debug logging.")
versionFlag = flag.Bool("version", false, "Show version information.")
)
func main() {
flag.Parse()
logger := lf.NewLogger(*debugFlag).Named("fx_ingestor")
defer logger.Sync()
av := appversion.Create()
if *versionFlag {
fmt.Fprintln(os.Stdout, av.Print())
return
}
logger.Info(fmt.Sprintf("Starting %s", av.Program()), zap.String("version", av.Info()))
ctx, cancel := signalctx.WithSignals(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
application, err := app.New(logger, *configFile)
if err != nil {
logger.Fatal("Failed to initialise application", zap.Error(err))
}
if err := application.Run(ctx); err != nil {
if errors.Is(err, context.Canceled) {
logger.Info("FX ingestor stopped")
return
}
logger.Fatal("Ingestor terminated with error", zap.Error(err))
}
logger.Info("FX ingestor stopped")
}

32
api/fx/oracle/.air.toml Normal file
View File

@@ -0,0 +1,32 @@
# Config file for Air in TOML format
root = "./../.."
tmp_dir = "tmp"
[build]
cmd = "go build -o app -ldflags \"-X 'github.com/tech/sendico/fx/oracle/internal/appversion.BuildUser=$(whoami)' -X 'github.com/tech/sendico/fx/oracle/internal/appversion.Version=$APP_V' -X 'github.com/tech/sendico/fx/oracle/internal/appversion.Branch=$BUILD_BRANCH' -X 'github.com/tech/sendico/fx/oracle/internal/appversion.Revision=$GIT_REV' -X 'github.com/tech/sendico/fx/oracle/internal/appversion.BuildDate=$(date)'\""
bin = "./app"
full_bin = "./app --debug --config.file=config.yml"
include_ext = ["go", "yaml", "yml"]
exclude_dir = ["fx/oracle/tmp", "pkg/.git", "fx/oracle/env"]
exclude_regex = ["_test\\.go"]
exclude_unchanged = true
follow_symlink = true
log = "air.log"
delay = 0
stop_on_error = true
send_interrupt = true
kill_delay = 500
args_bin = []
[log]
time = false
[color]
main = "magenta"
watcher = "cyan"
build = "yellow"
runner = "green"
[misc]
clean_on_exit = true

3
api/fx/oracle/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
internal/generated
.gocache
app

View File

@@ -0,0 +1,252 @@
package client
import (
"context"
"crypto/tls"
"fmt"
"strings"
"time"
"github.com/tech/sendico/pkg/merrors"
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"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
)
// Client exposes typed helpers around the oracle gRPC API.
type Client interface {
LatestRate(ctx context.Context, req LatestRateParams) (*RateSnapshot, error)
GetQuote(ctx context.Context, req GetQuoteParams) (*Quote, error)
Close() error
}
// RequestMeta carries optional multi-tenant context for oracle calls.
type RequestMeta struct {
TenantRef string
OrganizationRef string
Trace *tracev1.TraceContext
}
type LatestRateParams struct {
Meta RequestMeta
Pair *fxv1.CurrencyPair
Provider string
}
type RateSnapshot struct {
Pair *fxv1.CurrencyPair
Mid string
Bid string
Ask string
SpreadBps string
Provider string
RateRef string
AsOf time.Time
}
type GetQuoteParams struct {
Meta RequestMeta
Pair *fxv1.CurrencyPair
Side fxv1.Side
BaseAmount *moneyv1.Money
QuoteAmount *moneyv1.Money
Firm bool
TTL time.Duration
PreferredProvider string
MaxAge time.Duration
}
type Quote struct {
QuoteRef string
Pair *fxv1.CurrencyPair
Side fxv1.Side
Price string
BaseAmount *moneyv1.Money
QuoteAmount *moneyv1.Money
ExpiresAt time.Time
Provider string
RateRef string
Firm bool
}
type grpcOracleClient interface {
GetQuote(ctx context.Context, in *oraclev1.GetQuoteRequest, opts ...grpc.CallOption) (*oraclev1.GetQuoteResponse, error)
LatestRate(ctx context.Context, in *oraclev1.LatestRateRequest, opts ...grpc.CallOption) (*oraclev1.LatestRateResponse, error)
}
type oracleClient struct {
cfg Config
conn *grpc.ClientConn
client grpcOracleClient
}
// New dials the oracle endpoint and returns a ready client.
func New(ctx context.Context, cfg Config, opts ...grpc.DialOption) (Client, error) {
cfg.setDefaults()
if strings.TrimSpace(cfg.Address) == "" {
return nil, merrors.InvalidArgument("oracle: address is required")
}
dialCtx, cancel := context.WithTimeout(ctx, cfg.DialTimeout)
defer cancel()
dialOpts := make([]grpc.DialOption, 0, len(opts)+1)
dialOpts = append(dialOpts, opts...)
if cfg.Insecure {
dialOpts = append(dialOpts, grpc.WithTransportCredentials(insecure.NewCredentials()))
} else {
dialOpts = append(dialOpts, grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{})))
}
conn, err := grpc.DialContext(dialCtx, cfg.Address, dialOpts...)
if err != nil {
return nil, merrors.InternalWrap(err, fmt.Sprintf("oracle: dial %s", cfg.Address))
}
return &oracleClient{
cfg: cfg,
conn: conn,
client: oraclev1.NewOracleClient(conn),
}, nil
}
// NewWithClient injects a pre-built oracle client (useful for tests).
func NewWithClient(cfg Config, oc grpcOracleClient) Client {
cfg.setDefaults()
return &oracleClient{
cfg: cfg,
client: oc,
}
}
func (c *oracleClient) Close() error {
if c.conn != nil {
return c.conn.Close()
}
return nil
}
func (c *oracleClient) LatestRate(ctx context.Context, req LatestRateParams) (*RateSnapshot, error) {
if req.Pair == nil {
return nil, merrors.InvalidArgument("oracle: pair is required")
}
callCtx, cancel := c.callContext(ctx)
defer cancel()
resp, err := c.client.LatestRate(callCtx, &oraclev1.LatestRateRequest{
Meta: toProtoMeta(req.Meta),
Pair: req.Pair,
Provider: req.Provider,
})
if err != nil {
return nil, merrors.InternalWrap(err, "oracle: latest rate")
}
if resp.GetRate() == nil {
return nil, merrors.Internal("oracle: latest rate: empty payload")
}
return fromProtoRate(resp.GetRate()), nil
}
func (c *oracleClient) GetQuote(ctx context.Context, req GetQuoteParams) (*Quote, error) {
if req.Pair == nil {
return nil, merrors.InvalidArgument("oracle: pair is required")
}
if req.Side == fxv1.Side_SIDE_UNSPECIFIED {
return nil, merrors.InvalidArgument("oracle: side is required")
}
baseSupplied := req.BaseAmount != nil
quoteSupplied := req.QuoteAmount != nil
if baseSupplied == quoteSupplied {
return nil, merrors.InvalidArgument("oracle: exactly one of base_amount or quote_amount must be set")
}
callCtx, cancel := c.callContext(ctx)
defer cancel()
protoReq := &oraclev1.GetQuoteRequest{
Meta: toProtoMeta(req.Meta),
Pair: req.Pair,
Side: req.Side,
Firm: req.Firm,
PreferredProvider: req.PreferredProvider,
}
if req.TTL > 0 {
protoReq.TtlMs = req.TTL.Milliseconds()
}
if req.MaxAge > 0 {
protoReq.MaxAgeMs = int32(req.MaxAge.Milliseconds())
}
if baseSupplied {
protoReq.AmountInput = &oraclev1.GetQuoteRequest_BaseAmount{BaseAmount: req.BaseAmount}
} else {
protoReq.AmountInput = &oraclev1.GetQuoteRequest_QuoteAmount{QuoteAmount: req.QuoteAmount}
}
resp, err := c.client.GetQuote(callCtx, protoReq)
if err != nil {
return nil, merrors.InternalWrap(err, "oracle: get quote")
}
if resp.GetQuote() == nil {
return nil, merrors.Internal("oracle: get quote: empty payload")
}
return fromProtoQuote(resp.GetQuote()), nil
}
func (c *oracleClient) callContext(ctx context.Context) (context.Context, context.CancelFunc) {
if _, ok := ctx.Deadline(); ok {
return context.WithCancel(ctx)
}
return context.WithTimeout(ctx, c.cfg.CallTimeout)
}
func toProtoMeta(meta RequestMeta) *oraclev1.RequestMeta {
if meta.TenantRef == "" && meta.OrganizationRef == "" && meta.Trace == nil {
return nil
}
return &oraclev1.RequestMeta{
TenantRef: meta.TenantRef,
OrganizationRef: meta.OrganizationRef,
Trace: meta.Trace,
}
}
func fromProtoRate(rate *oraclev1.RateSnapshot) *RateSnapshot {
if rate == nil {
return nil
}
return &RateSnapshot{
Pair: rate.Pair,
Mid: rate.GetMid().GetValue(),
Bid: rate.GetBid().GetValue(),
Ask: rate.GetAsk().GetValue(),
SpreadBps: rate.GetSpreadBps().GetValue(),
Provider: rate.GetProvider(),
RateRef: rate.GetRateRef(),
AsOf: time.UnixMilli(rate.GetAsofUnixMs()),
}
}
func fromProtoQuote(quote *oraclev1.Quote) *Quote {
if quote == nil {
return nil
}
return &Quote{
QuoteRef: quote.GetQuoteRef(),
Pair: quote.Pair,
Side: quote.GetSide(),
Price: quote.GetPrice().GetValue(),
BaseAmount: quote.BaseAmount,
QuoteAmount: quote.QuoteAmount,
ExpiresAt: time.UnixMilli(quote.GetExpiresAtUnixMs()),
Provider: quote.GetProvider(),
RateRef: quote.GetRateRef(),
Firm: quote.GetFirm(),
}
}

View File

@@ -0,0 +1,116 @@
package client
import (
"context"
"testing"
"time"
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
"google.golang.org/grpc"
)
type stubOracle struct {
latestResp *oraclev1.LatestRateResponse
latestErr error
quoteResp *oraclev1.GetQuoteResponse
quoteErr error
lastLatest *oraclev1.LatestRateRequest
lastQuote *oraclev1.GetQuoteRequest
}
func (s *stubOracle) LatestRate(ctx context.Context, in *oraclev1.LatestRateRequest, _ ...grpc.CallOption) (*oraclev1.LatestRateResponse, error) {
s.lastLatest = in
return s.latestResp, s.latestErr
}
func (s *stubOracle) GetQuote(ctx context.Context, in *oraclev1.GetQuoteRequest, _ ...grpc.CallOption) (*oraclev1.GetQuoteResponse, error) {
s.lastQuote = in
return s.quoteResp, s.quoteErr
}
func TestLatestRate(t *testing.T) {
expectedTime := time.Date(2024, 1, 1, 15, 0, 0, 0, time.UTC)
stub := &stubOracle{
latestResp: &oraclev1.LatestRateResponse{
Rate: &oraclev1.RateSnapshot{
Pair: &fxv1.CurrencyPair{Base: "USD", Quote: "EUR"},
Mid: &moneyv1.Decimal{Value: "1.1000"},
Bid: &moneyv1.Decimal{Value: "1.0995"},
Ask: &moneyv1.Decimal{Value: "1.1005"},
SpreadBps: &moneyv1.Decimal{Value: "5"},
Provider: "ECB",
RateRef: "ECB-20240101",
AsofUnixMs: expectedTime.UnixMilli(),
},
},
}
client := NewWithClient(Config{}, stub)
resp, err := client.LatestRate(context.Background(), LatestRateParams{
Meta: RequestMeta{
TenantRef: "tenant",
OrganizationRef: "org",
},
Pair: &fxv1.CurrencyPair{Base: "USD", Quote: "EUR"},
Provider: "ECB",
})
if err != nil {
t.Fatalf("LatestRate returned error: %v", err)
}
if stub.lastLatest.GetProvider() != "ECB" {
t.Fatalf("expected provider to propagate, got %s", stub.lastLatest.GetProvider())
}
if resp.Provider != "ECB" || resp.RateRef != "ECB-20240101" {
t.Fatalf("unexpected response: %+v", resp)
}
if !resp.AsOf.Equal(expectedTime) {
t.Fatalf("expected as-of %s, got %s", expectedTime, resp.AsOf)
}
}
func TestGetQuote(t *testing.T) {
expiresAt := time.Date(2024, 2, 2, 12, 0, 0, 0, time.UTC)
stub := &stubOracle{
quoteResp: &oraclev1.GetQuoteResponse{
Quote: &oraclev1.Quote{
QuoteRef: "quote-123",
Pair: &fxv1.CurrencyPair{Base: "GBP", Quote: "USD"},
Side: fxv1.Side_BUY_BASE_SELL_QUOTE,
Price: &moneyv1.Decimal{Value: "1.2500"},
BaseAmount: &moneyv1.Money{Amount: "100.00", Currency: "GBP"},
QuoteAmount: &moneyv1.Money{Amount: "125.00", Currency: "USD"},
ExpiresAtUnixMs: expiresAt.UnixMilli(),
Provider: "Test",
RateRef: "test-ref",
Firm: true,
},
},
}
client := NewWithClient(Config{}, stub)
resp, err := client.GetQuote(context.Background(), GetQuoteParams{
Pair: &fxv1.CurrencyPair{Base: "GBP", Quote: "USD"},
Side: fxv1.Side_BUY_BASE_SELL_QUOTE,
BaseAmount: &moneyv1.Money{Amount: "100.00", Currency: "GBP"},
Firm: true,
TTL: 2 * time.Second,
})
if err != nil {
t.Fatalf("GetQuote returned error: %v", err)
}
if stub.lastQuote.GetFirm() != true {
t.Fatalf("expected firm flag to propagate")
}
if stub.lastQuote.GetTtlMs() == 0 {
t.Fatalf("expected ttl to be populated")
}
if resp.QuoteRef != "quote-123" || resp.Price != "1.2500" || !resp.ExpiresAt.Equal(expiresAt) {
t.Fatalf("unexpected quote response: %+v", resp)
}
}

View File

@@ -0,0 +1,20 @@
package client
import "time"
// Config captures connection settings for the FX oracle gRPC service.
type Config struct {
Address string
DialTimeout time.Duration
CallTimeout time.Duration
Insecure bool
}
func (c *Config) setDefaults() {
if c.DialTimeout <= 0 {
c.DialTimeout = 5 * time.Second
}
if c.CallTimeout <= 0 {
c.CallTimeout = 3 * time.Second
}
}

View File

@@ -0,0 +1,60 @@
package client
import (
"context"
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
)
// Fake implements Client for tests.
type Fake struct {
LatestRateFn func(ctx context.Context, req LatestRateParams) (*RateSnapshot, error)
GetQuoteFn func(ctx context.Context, req GetQuoteParams) (*Quote, error)
CloseFn func() error
}
func (f *Fake) LatestRate(ctx context.Context, req LatestRateParams) (*RateSnapshot, error) {
if f.LatestRateFn != nil {
return f.LatestRateFn(ctx, req)
}
return &RateSnapshot{
Pair: &fxv1.CurrencyPair{Base: "USD", Quote: "EUR"},
Mid: "1.1000",
Bid: "1.0995",
Ask: "1.1005",
SpreadBps: "5",
Provider: "fake",
RateRef: "fake",
}, nil
}
func (f *Fake) GetQuote(ctx context.Context, req GetQuoteParams) (*Quote, error) {
if f.GetQuoteFn != nil {
return f.GetQuoteFn(ctx, req)
}
return &Quote{
QuoteRef: "fake-quote",
Pair: req.Pair,
Side: req.Side,
Price: "1.1000",
BaseAmount: &moneyv1.Money{
Amount: "100.00",
Currency: req.Pair.GetBase(),
},
QuoteAmount: &moneyv1.Money{
Amount: "110.00",
Currency: req.Pair.GetQuote(),
},
Provider: "fake",
RateRef: "fake",
Firm: req.Firm,
}, nil
}
func (f *Fake) Close() error {
if f.CloseFn != nil {
return f.CloseFn()
}
return nil
}

34
api/fx/oracle/config.yml Normal file
View File

@@ -0,0 +1,34 @@
runtime:
shutdown_timeout_seconds: 15
grpc:
network: tcp
address: ":50051"
enable_reflection: true
enable_health: true
metrics:
address: ":9400"
database:
driver: mongodb
settings:
host_env: FX_MONGO_HOST
port_env: FX_MONGO_PORT
database_env: FX_MONGO_DATABASE
user_env: FX_MONGO_USER
password_env: FX_MONGO_PASSWORD
auth_source_env: FX_MONGO_AUTH_SOURCE
replica_set_env: FX_MONGO_REPLICA_SET
messaging:
driver: NATS
settings:
url_env: NATS_URL
host_env: NATS_HOST
port_env: NATS_PORT
username_env: NATS_USER
password_env: NATS_PASSWORD
broker_name: FX Oracle
max_reconnects: 10
reconnect_wait: 5

1
api/fx/oracle/env/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
.env.api

54
api/fx/oracle/go.mod Normal file
View File

@@ -0,0 +1,54 @@
module github.com/tech/sendico/fx/oracle
go 1.25.3
replace github.com/tech/sendico/pkg => ../../pkg
replace github.com/tech/sendico/fx/storage => ../storage
require (
github.com/google/uuid v1.6.0
github.com/prometheus/client_golang v1.23.2
github.com/tech/sendico/fx/storage v0.0.0
github.com/tech/sendico/pkg v0.1.0
go.mongodb.org/mongo-driver v1.17.6
go.uber.org/zap v1.27.1
google.golang.org/grpc v1.77.0
google.golang.org/protobuf v1.36.10
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
github.com/casbin/casbin/v2 v2.134.0 // indirect
github.com/casbin/govaluate v1.10.0 // indirect
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/go-chi/chi/v5 v5.2.3 // indirect
github.com/golang/snappy v1.0.0 // indirect
github.com/klauspost/compress v1.18.2 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/montanaflynn/stats v0.7.1 // 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/nkeys v0.4.12 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.4 // indirect
github.com/prometheus/procfs v0.19.2 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.2.0 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
)

225
api/fx/oracle/go.sum Normal file
View File

@@ -0,0 +1,225 @@
dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/casbin/casbin/v2 v2.134.0 h1:wyO3hZb487GzlGVAI2hUoHQT0ehFD+9B5P+HVG9BVTM=
github.com/casbin/casbin/v2 v2.134.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
github.com/casbin/mongodb-adapter/v3 v3.7.0 h1:w9c3bea1BGK4eZTAmk17JkY52yv/xSZDSHKji8q+z6E=
github.com/casbin/mongodb-adapter/v3 v3.7.0/go.mod h1:F1mu4ojoJVE/8VhIMxMedhjfwRDdIXgANYs6Sd0MgVA=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v27.3.1+incompatible h1:KttF0XoteNTicmUtBO0L2tP+J7FGRFTjaEF4k6WdhfI=
github.com/docker/docker v27.3.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54 h1:mFWunSatvkQQDhpdyuFAYwyAan3hzCuma+Pz8sqvOfg=
github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
github.com/moby/sys/user v0.3.0 h1:9ni5DlcW5an3SvRSx4MouotOygvzaXbaSrc/wGDFWPo=
github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE=
github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nats-io/nats.go v1.47.0 h1:YQdADw6J/UfGUd2Oy6tn4Hq6YHxCaJrVKayxxFqYrgM=
github.com/nats-io/nats.go v1.47.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/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc=
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/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
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/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/testcontainers/testcontainers-go v0.33.0 h1:zJS9PfXYT5O0ZFXM2xxXfk4J5UMw/kRiISng037Gxdw=
github.com/testcontainers/testcontainers-go v0.33.0/go.mod h1:W80YpTa8D5C3Yy16icheD01UTDu+LmXIA2Keo+jWtT8=
github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0 h1:iXVA84s5hKMS5gn01GWOYHE3ymy/2b+0YkpFeTxB2XY=
github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0/go.mod h1:R6tMjTojRiaoo89fh/hf7tOmfzohdqSU17R9DwSVSog=
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8=
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.mongodb.org/mongo-driver v1.17.6 h1:87JUG1wZfWsr6rIz3ZmpH90rL5tea7O3IHuSwHUpsss=
go.mongodb.org/mongo-driver v1.17.6/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 h1:UP6IpuHFkUgOQL9FFQFrZ+5LiwhhYRbi7VZSIx6Nj5s=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM=
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
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/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
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/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -0,0 +1,27 @@
package appversion
import (
"github.com/tech/sendico/pkg/version"
vf "github.com/tech/sendico/pkg/version/factory"
)
// Build information. Populated at build-time.
var (
Version string
Revision string
Branch string
BuildUser string
BuildDate string
)
func Create() version.Printer {
vi := version.Info{
Program: "Sendico FX Oracle Service",
Revision: Revision,
Branch: Branch,
BuildUser: BuildUser,
BuildDate: BuildDate,
Version: Version,
}
return vf.Create(&vi)
}

View File

@@ -0,0 +1,101 @@
package serverimp
import (
"context"
"os"
"time"
"github.com/tech/sendico/fx/oracle/internal/service/oracle"
"github.com/tech/sendico/fx/storage"
mongostorage "github.com/tech/sendico/fx/storage/mongo"
"github.com/tech/sendico/pkg/api/routers"
"github.com/tech/sendico/pkg/db"
msg "github.com/tech/sendico/pkg/messaging"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/server/grpcapp"
"go.uber.org/zap"
"gopkg.in/yaml.v3"
)
type Imp struct {
logger mlogger.Logger
file string
debug bool
config *grpcapp.Config
app *grpcapp.App[storage.Repository]
}
func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) {
return &Imp{
logger: logger.Named("server"),
file: file,
debug: debug,
}, nil
}
func (i *Imp) Shutdown() {
if i.app == nil {
return
}
timeout := 15 * time.Second
if i.config != nil && i.config.Runtime != nil {
timeout = i.config.Runtime.ShutdownTimeout()
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
i.app.Shutdown(ctx)
cancel()
}
func (i *Imp) Start() error {
cfg, err := i.loadConfig()
if err != nil {
return err
}
i.config = cfg
repoFactory := func(logger mlogger.Logger, conn *db.MongoConnection) (storage.Repository, error) {
return mongostorage.New(logger, conn)
}
serviceFactory := func(logger mlogger.Logger, repo storage.Repository, producer msg.Producer) (grpcapp.Service, error) {
return oracle.NewService(logger, repo, producer), nil
}
app, err := grpcapp.NewApp(i.logger, "fx_oracle", cfg, i.debug, repoFactory, serviceFactory)
if err != nil {
return err
}
i.app = app
return i.app.Start()
}
func (i *Imp) loadConfig() (*grpcapp.Config, error) {
data, err := os.ReadFile(i.file)
if err != nil {
i.logger.Error("Could not read configuration file", zap.String("config_file", i.file), zap.Error(err))
return nil, err
}
cfg := &grpcapp.Config{}
if err := yaml.Unmarshal(data, cfg); err != nil {
i.logger.Error("Failed to parse configuration", zap.Error(err))
return nil, err
}
if cfg.Runtime == nil {
cfg.Runtime = &grpcapp.RuntimeConfig{ShutdownTimeoutSeconds: 15}
}
if cfg.GRPC == nil {
cfg.GRPC = &routers.GRPCConfig{
Network: "tcp",
Address: ":50051",
EnableReflection: true,
EnableHealth: true,
}
}
return cfg, nil
}

View File

@@ -0,0 +1,11 @@
package server
import (
serverimp "github.com/tech/sendico/fx/oracle/internal/server/internal"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/server"
)
func Create(logger mlogger.Logger, file string, debug bool) (server.Application, error) {
return serverimp.Create(logger, file, debug)
}

View File

@@ -0,0 +1,223 @@
package oracle
import (
"math/big"
"strings"
"time"
"github.com/google/uuid"
"github.com/tech/sendico/fx/storage/model"
"github.com/tech/sendico/pkg/merrors"
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.mongodb.org/mongo-driver/bson/primitive"
)
type quoteComputation struct {
pair *model.Pair
rate *model.RateSnapshot
sideProto fxv1.Side
sideModel model.QuoteSide
price *big.Rat
baseInput *big.Rat
quoteInput *big.Rat
amountType model.QuoteAmountType
baseRounded *big.Rat
quoteRounded *big.Rat
priceRounded *big.Rat
baseScale uint32
quoteScale uint32
priceScale uint32
provider string
}
func newQuoteComputation(pair *model.Pair, rate *model.RateSnapshot, side fxv1.Side, provider string) (*quoteComputation, error) {
if pair == nil || rate == nil {
return nil, merrors.InvalidArgument("oracle: missing pair or rate")
}
sideModel := protoSideToModel(side)
if sideModel == "" {
return nil, merrors.InvalidArgument("oracle: unsupported side")
}
price, err := priceFromRate(rate, side)
if err != nil {
return nil, err
}
if strings.TrimSpace(provider) == "" {
provider = rate.Provider
}
return &quoteComputation{
pair: pair,
rate: rate,
sideProto: side,
sideModel: sideModel,
price: price,
baseScale: pair.BaseMeta.Decimals,
quoteScale: pair.QuoteMeta.Decimals,
priceScale: pair.QuoteMeta.Decimals,
provider: provider,
}, nil
}
func (qc *quoteComputation) withBaseInput(m *moneyv1.Money) error {
if m == nil {
return merrors.InvalidArgument("oracle: base amount missing")
}
if !strings.EqualFold(m.GetCurrency(), qc.pair.Pair.Base) {
return merrors.InvalidArgument("oracle: base amount currency mismatch")
}
val, err := ratFromString(m.GetAmount())
if err != nil {
return err
}
qc.baseInput = val
qc.amountType = model.QuoteAmountTypeBase
return nil
}
func (qc *quoteComputation) withQuoteInput(m *moneyv1.Money) error {
if m == nil {
return merrors.InvalidArgument("oracle: quote amount missing")
}
if !strings.EqualFold(m.GetCurrency(), qc.pair.Pair.Quote) {
return merrors.InvalidArgument("oracle: quote amount currency mismatch")
}
val, err := ratFromString(m.GetAmount())
if err != nil {
return err
}
qc.quoteInput = val
qc.amountType = model.QuoteAmountTypeQuote
return nil
}
func (qc *quoteComputation) compute() error {
var baseRaw, quoteRaw *big.Rat
switch qc.amountType {
case model.QuoteAmountTypeBase:
baseRaw = qc.baseInput
quoteRaw = mulRat(qc.baseInput, qc.price)
case model.QuoteAmountTypeQuote:
quoteRaw = qc.quoteInput
base, err := divRat(qc.quoteInput, qc.price)
if err != nil {
return err
}
baseRaw = base
default:
return merrors.InvalidArgument("oracle: amount type not set")
}
var err error
qc.baseRounded, err = roundRatToScale(baseRaw, qc.baseScale, qc.pair.BaseMeta.Rounding)
if err != nil {
return err
}
qc.quoteRounded, err = roundRatToScale(quoteRaw, qc.quoteScale, qc.pair.QuoteMeta.Rounding)
if err != nil {
return err
}
qc.priceRounded, err = roundRatToScale(qc.price, qc.priceScale, qc.pair.QuoteMeta.Rounding)
if err != nil {
return err
}
return nil
}
func (qc *quoteComputation) buildModelQuote(firm bool, expiryMillis int64, req *oraclev1.GetQuoteRequest) (*model.Quote, error) {
if qc.baseRounded == nil || qc.quoteRounded == nil || qc.priceRounded == nil {
return nil, merrors.Internal("oracle: computation not executed")
}
quote := &model.Quote{
QuoteRef: uuid.NewString(),
Firm: firm,
Status: model.QuoteStatusIssued,
Pair: qc.pair.Pair,
Side: qc.sideModel,
Price: formatRat(qc.priceRounded, qc.priceScale),
BaseAmount: model.Money{
Currency: qc.pair.Pair.Base,
Amount: formatRat(qc.baseRounded, qc.baseScale),
},
QuoteAmount: model.Money{
Currency: qc.pair.Pair.Quote,
Amount: formatRat(qc.quoteRounded, qc.quoteScale),
},
AmountType: qc.amountType,
RateRef: qc.rate.RateRef,
Provider: qc.provider,
PreferredProvider: req.GetPreferredProvider(),
RequestedTTLMs: req.GetTtlMs(),
MaxAgeToleranceMs: int64(req.GetMaxAgeMs()),
Meta: buildQuoteMeta(req.GetMeta()),
}
if firm {
quote.ExpiresAtUnixMs = expiryMillis
expiry := time.UnixMilli(expiryMillis)
quote.ExpiresAt = &expiry
}
return quote, nil
}
func buildQuoteMeta(meta *oraclev1.RequestMeta) *model.QuoteMeta {
if meta == nil {
return nil
}
trace := meta.GetTrace()
qm := &model.QuoteMeta{
RequestRef: deriveRequestRef(meta, trace),
TenantRef: meta.GetTenantRef(),
TraceRef: deriveTraceRef(meta, trace),
IdempotencyKey: deriveIdempotencyKey(meta, trace),
}
if org := strings.TrimSpace(meta.GetOrganizationRef()); org != "" {
if objID, err := primitive.ObjectIDFromHex(org); err == nil {
qm.SetOrganizationRef(objID)
}
}
return qm
}
func protoSideToModel(side fxv1.Side) model.QuoteSide {
switch side {
case fxv1.Side_BUY_BASE_SELL_QUOTE:
return model.QuoteSideBuyBaseSellQuote
case fxv1.Side_SELL_BASE_BUY_QUOTE:
return model.QuoteSideSellBaseBuyQuote
default:
return ""
}
}
func computeExpiry(now time.Time, ttlMs int64) (int64, error) {
if ttlMs <= 0 {
return 0, merrors.InvalidArgument("oracle: ttl must be positive")
}
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,221 @@
package oracle
import (
"context"
"fmt"
"math/big"
"strings"
"time"
"github.com/tech/sendico/fx/storage/model"
"github.com/tech/sendico/pkg/merrors"
)
type priceSet struct {
bid *big.Rat
ask *big.Rat
mid *big.Rat
}
func (s *Service) computeCrossRate(ctx context.Context, pair *model.Pair, provider string) (*model.RateSnapshot, error) {
if pair == nil || pair.Cross == nil || !pair.Cross.Enabled {
return nil, merrors.ErrNoData
}
baseSnap, err := s.fetchCrossLegSnapshot(ctx, pair.Cross.BaseLeg, provider)
if err != nil {
return nil, err
}
quoteSnap, err := s.fetchCrossLegSnapshot(ctx, pair.Cross.QuoteLeg, provider)
if err != nil {
return nil, err
}
basePrices, err := buildPriceSet(baseSnap)
if err != nil {
return nil, err
}
quotePrices, err := buildPriceSet(quoteSnap)
if err != nil {
return nil, err
}
if pair.Cross.BaseLeg.Invert {
basePrices, err = invertPriceSet(basePrices)
if err != nil {
return nil, err
}
}
if pair.Cross.QuoteLeg.Invert {
quotePrices, err = invertPriceSet(quotePrices)
if err != nil {
return nil, err
}
}
result := multiplyPriceSets(basePrices, quotePrices)
if result.ask.Cmp(result.bid) < 0 {
result.ask, result.bid = result.bid, result.ask
}
spread := calcSpreadBps(result)
asOfMs := minNonZero(baseSnap.AsOfUnixMs, quoteSnap.AsOfUnixMs)
if asOfMs == 0 {
asOfMs = time.Now().UnixMilli()
}
asOf := time.UnixMilli(asOfMs)
rateRef := fmt.Sprintf("cross|%s/%s|%s|%s+%s", pair.Pair.Base, pair.Pair.Quote, provider, baseSnap.RateRef, quoteSnap.RateRef)
return &model.RateSnapshot{
RateRef: rateRef,
Pair: pair.Pair,
Provider: provider,
Mid: formatPrice(result.mid),
Bid: formatPrice(result.bid),
Ask: formatPrice(result.ask),
SpreadBps: formatPrice(spread),
AsOfUnixMs: asOfMs,
AsOf: &asOf,
Source: "cross_rate",
ProviderRef: rateRef,
}, nil
}
func (s *Service) fetchCrossLegSnapshot(ctx context.Context, leg model.CrossRateLeg, fallbackProvider string) (*model.RateSnapshot, error) {
provider := fallbackProvider
if strings.TrimSpace(leg.Provider) != "" {
provider = leg.Provider
}
if provider == "" {
return nil, merrors.InvalidArgument("oracle: cross leg provider missing")
}
return s.storage.Rates().LatestSnapshot(ctx, leg.Pair, provider)
}
func buildPriceSet(rate *model.RateSnapshot) (priceSet, error) {
if rate == nil {
return priceSet{}, merrors.InvalidArgument("oracle: cross rate requires underlying snapshot")
}
ask, err := parsePrice(rate.Ask)
if err != nil {
return priceSet{}, err
}
bid, err := parsePrice(rate.Bid)
if err != nil {
return priceSet{}, err
}
mid, err := parsePrice(rate.Mid)
if err != nil {
return priceSet{}, err
}
if ask == nil && bid == nil {
if mid == nil {
return priceSet{}, merrors.InvalidArgument("oracle: cross rate snapshot missing price data")
}
ask = new(big.Rat).Set(mid)
bid = new(big.Rat).Set(mid)
}
if ask == nil && mid != nil {
ask = new(big.Rat).Set(mid)
}
if bid == nil && mid != nil {
bid = new(big.Rat).Set(mid)
}
if ask == nil || bid == nil {
return priceSet{}, merrors.InvalidArgument("oracle: cross rate snapshot missing bid/ask data")
}
ps := priceSet{
bid: new(big.Rat).Set(bid),
ask: new(big.Rat).Set(ask),
mid: averageOrMid(bid, ask, mid),
}
if ps.ask.Cmp(ps.bid) < 0 {
ps.ask, ps.bid = ps.bid, ps.ask
}
return ps, nil
}
func parsePrice(value string) (*big.Rat, error) {
if strings.TrimSpace(value) == "" {
return nil, nil
}
return ratFromString(value)
}
func averageOrMid(bid, ask, mid *big.Rat) *big.Rat {
if mid != nil {
return new(big.Rat).Set(mid)
}
sum := new(big.Rat).Add(bid, ask)
return sum.Quo(sum, big.NewRat(2, 1))
}
func invertPriceSet(ps priceSet) (priceSet, error) {
if ps.ask.Sign() == 0 || ps.bid.Sign() == 0 {
return priceSet{}, merrors.InvalidArgument("oracle: cannot invert zero price")
}
one := big.NewRat(1, 1)
invBid := new(big.Rat).Quo(one, ps.ask)
invAsk := new(big.Rat).Quo(one, ps.bid)
var invMid *big.Rat
if ps.mid != nil && ps.mid.Sign() != 0 {
invMid = new(big.Rat).Quo(one, ps.mid)
} else {
invMid = averageOrMid(invBid, invAsk, nil)
}
result := priceSet{
bid: invBid,
ask: invAsk,
mid: invMid,
}
if result.ask.Cmp(result.bid) < 0 {
result.ask, result.bid = result.bid, result.ask
}
return result, nil
}
func multiplyPriceSets(a, b priceSet) priceSet {
result := priceSet{
bid: mulRat(a.bid, b.bid),
ask: mulRat(a.ask, b.ask),
}
result.mid = averageOrMid(result.bid, result.ask, nil)
return result
}
func calcSpreadBps(ps priceSet) *big.Rat {
if ps.mid == nil || ps.mid.Sign() == 0 {
return nil
}
spread := new(big.Rat).Sub(ps.ask, ps.bid)
if spread.Sign() < 0 {
spread.Neg(spread)
}
spread.Quo(spread, ps.mid)
spread.Mul(spread, big.NewRat(10000, 1))
return spread
}
func minNonZero(values ...int64) int64 {
var result int64
for _, v := range values {
if v <= 0 {
continue
}
if result == 0 || v < result {
result = v
}
}
return result
}
func formatPrice(r *big.Rat) string {
if r == nil {
return ""
}
return r.FloatString(8)
}

View File

@@ -0,0 +1,67 @@
package oracle
import (
"math/big"
"strings"
"time"
"github.com/tech/sendico/fx/storage/model"
"github.com/tech/sendico/pkg/decimal"
"github.com/tech/sendico/pkg/merrors"
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
)
// Convenience aliases to pkg/decimal for backward compatibility
var (
ratFromString = decimal.RatFromString
mulRat = decimal.MulRat
divRat = decimal.DivRat
formatRat = decimal.FormatRat
)
// roundRatToScale wraps pkg/decimal.RoundRatToScale with model RoundingMode conversion
func roundRatToScale(value *big.Rat, scale uint32, mode model.RoundingMode) (*big.Rat, error) {
return decimal.RoundRatToScale(value, scale, convertRoundingMode(mode))
}
// convertRoundingMode converts fx/storage model.RoundingMode to pkg/decimal.RoundingMode
func convertRoundingMode(mode model.RoundingMode) decimal.RoundingMode {
switch mode {
case model.RoundingModeHalfEven:
return decimal.RoundingModeHalfEven
case model.RoundingModeHalfUp:
return decimal.RoundingModeHalfUp
case model.RoundingModeDown:
return decimal.RoundingModeDown
case model.RoundingModeUnspecified:
return decimal.RoundingModeUnspecified
default:
return decimal.RoundingModeHalfEven
}
}
func priceFromRate(rate *model.RateSnapshot, side fxv1.Side) (*big.Rat, error) {
var priceStr string
switch side {
case fxv1.Side_BUY_BASE_SELL_QUOTE:
priceStr = rate.Ask
case fxv1.Side_SELL_BASE_BUY_QUOTE:
priceStr = rate.Bid
default:
priceStr = ""
}
if strings.TrimSpace(priceStr) == "" {
priceStr = rate.Mid
}
if strings.TrimSpace(priceStr) == "" {
return nil, merrors.InvalidArgument("oracle: rate snapshot missing price")
}
return ratFromString(priceStr)
}
func timeFromUnixMilli(ms int64) time.Time {
return time.Unix(0, ms*int64(time.Millisecond))
}

View File

@@ -0,0 +1,65 @@
package oracle
import (
"strings"
"sync"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
var (
metricsOnce sync.Once
rpcRequestsTotal *prometheus.CounterVec
rpcLatency *prometheus.HistogramVec
)
func initMetrics() {
metricsOnce.Do(func() {
rpcRequestsTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Namespace: "fx",
Subsystem: "oracle",
Name: "requests_total",
Help: "Total number of FX oracle RPC calls handled.",
},
[]string{"method", "result"},
)
rpcLatency = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Namespace: "fx",
Subsystem: "oracle",
Name: "request_latency_seconds",
Help: "Latency of FX oracle RPC calls.",
Buckets: prometheus.DefBuckets,
},
[]string{"method", "result"},
)
})
}
func observeRPC(start time.Time, method string, err error) {
result := labelFromError(err)
rpcRequestsTotal.WithLabelValues(method, result).Inc()
rpcLatency.WithLabelValues(method, result).Observe(time.Since(start).Seconds())
}
func labelFromError(err error) string {
if err == nil {
return strings.ToLower(codes.OK.String())
}
st, ok := status.FromError(err)
if !ok {
return "error"
}
code := st.Code()
if code == codes.OK {
return strings.ToLower(code.String())
}
return strings.ToLower(code.String())
}

View File

@@ -0,0 +1,402 @@
package oracle
import (
"context"
"errors"
"strings"
"time"
"github.com/tech/sendico/fx/storage"
"github.com/tech/sendico/fx/storage/model"
"github.com/tech/sendico/pkg/api/routers"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
"github.com/tech/sendico/pkg/merrors"
pmessaging "github.com/tech/sendico/pkg/messaging"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
"go.uber.org/zap"
"google.golang.org/grpc"
)
type serviceError string
func (e serviceError) Error() string {
return string(e)
}
var (
errSideRequired = serviceError("oracle: side is required")
errAmountsMutuallyExclusive = serviceError("oracle: exactly one amount must be provided")
errAmountRequired = serviceError("oracle: amount is required")
errQuoteRefRequired = serviceError("oracle: quote_ref is required")
errEmptyRequest = serviceError("oracle: request payload is empty")
errLedgerTxnRefRequired = serviceError("oracle: ledger_txn_ref is required")
)
type Service struct {
logger mlogger.Logger
storage storage.Repository
producer pmessaging.Producer
oraclev1.UnimplementedOracleServer
}
func NewService(logger mlogger.Logger, repo storage.Repository, prod pmessaging.Producer) *Service {
initMetrics()
return &Service{
logger: logger.Named("oracle"),
storage: repo,
producer: prod,
}
}
func (s *Service) Register(router routers.GRPC) error {
return router.Register(func(reg grpc.ServiceRegistrar) {
oraclev1.RegisterOracleServer(reg, s)
})
}
func (s *Service) GetQuote(ctx context.Context, req *oraclev1.GetQuoteRequest) (*oraclev1.GetQuoteResponse, error) {
start := time.Now()
responder := s.getQuoteResponder(ctx, req)
resp, err := responder(ctx)
observeRPC(start, "GetQuote", err)
return resp, err
}
func (s *Service) ValidateQuote(ctx context.Context, req *oraclev1.ValidateQuoteRequest) (*oraclev1.ValidateQuoteResponse, error) {
start := time.Now()
responder := s.validateQuoteResponder(ctx, req)
resp, err := responder(ctx)
observeRPC(start, "ValidateQuote", err)
return resp, err
}
func (s *Service) ConsumeQuote(ctx context.Context, req *oraclev1.ConsumeQuoteRequest) (*oraclev1.ConsumeQuoteResponse, error) {
start := time.Now()
responder := s.consumeQuoteResponder(ctx, req)
resp, err := responder(ctx)
observeRPC(start, "ConsumeQuote", err)
return resp, err
}
func (s *Service) LatestRate(ctx context.Context, req *oraclev1.LatestRateRequest) (*oraclev1.LatestRateResponse, error) {
start := time.Now()
responder := s.latestRateResponder(ctx, req)
resp, err := responder(ctx)
observeRPC(start, "LatestRate", err)
return resp, err
}
func (s *Service) ListPairs(ctx context.Context, req *oraclev1.ListPairsRequest) (*oraclev1.ListPairsResponse, error) {
start := time.Now()
responder := s.listPairsResponder(ctx, req)
resp, err := responder(ctx)
observeRPC(start, "ListPairs", err)
return resp, err
}
func (s *Service) getQuoteResponder(ctx context.Context, req *oraclev1.GetQuoteRequest) gsresponse.Responder[oraclev1.GetQuoteResponse] {
if req == nil {
req = &oraclev1.GetQuoteRequest{}
}
s.logger.Debug("Handling GetQuote", zap.String("pair", req.GetPair().GetBase()+"/"+req.GetPair().GetQuote()), zap.Bool("firm", req.GetFirm()))
if req.GetSide() == fxv1.Side_SIDE_UNSPECIFIED {
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, errSideRequired)
}
if req.GetBaseAmount() != nil && req.GetQuoteAmount() != nil {
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, errAmountsMutuallyExclusive)
}
if req.GetBaseAmount() == nil && req.GetQuoteAmount() == nil {
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, errAmountRequired)
}
if err := s.pingStorage(ctx); err != nil {
s.logger.Warn("Storage unavailable during GetQuote", zap.Error(err))
return gsresponse.Unavailable[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
}
pairMsg := req.GetPair()
if pairMsg == nil || strings.TrimSpace(pairMsg.GetBase()) == "" || strings.TrimSpace(pairMsg.GetQuote()) == "" {
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, errEmptyRequest)
}
pairKey := model.CurrencyPair{Base: strings.ToUpper(pairMsg.GetBase()), Quote: strings.ToUpper(pairMsg.GetQuote())}
pair, err := s.storage.Pairs().Get(ctx, pairKey)
if err != nil {
switch {
case errors.Is(err, merrors.ErrNoData):
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, merrors.InvalidArgument("pair_not_supported"))
default:
return gsresponse.Internal[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
}
}
provider := req.GetPreferredProvider()
if provider == "" {
provider = pair.DefaultProvider
}
if provider == "" && len(pair.Providers) > 0 {
provider = pair.Providers[0]
}
rate, err := s.getLatestRate(ctx, pair, provider)
if err != nil {
switch {
case errors.Is(err, merrors.ErrNoData):
return gsresponse.FailedPrecondition[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, "rate_not_found", err)
default:
return gsresponse.Internal[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
}
}
now := time.Now()
if maxAge := req.GetMaxAgeMs(); maxAge > 0 {
age := now.UnixMilli() - rate.AsOfUnixMs
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))
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)
if err != nil {
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
}
if req.GetBaseAmount() != nil {
if err := comp.withBaseInput(req.GetBaseAmount()); err != nil {
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
}
} else if req.GetQuoteAmount() != nil {
if err := comp.withQuoteInput(req.GetQuoteAmount()); err != nil {
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
}
}
if err := comp.compute(); err != nil {
return gsresponse.Internal[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
}
expiresAt := int64(0)
if req.GetFirm() {
expiry, err := computeExpiry(now, req.GetTtlMs())
if err != nil {
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
}
expiresAt = expiry
}
quoteModel, err := comp.buildModelQuote(req.GetFirm(), expiresAt, req)
if err != nil {
return gsresponse.Internal[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
}
if req.GetFirm() {
if err := s.storage.Quotes().Issue(ctx, quoteModel); err != nil {
switch {
case errors.Is(err, merrors.ErrDataConflict):
return gsresponse.Conflict[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
default:
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))
}
resp := &oraclev1.GetQuoteResponse{
Meta: buildResponseMeta(req.GetMeta()),
Quote: quoteModelToProto(quoteModel),
}
return gsresponse.Success(resp)
}
func (s *Service) validateQuoteResponder(ctx context.Context, req *oraclev1.ValidateQuoteRequest) gsresponse.Responder[oraclev1.ValidateQuoteResponse] {
if req == nil {
req = &oraclev1.ValidateQuoteRequest{}
}
s.logger.Debug("Handling ValidateQuote", zap.String("quote_ref", req.GetQuoteRef()))
if req.GetQuoteRef() == "" {
return gsresponse.InvalidArgument[oraclev1.ValidateQuoteResponse](s.logger, mservice.FXOracle, errQuoteRefRequired)
}
if err := s.pingStorage(ctx); err != nil {
s.logger.Warn("Storage unavailable during ValidateQuote", zap.Error(err))
return gsresponse.Unavailable[oraclev1.ValidateQuoteResponse](s.logger, mservice.FXOracle, err)
}
quote, err := s.storage.Quotes().GetByRef(ctx, req.GetQuoteRef())
if err != nil {
switch {
case errors.Is(err, merrors.ErrNoData):
resp := &oraclev1.ValidateQuoteResponse{
Meta: buildResponseMeta(req.GetMeta()),
Quote: nil,
Valid: false,
Reason: "not_found",
}
return gsresponse.Success(resp)
default:
return gsresponse.Internal[oraclev1.ValidateQuoteResponse](s.logger, mservice.FXOracle, err)
}
}
now := time.Now()
valid := true
reason := ""
if quote.IsExpired(now) {
valid = false
reason = "expired"
} else if quote.Status == model.QuoteStatusConsumed {
valid = false
reason = "consumed"
}
resp := &oraclev1.ValidateQuoteResponse{
Meta: buildResponseMeta(req.GetMeta()),
Quote: quoteModelToProto(quote),
Valid: valid,
Reason: reason,
}
return gsresponse.Success(resp)
}
func (s *Service) consumeQuoteResponder(ctx context.Context, req *oraclev1.ConsumeQuoteRequest) gsresponse.Responder[oraclev1.ConsumeQuoteResponse] {
if req == nil {
req = &oraclev1.ConsumeQuoteRequest{}
}
s.logger.Debug("Handling ConsumeQuote", zap.String("quote_ref", req.GetQuoteRef()), zap.String("ledger_txn_ref", req.GetLedgerTxnRef()))
if req.GetQuoteRef() == "" {
return gsresponse.InvalidArgument[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, errQuoteRefRequired)
}
if req.GetLedgerTxnRef() == "" {
return gsresponse.InvalidArgument[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, errLedgerTxnRefRequired)
}
if err := s.pingStorage(ctx); err != nil {
s.logger.Warn("Storage unavailable during ConsumeQuote", zap.Error(err))
return gsresponse.Unavailable[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, err)
}
_, err := s.storage.Quotes().Consume(ctx, req.GetQuoteRef(), req.GetLedgerTxnRef(), time.Now())
if err != nil {
switch {
case errors.Is(err, storage.ErrQuoteExpired):
return gsresponse.FailedPrecondition[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, "expired", err)
case errors.Is(err, storage.ErrQuoteConsumed):
return gsresponse.FailedPrecondition[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, "consumed", err)
case errors.Is(err, storage.ErrQuoteNotFirm):
return gsresponse.FailedPrecondition[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, "not_firm", err)
case errors.Is(err, merrors.ErrNoData):
return gsresponse.NotFound[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, err)
default:
return gsresponse.Internal[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, err)
}
}
resp := &oraclev1.ConsumeQuoteResponse{
Meta: buildResponseMeta(req.GetMeta()),
Consumed: true,
Reason: "consumed",
}
s.logger.Debug("Quote consumed", zap.String("quote_ref", req.GetQuoteRef()), zap.String("ledger_txn_ref", req.GetLedgerTxnRef()))
return gsresponse.Success(resp)
}
func (s *Service) latestRateResponder(ctx context.Context, req *oraclev1.LatestRateRequest) gsresponse.Responder[oraclev1.LatestRateResponse] {
if req == nil {
req = &oraclev1.LatestRateRequest{}
}
s.logger.Debug("Handling LatestRate", zap.String("pair", req.GetPair().GetBase()+"/"+req.GetPair().GetQuote()))
if err := s.pingStorage(ctx); err != nil {
s.logger.Warn("Storage unavailable during LatestRate", zap.Error(err))
return gsresponse.Unavailable[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, err)
}
pairMsg := req.GetPair()
if pairMsg == nil || strings.TrimSpace(pairMsg.GetBase()) == "" || strings.TrimSpace(pairMsg.GetQuote()) == "" {
return gsresponse.InvalidArgument[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, errEmptyRequest)
}
pair := model.CurrencyPair{Base: strings.ToUpper(pairMsg.GetBase()), Quote: strings.ToUpper(pairMsg.GetQuote())}
pairMeta, err := s.storage.Pairs().Get(ctx, pair)
if err != nil {
switch {
case errors.Is(err, merrors.ErrNoData):
return gsresponse.NotFound[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, err)
default:
return gsresponse.Internal[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, err)
}
}
provider := req.GetProvider()
if provider == "" {
provider = pairMeta.DefaultProvider
}
if provider == "" && len(pairMeta.Providers) > 0 {
provider = pairMeta.Providers[0]
}
rate, err := s.getLatestRate(ctx, pairMeta, provider)
if err != nil {
switch {
case errors.Is(err, merrors.ErrNoData):
return gsresponse.NotFound[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, err)
default:
return gsresponse.Internal[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, err)
}
}
resp := &oraclev1.LatestRateResponse{
Meta: buildResponseMeta(req.GetMeta()),
Rate: rateModelToProto(rate),
}
return gsresponse.Success(resp)
}
func (s *Service) listPairsResponder(ctx context.Context, req *oraclev1.ListPairsRequest) gsresponse.Responder[oraclev1.ListPairsResponse] {
if req == nil {
req = &oraclev1.ListPairsRequest{}
}
s.logger.Debug("Handling ListPairs")
if err := s.pingStorage(ctx); err != nil {
s.logger.Warn("Storage unavailable during ListPairs", zap.Error(err))
return gsresponse.Unavailable[oraclev1.ListPairsResponse](s.logger, mservice.FXOracle, err)
}
pairs, err := s.storage.Pairs().ListEnabled(ctx)
if err != nil {
return gsresponse.Internal[oraclev1.ListPairsResponse](s.logger, mservice.FXOracle, err)
}
result := make([]*oraclev1.PairMeta, 0, len(pairs))
for _, pair := range pairs {
result = append(result, pairModelToProto(pair))
}
resp := &oraclev1.ListPairsResponse{
Meta: buildResponseMeta(req.GetMeta()),
Pairs: result,
}
s.logger.Debug("ListPairs returning metadata", zap.Int("pairs", len(resp.GetPairs())))
return gsresponse.Success(resp)
}
func (s *Service) pingStorage(ctx context.Context) error {
if s.storage == nil {
return nil
}
return s.storage.Ping(ctx)
}
func (s *Service) getLatestRate(ctx context.Context, pair *model.Pair, provider string) (*model.RateSnapshot, error) {
rate, err := s.storage.Rates().LatestSnapshot(ctx, pair.Pair, provider)
if err == nil {
return rate, nil
}
if !errors.Is(err, merrors.ErrNoData) {
return nil, err
}
crossRate, crossErr := s.computeCrossRate(ctx, pair, provider)
if crossErr != nil {
if errors.Is(crossErr, merrors.ErrNoData) {
return nil, err
}
return nil, crossErr
}
s.logger.Debug("Derived cross rate", zap.String("pair", pair.Pair.Base+"/"+pair.Pair.Quote), zap.String("provider", provider))
return crossRate, nil
}
var _ oraclev1.OracleServer = (*Service)(nil)

View File

@@ -0,0 +1,467 @@
package oracle
import (
"context"
"strings"
"testing"
"time"
"github.com/tech/sendico/fx/storage"
"github.com/tech/sendico/fx/storage/model"
"github.com/tech/sendico/pkg/merrors"
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"
)
type repositoryStub struct {
rates storage.RatesStore
quotes storage.QuotesStore
pairs storage.PairStore
currencies storage.CurrencyStore
pingErr error
}
func (r *repositoryStub) Ping(ctx context.Context) error { return r.pingErr }
func (r *repositoryStub) Rates() storage.RatesStore { return r.rates }
func (r *repositoryStub) Quotes() storage.QuotesStore { return r.quotes }
func (r *repositoryStub) Pairs() storage.PairStore { return r.pairs }
func (r *repositoryStub) Currencies() storage.CurrencyStore {
return r.currencies
}
type ratesStoreStub struct {
latestFn func(ctx context.Context, pair model.CurrencyPair, provider string) (*model.RateSnapshot, error)
}
func (r *ratesStoreStub) UpsertSnapshot(ctx context.Context, snapshot *model.RateSnapshot) error {
return nil
}
func (r *ratesStoreStub) LatestSnapshot(ctx context.Context, pair model.CurrencyPair, provider string) (*model.RateSnapshot, error) {
if r.latestFn != nil {
return r.latestFn(ctx, pair, provider)
}
return nil, merrors.ErrNoData
}
type quotesStoreStub struct {
issueFn func(ctx context.Context, quote *model.Quote) error
getFn func(ctx context.Context, ref string) (*model.Quote, error)
consumeFn func(ctx context.Context, ref, ledger string, when time.Time) (*model.Quote, error)
}
func (q *quotesStoreStub) Issue(ctx context.Context, quote *model.Quote) error {
if q.issueFn != nil {
return q.issueFn(ctx, quote)
}
return nil
}
func (q *quotesStoreStub) GetByRef(ctx context.Context, ref string) (*model.Quote, error) {
if q.getFn != nil {
return q.getFn(ctx, ref)
}
return nil, merrors.ErrNoData
}
func (q *quotesStoreStub) Consume(ctx context.Context, ref, ledger string, when time.Time) (*model.Quote, error) {
if q.consumeFn != nil {
return q.consumeFn(ctx, ref, ledger, when)
}
return nil, nil
}
func (q *quotesStoreStub) ExpireIssuedBefore(ctx context.Context, cutoff time.Time) (int, error) {
return 0, nil
}
type pairStoreStub struct {
getFn func(ctx context.Context, pair model.CurrencyPair) (*model.Pair, error)
listFn func(ctx context.Context) ([]*model.Pair, error)
}
func (p *pairStoreStub) ListEnabled(ctx context.Context) ([]*model.Pair, error) {
if p.listFn != nil {
return p.listFn(ctx)
}
return nil, nil
}
func (p *pairStoreStub) Get(ctx context.Context, pair model.CurrencyPair) (*model.Pair, error) {
if p.getFn != nil {
return p.getFn(ctx, pair)
}
return nil, merrors.ErrNoData
}
func (p *pairStoreStub) Upsert(ctx context.Context, pair *model.Pair) error { return nil }
type currencyStoreStub struct{}
func (currencyStoreStub) Get(ctx context.Context, code string) (*model.Currency, error) {
return nil, merrors.ErrNoData
}
func (currencyStoreStub) List(ctx context.Context, codes ...string) ([]*model.Currency, error) {
return nil, nil
}
func (currencyStoreStub) Upsert(ctx context.Context, currency *model.Currency) error { return nil }
func TestServiceGetQuoteFirm(t *testing.T) {
repo := &repositoryStub{}
repo.pairs = &pairStoreStub{
getFn: func(ctx context.Context, pair model.CurrencyPair) (*model.Pair, error) {
return &model.Pair{
Pair: pair,
BaseMeta: model.CurrencySettings{Code: pair.Base, Decimals: 2, Rounding: model.RoundingModeHalfEven},
QuoteMeta: model.CurrencySettings{Code: pair.Quote, Decimals: 2, Rounding: model.RoundingModeHalfEven},
}, nil
},
}
repo.rates = &ratesStoreStub{
latestFn: func(ctx context.Context, pair model.CurrencyPair, provider string) (*model.RateSnapshot, error) {
return &model.RateSnapshot{
Pair: pair,
Provider: provider,
Ask: "1.10",
Bid: "1.08",
RateRef: "rate#1",
AsOfUnixMs: time.Now().UnixMilli(),
}, nil
},
}
savedQuote := &model.Quote{}
repo.quotes = &quotesStoreStub{
issueFn: func(ctx context.Context, quote *model.Quote) error {
*savedQuote = *quote
return nil
},
}
repo.currencies = currencyStoreStub{}
svc := NewService(zap.NewNop(), repo, nil)
req := &oraclev1.GetQuoteRequest{
Meta: &oraclev1.RequestMeta{
TenantRef: "tenant",
Trace: &tracev1.TraceContext{RequestRef: "req"},
},
Pair: &fxv1.CurrencyPair{Base: "USD", Quote: "EUR"},
Side: fxv1.Side_BUY_BASE_SELL_QUOTE,
AmountInput: &oraclev1.GetQuoteRequest_BaseAmount{BaseAmount: &moneyv1.Money{
Currency: "USD",
Amount: "100",
}},
Firm: true,
TtlMs: 60000,
}
resp, err := svc.GetQuote(context.Background(), req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.GetQuote().GetFirm() != true {
t.Fatalf("expected firm quote")
}
if resp.GetQuote().GetQuoteAmount().GetAmount() != "110.00" {
t.Fatalf("unexpected quote amount: %s", resp.GetQuote().GetQuoteAmount().GetAmount())
}
if savedQuote.QuoteRef == "" {
t.Fatalf("expected quote persisted")
}
}
func TestServiceGetQuoteRateNotFound(t *testing.T) {
repo := &repositoryStub{
pairs: &pairStoreStub{
getFn: func(ctx context.Context, pair model.CurrencyPair) (*model.Pair, error) {
return &model.Pair{
Pair: pair,
BaseMeta: model.CurrencySettings{Code: pair.Base, Decimals: 2, Rounding: model.RoundingModeHalfEven},
QuoteMeta: model.CurrencySettings{Code: pair.Quote, Decimals: 2, Rounding: model.RoundingModeHalfEven},
}, nil
},
},
rates: &ratesStoreStub{latestFn: func(context.Context, model.CurrencyPair, string) (*model.RateSnapshot, error) {
return nil, merrors.ErrNoData
}},
}
svc := NewService(zap.NewNop(), repo, nil)
_, err := svc.GetQuote(context.Background(), &oraclev1.GetQuoteRequest{
Pair: &fxv1.CurrencyPair{Base: "USD", Quote: "EUR"},
Side: fxv1.Side_BUY_BASE_SELL_QUOTE,
AmountInput: &oraclev1.GetQuoteRequest_BaseAmount{BaseAmount: &moneyv1.Money{Currency: "USD", Amount: "1"}},
})
if err == nil {
t.Fatalf("expected error")
}
}
func TestServiceGetQuoteCrossRate(t *testing.T) {
repo := &repositoryStub{}
targetPair := model.CurrencyPair{Base: "EUR", Quote: "RUB"}
baseLegPair := model.CurrencyPair{Base: "USDT", Quote: "EUR"}
quoteLegPair := model.CurrencyPair{Base: "USDT", Quote: "RUB"}
repo.pairs = &pairStoreStub{
getFn: func(ctx context.Context, pair model.CurrencyPair) (*model.Pair, error) {
if pair != targetPair {
t.Fatalf("unexpected pair lookup: %v", pair)
}
return &model.Pair{
Pair: pair,
BaseMeta: model.CurrencySettings{Code: pair.Base, Decimals: 2, Rounding: model.RoundingModeHalfEven},
QuoteMeta: model.CurrencySettings{Code: pair.Quote, Decimals: 2, Rounding: model.RoundingModeHalfEven},
DefaultProvider: "CROSSPROV",
Cross: &model.CrossRateConfig{
Enabled: true,
BaseLeg: model.CrossRateLeg{
Pair: baseLegPair,
Invert: true,
},
QuoteLeg: model.CrossRateLeg{
Pair: quoteLegPair,
},
},
}, nil
},
}
repo.rates = &ratesStoreStub{
latestFn: func(ctx context.Context, pair model.CurrencyPair, provider string) (*model.RateSnapshot, error) {
switch pair {
case targetPair:
return nil, merrors.ErrNoData
case baseLegPair:
return &model.RateSnapshot{
Pair: pair,
Provider: provider,
Ask: "0.90",
Bid: "0.90",
Mid: "0.90",
RateRef: "base-leg",
AsOfUnixMs: 1_000,
}, nil
case quoteLegPair:
return &model.RateSnapshot{
Pair: pair,
Provider: provider,
Ask: "90",
Bid: "90",
Mid: "90",
RateRef: "quote-leg",
AsOfUnixMs: 2_000,
}, nil
default:
return nil, merrors.ErrNoData
}
},
}
repo.quotes = &quotesStoreStub{}
repo.currencies = currencyStoreStub{}
svc := NewService(zap.NewNop(), repo, nil)
req := &oraclev1.GetQuoteRequest{
Pair: &fxv1.CurrencyPair{Base: "EUR", Quote: "RUB"},
Side: fxv1.Side_BUY_BASE_SELL_QUOTE,
AmountInput: &oraclev1.GetQuoteRequest_BaseAmount{BaseAmount: &moneyv1.Money{Currency: "EUR", Amount: "1"}},
}
resp, err := svc.GetQuote(context.Background(), req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.GetQuote().GetPrice().GetValue() != "100.00" {
t.Fatalf("unexpected cross price: %s", resp.GetQuote().GetPrice().GetValue())
}
if resp.GetQuote().GetQuoteAmount().GetAmount() != "100.00" {
t.Fatalf("unexpected cross quote amount: %s", resp.GetQuote().GetQuoteAmount().GetAmount())
}
if !strings.HasPrefix(resp.GetQuote().GetRateRef(), "cross|") {
t.Fatalf("expected cross rate ref, got %s", resp.GetQuote().GetRateRef())
}
if resp.GetQuote().GetProvider() != "CROSSPROV" {
t.Fatalf("unexpected provider: %s", resp.GetQuote().GetProvider())
}
}
func TestServiceLatestRateCross(t *testing.T) {
repo := &repositoryStub{}
targetPair := model.CurrencyPair{Base: "EUR", Quote: "RUB"}
baseLegPair := model.CurrencyPair{Base: "USDT", Quote: "EUR"}
quoteLegPair := model.CurrencyPair{Base: "USDT", Quote: "RUB"}
repo.pairs = &pairStoreStub{
getFn: func(ctx context.Context, pair model.CurrencyPair) (*model.Pair, error) {
if pair != targetPair {
t.Fatalf("unexpected pair lookup: %v", pair)
}
return &model.Pair{
Pair: pair,
BaseMeta: model.CurrencySettings{Code: pair.Base, Decimals: 2, Rounding: model.RoundingModeHalfEven},
QuoteMeta: model.CurrencySettings{Code: pair.Quote, Decimals: 2, Rounding: model.RoundingModeHalfEven},
DefaultProvider: "CROSSPROV",
Cross: &model.CrossRateConfig{
Enabled: true,
BaseLeg: model.CrossRateLeg{
Pair: baseLegPair,
Invert: true,
},
QuoteLeg: model.CrossRateLeg{
Pair: quoteLegPair,
},
},
}, nil
},
}
repo.rates = &ratesStoreStub{
latestFn: func(ctx context.Context, pair model.CurrencyPair, provider string) (*model.RateSnapshot, error) {
switch pair {
case targetPair:
return nil, merrors.ErrNoData
case baseLegPair:
return &model.RateSnapshot{
Pair: pair,
Provider: provider,
Ask: "0.90",
Bid: "0.90",
Mid: "0.90",
RateRef: "base-leg",
AsOfUnixMs: 1_000,
}, nil
case quoteLegPair:
return &model.RateSnapshot{
Pair: pair,
Provider: provider,
Ask: "90",
Bid: "90",
Mid: "90",
RateRef: "quote-leg",
AsOfUnixMs: 2_000,
}, nil
default:
return nil, merrors.ErrNoData
}
},
}
repo.quotes = &quotesStoreStub{}
repo.currencies = currencyStoreStub{}
svc := NewService(zap.NewNop(), repo, nil)
resp, err := svc.LatestRate(context.Background(), &oraclev1.LatestRateRequest{
Pair: &fxv1.CurrencyPair{Base: "EUR", Quote: "RUB"},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.GetRate().GetMid().GetValue() != "100.00000000" {
t.Fatalf("unexpected mid price: %s", resp.GetRate().GetMid().GetValue())
}
if resp.GetRate().GetProvider() != "CROSSPROV" {
t.Fatalf("unexpected provider: %s", resp.GetRate().GetProvider())
}
if !strings.HasPrefix(resp.GetRate().GetRateRef(), "cross|") {
t.Fatalf("expected cross rate ref, got %s", resp.GetRate().GetRateRef())
}
}
func TestServiceValidateQuote(t *testing.T) {
now := time.Now().Add(time.Minute)
repo := &repositoryStub{
quotes: &quotesStoreStub{
getFn: func(context.Context, string) (*model.Quote, error) {
return &model.Quote{
QuoteRef: "q1",
Pair: model.CurrencyPair{Base: "USD", Quote: "EUR"},
Side: model.QuoteSideBuyBaseSellQuote,
Price: "1.10",
BaseAmount: model.Money{Currency: "USD", Amount: "100"},
QuoteAmount: model.Money{Currency: "EUR", Amount: "110"},
ExpiresAtUnixMs: now.UnixMilli(),
Status: model.QuoteStatusIssued,
}, nil
},
},
}
svc := NewService(zap.NewNop(), repo, nil)
resp, err := svc.ValidateQuote(context.Background(), &oraclev1.ValidateQuoteRequest{QuoteRef: "q1"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !resp.GetValid() {
t.Fatalf("expected quote valid")
}
}
func TestServiceConsumeQuoteExpired(t *testing.T) {
repo := &repositoryStub{
quotes: &quotesStoreStub{
consumeFn: func(context.Context, string, string, time.Time) (*model.Quote, error) {
return nil, storage.ErrQuoteExpired
},
},
}
svc := NewService(zap.NewNop(), repo, nil)
_, err := svc.ConsumeQuote(context.Background(), &oraclev1.ConsumeQuoteRequest{QuoteRef: "q1", LedgerTxnRef: "ledger"})
if err == nil {
t.Fatalf("expected error")
}
}
func TestServiceLatestRateSuccess(t *testing.T) {
repo := &repositoryStub{
rates: &ratesStoreStub{latestFn: func(_ context.Context, pair model.CurrencyPair, provider string) (*model.RateSnapshot, error) {
if pair != (model.CurrencyPair{Base: "USD", Quote: "EUR"}) {
t.Fatalf("unexpected pair: %v", pair)
}
if provider != "DEFAULT" {
t.Fatalf("unexpected provider: %s", provider)
}
return &model.RateSnapshot{Pair: pair, RateRef: "rate", Provider: provider}, nil
}},
pairs: &pairStoreStub{
getFn: func(ctx context.Context, pair model.CurrencyPair) (*model.Pair, error) {
return &model.Pair{
Pair: pair,
BaseMeta: model.CurrencySettings{Code: pair.Base, Decimals: 2, Rounding: model.RoundingModeHalfEven},
QuoteMeta: model.CurrencySettings{Code: pair.Quote, Decimals: 2, Rounding: model.RoundingModeHalfEven},
DefaultProvider: "DEFAULT",
}, nil
},
},
}
svc := NewService(zap.NewNop(), repo, nil)
resp, err := svc.LatestRate(context.Background(), &oraclev1.LatestRateRequest{Pair: &fxv1.CurrencyPair{Base: "USD", Quote: "EUR"}})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.GetRate().GetRateRef() != "rate" {
t.Fatalf("unexpected rate ref")
}
}
func TestServiceListPairs(t *testing.T) {
repo := &repositoryStub{
pairs: &pairStoreStub{listFn: func(context.Context) ([]*model.Pair, error) {
return []*model.Pair{{Pair: model.CurrencyPair{Base: "USD", Quote: "EUR"}}}, nil
}},
}
svc := NewService(zap.NewNop(), repo, nil)
resp, err := svc.ListPairs(context.Background(), &oraclev1.ListPairsRequest{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(resp.GetPairs()) != 1 {
t.Fatalf("expected one pair")
}
}

View File

@@ -0,0 +1,126 @@
package oracle
import (
"strings"
"github.com/tech/sendico/fx/storage/model"
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"
)
func buildResponseMeta(meta *oraclev1.RequestMeta) *oraclev1.ResponseMeta {
resp := &oraclev1.ResponseMeta{}
if meta == nil {
return resp
}
resp.RequestRef = meta.GetRequestRef()
resp.TraceRef = meta.GetTraceRef()
trace := meta.GetTrace()
if trace == nil {
trace = &tracev1.TraceContext{
RequestRef: meta.GetRequestRef(),
IdempotencyKey: meta.GetIdempotencyKey(),
TraceRef: meta.GetTraceRef(),
}
}
resp.Trace = trace
return resp
}
func quoteModelToProto(q *model.Quote) *oraclev1.Quote {
if q == nil {
return nil
}
return &oraclev1.Quote{
QuoteRef: q.QuoteRef,
Pair: &fxv1.CurrencyPair{Base: q.Pair.Base, Quote: q.Pair.Quote},
Side: sideModelToProto(q.Side),
Price: decimalStringToProto(q.Price),
BaseAmount: moneyModelToProto(&q.BaseAmount),
QuoteAmount: moneyModelToProto(&q.QuoteAmount),
ExpiresAtUnixMs: q.ExpiresAtUnixMs,
Provider: q.Provider,
RateRef: q.RateRef,
Firm: q.Firm,
}
}
func moneyModelToProto(m *model.Money) *moneyv1.Money {
if m == nil {
return nil
}
return &moneyv1.Money{Currency: m.Currency, Amount: m.Amount}
}
func sideModelToProto(side model.QuoteSide) fxv1.Side {
switch side {
case model.QuoteSideBuyBaseSellQuote:
return fxv1.Side_BUY_BASE_SELL_QUOTE
case model.QuoteSideSellBaseBuyQuote:
return fxv1.Side_SELL_BASE_BUY_QUOTE
default:
return fxv1.Side_SIDE_UNSPECIFIED
}
}
func rateModelToProto(rate *model.RateSnapshot) *oraclev1.RateSnapshot {
if rate == nil {
return nil
}
return &oraclev1.RateSnapshot{
Pair: &fxv1.CurrencyPair{Base: rate.Pair.Base, Quote: rate.Pair.Quote},
Mid: decimalStringToProto(rate.Mid),
Bid: decimalStringToProto(rate.Bid),
Ask: decimalStringToProto(rate.Ask),
AsofUnixMs: rate.AsOfUnixMs,
Provider: rate.Provider,
RateRef: rate.RateRef,
SpreadBps: decimalStringToProto(rate.SpreadBps),
}
}
func pairModelToProto(pair *model.Pair) *oraclev1.PairMeta {
if pair == nil {
return nil
}
return &oraclev1.PairMeta{
Pair: &fxv1.CurrencyPair{Base: pair.Pair.Base, Quote: pair.Pair.Quote},
BaseMeta: currencySettingsToProto(&pair.BaseMeta),
QuoteMeta: currencySettingsToProto(&pair.QuoteMeta),
}
}
func currencySettingsToProto(c *model.CurrencySettings) *moneyv1.CurrencyMeta {
if c == nil {
return nil
}
return &moneyv1.CurrencyMeta{
Code: c.Code,
Decimals: c.Decimals,
Rounding: roundingModeToProto(c.Rounding),
}
}
func roundingModeToProto(mode model.RoundingMode) moneyv1.RoundingMode {
switch mode {
case model.RoundingModeHalfUp:
return moneyv1.RoundingMode_ROUND_HALF_UP
case model.RoundingModeDown:
return moneyv1.RoundingMode_ROUND_DOWN
case model.RoundingModeHalfEven, model.RoundingModeUnspecified:
return moneyv1.RoundingMode_ROUND_HALF_EVEN
default:
return moneyv1.RoundingMode_ROUNDING_MODE_UNSPECIFIED
}
}
func decimalStringToProto(value string) *moneyv1.Decimal {
if strings.TrimSpace(value) == "" {
return nil
}
return &moneyv1.Decimal{Value: value}
}

17
api/fx/oracle/main.go Normal file
View File

@@ -0,0 +1,17 @@
package main
import (
"github.com/tech/sendico/fx/oracle/internal/appversion"
si "github.com/tech/sendico/fx/oracle/internal/server"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/server"
smain "github.com/tech/sendico/pkg/server/main"
)
func factory(logger mlogger.Logger, file string, debug bool) (server.Application, error) {
return si.Create(logger, file, debug)
}
func main() {
smain.RunServer("main", appversion.Create(), factory)
}

2
api/fx/storage/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
internal/generated
.gocache

32
api/fx/storage/go.mod Normal file
View File

@@ -0,0 +1,32 @@
module github.com/tech/sendico/fx/storage
go 1.25.3
replace github.com/tech/sendico/pkg => ../../pkg
require (
github.com/tech/sendico/pkg v0.1.0
go.mongodb.org/mongo-driver v1.17.6
go.uber.org/zap v1.27.1
)
require (
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
github.com/casbin/casbin/v2 v2.134.0 // indirect
github.com/casbin/govaluate v1.10.0 // indirect
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
github.com/golang/snappy v1.0.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/compress v1.18.2 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/montanaflynn/stats v0.7.1 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.2.0 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/text v0.31.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
)

175
api/fx/storage/go.sum Normal file
View File

@@ -0,0 +1,175 @@
dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/casbin/casbin/v2 v2.134.0 h1:wyO3hZb487GzlGVAI2hUoHQT0ehFD+9B5P+HVG9BVTM=
github.com/casbin/casbin/v2 v2.134.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
github.com/casbin/mongodb-adapter/v3 v3.7.0 h1:w9c3bea1BGK4eZTAmk17JkY52yv/xSZDSHKji8q+z6E=
github.com/casbin/mongodb-adapter/v3 v3.7.0/go.mod h1:F1mu4ojoJVE/8VhIMxMedhjfwRDdIXgANYs6Sd0MgVA=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v27.3.1+incompatible h1:KttF0XoteNTicmUtBO0L2tP+J7FGRFTjaEF4k6WdhfI=
github.com/docker/docker v27.3.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54 h1:mFWunSatvkQQDhpdyuFAYwyAan3hzCuma+Pz8sqvOfg=
github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
github.com/moby/sys/user v0.3.0 h1:9ni5DlcW5an3SvRSx4MouotOygvzaXbaSrc/wGDFWPo=
github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE=
github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
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/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/testcontainers/testcontainers-go v0.33.0 h1:zJS9PfXYT5O0ZFXM2xxXfk4J5UMw/kRiISng037Gxdw=
github.com/testcontainers/testcontainers-go v0.33.0/go.mod h1:W80YpTa8D5C3Yy16icheD01UTDu+LmXIA2Keo+jWtT8=
github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0 h1:iXVA84s5hKMS5gn01GWOYHE3ymy/2b+0YkpFeTxB2XY=
github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0/go.mod h1:R6tMjTojRiaoo89fh/hf7tOmfzohdqSU17R9DwSVSog=
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8=
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.mongodb.org/mongo-driver v1.17.6 h1:87JUG1wZfWsr6rIz3ZmpH90rL5tea7O3IHuSwHUpsss=
go.mongodb.org/mongo-driver v1.17.6/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 h1:UP6IpuHFkUgOQL9FFQFrZ+5LiwhhYRbi7VZSIx6Nj5s=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM=
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
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/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.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -0,0 +1,18 @@
package model
// CrossRateConfig describes how to synthetically derive a currency pair using
// two other pairs connected by a pivot currency.
type CrossRateConfig struct {
Enabled bool `bson:"enabled" json:"enabled"`
PivotCurrency string `bson:"pivotCurrency,omitempty" json:"pivotCurrency,omitempty"`
BaseLeg CrossRateLeg `bson:"baseLeg" json:"baseLeg"`
QuoteLeg CrossRateLeg `bson:"quoteLeg" json:"quoteLeg"`
}
// CrossRateLeg identifies a supporting currency pair and optional overrides to
// fetch or orient its pricing data for cross-rate calculations.
type CrossRateLeg struct {
Pair CurrencyPair `bson:"pair" json:"pair"`
Invert bool `bson:"invert,omitempty" json:"invert,omitempty"`
Provider string `bson:"provider,omitempty" json:"provider,omitempty"`
}

View File

@@ -0,0 +1,27 @@
package model
import "github.com/tech/sendico/pkg/db/storable"
// Currency captures rounding metadata for a given currency code.
type Currency struct {
storable.Base `bson:",inline" json:",inline"`
Code string `bson:"code" json:"code"`
Decimals uint32 `bson:"decimals" json:"decimals"`
Rounding RoundingMode `bson:"rounding" json:"rounding"`
DisplayName string `bson:"displayName,omitempty" json:"displayName,omitempty"`
Symbol string `bson:"symbol,omitempty" json:"symbol,omitempty"`
MinUnit string `bson:"minUnit,omitempty" json:"minUnit,omitempty"`
}
// Collection implements storable.Storable.
func (*Currency) Collection() string {
return CurrenciesCollection
}
// CurrencySettings embeds precision details inside a Pair document.
type CurrencySettings struct {
Code string `bson:"code" json:"code"`
Decimals uint32 `bson:"decimals" json:"decimals"`
Rounding RoundingMode `bson:"rounding" json:"rounding"`
}

View File

@@ -0,0 +1,26 @@
package model
import "github.com/tech/sendico/pkg/db/storable"
// Pair describes a supported FX currency pair and related metadata.
type Pair struct {
storable.Base `bson:",inline" json:",inline"`
Pair CurrencyPair `bson:"pair" json:"pair"`
BaseMeta CurrencySettings `bson:"baseMeta" json:"baseMeta"`
QuoteMeta CurrencySettings `bson:"quoteMeta" json:"quoteMeta"`
Providers []string `bson:"providers,omitempty" json:"providers,omitempty"`
IsEnabled bool `bson:"isEnabled" json:"isEnabled"`
TenantRef string `bson:"tenantRef,omitempty" json:"tenantRef,omitempty"`
DefaultProvider string `bson:"defaultProvider,omitempty" json:"defaultProvider,omitempty"`
Attributes map[string]any `bson:"attributes,omitempty" json:"attributes,omitempty"`
SupportedSides []QuoteSide `bson:"supportedSides,omitempty" json:"supportedSides,omitempty"`
FallbackProviders []string `bson:"fallbackProviders,omitempty" json:"fallbackProviders,omitempty"`
Tags []string `bson:"tags,omitempty" json:"tags,omitempty"`
Cross *CrossRateConfig `bson:"cross,omitempty" json:"cross,omitempty"`
}
// Collection implements storable.Storable.
func (*Pair) Collection() string {
return PairsCollection
}

View File

@@ -0,0 +1,63 @@
package model
import (
"time"
"github.com/tech/sendico/pkg/db/storable"
)
// Quote represents a firm or indicative quote persisted by the oracle.
type Quote struct {
storable.Base `bson:",inline" json:",inline"`
QuoteRef string `bson:"quoteRef" json:"quoteRef"`
Firm bool `bson:"firm" json:"firm"`
Status QuoteStatus `bson:"status" json:"status"`
Pair CurrencyPair `bson:"pair" json:"pair"`
Side QuoteSide `bson:"side" json:"side"`
Price string `bson:"price" json:"price"`
BaseAmount Money `bson:"baseAmount" json:"baseAmount"`
QuoteAmount Money `bson:"quoteAmount" json:"quoteAmount"`
AmountType QuoteAmountType `bson:"amountType" json:"amountType"`
ExpiresAtUnixMs int64 `bson:"expiresAtUnixMs" json:"expiresAtUnixMs"`
ExpiresAt *time.Time `bson:"expiresAt,omitempty" json:"expiresAt,omitempty"`
RateRef string `bson:"rateRef" json:"rateRef"`
Provider string `bson:"provider" json:"provider"`
PreferredProvider string `bson:"preferredProvider,omitempty" json:"preferredProvider,omitempty"`
RequestedTTLMs int64 `bson:"requestedTtlMs,omitempty" json:"requestedTtlMs,omitempty"`
MaxAgeToleranceMs int64 `bson:"maxAgeToleranceMs,omitempty" json:"maxAgeToleranceMs,omitempty"`
ConsumedByLedgerTxnRef string `bson:"consumedByLedgerTxnRef,omitempty" json:"consumedByLedgerTxnRef,omitempty"`
ConsumedAtUnixMs *int64 `bson:"consumedAtUnixMs,omitempty" json:"consumedAtUnixMs,omitempty"`
Meta *QuoteMeta `bson:"meta,omitempty" json:"meta,omitempty"`
}
// Collection implements storable.Storable.
func (*Quote) Collection() string {
return QuotesCollection
}
// MarkConsumed switches the quote to consumed status and links it to a ledger transaction.
func (q *Quote) MarkConsumed(ledgerTxnRef string, consumedAt time.Time) {
if ledgerTxnRef == "" {
return
}
q.Status = QuoteStatusConsumed
q.ConsumedByLedgerTxnRef = ledgerTxnRef
ts := consumedAt.UnixMilli()
q.ConsumedAtUnixMs = &ts
q.Base.Update()
}
// MarkExpired marks the quote as expired.
func (q *Quote) MarkExpired() {
q.Status = QuoteStatusExpired
q.Base.Update()
}
// IsExpired reports whether the quote has passed its expiration instant.
func (q *Quote) IsExpired(now time.Time) bool {
if q.ExpiresAtUnixMs == 0 {
return false
}
return now.UnixMilli() >= q.ExpiresAtUnixMs
}

View File

@@ -0,0 +1,34 @@
package model
import (
"time"
"github.com/tech/sendico/pkg/db/storable"
)
// RateSnapshot stores a normalized FX rate observation.
type RateSnapshot struct {
storable.Base `bson:",inline" json:",inline"`
RateRef string `bson:"rateRef" json:"rateRef"`
Pair CurrencyPair `bson:"pair" json:"pair"`
Provider string `bson:"provider" json:"provider"`
Mid string `bson:"mid,omitempty" json:"mid,omitempty"`
Bid string `bson:"bid,omitempty" json:"bid,omitempty"`
Ask string `bson:"ask,omitempty" json:"ask,omitempty"`
SpreadBps string `bson:"spreadBps,omitempty" json:"spreadBps,omitempty"`
AsOfUnixMs int64 `bson:"asOfUnixMs" json:"asOfUnixMs"`
AsOf *time.Time `bson:"asOf,omitempty" json:"asOf,omitempty"`
Source string `bson:"source,omitempty" json:"source,omitempty"`
ProviderRef string `bson:"providerRef,omitempty" json:"providerRef,omitempty"`
}
// Collection implements storable.Storable.
func (*RateSnapshot) Collection() string {
return RatesCollection
}
// AsOfTime converts the stored millisecond timestamp to time.Time.
func (r *RateSnapshot) AsOfTime() time.Time {
return time.UnixMilli(r.AsOfUnixMs)
}

View File

@@ -0,0 +1,68 @@
package model
import "github.com/tech/sendico/pkg/model"
// Collection names used by the FX oracle persistence layer.
const (
RatesCollection = "rates"
QuotesCollection = "quotes"
CurrenciesCollection = "currencies"
PairsCollection = "pairs"
)
// QuoteStatus tracks the lifecycle state of a quote.
type QuoteStatus string
const (
QuoteStatusIssued QuoteStatus = "issued"
QuoteStatusConsumed QuoteStatus = "consumed"
QuoteStatusExpired QuoteStatus = "expired"
)
// QuoteSide expresses the trade direction for the requested quote.
type QuoteSide string
const (
QuoteSideBuyBaseSellQuote QuoteSide = "buy_base_sell_quote"
QuoteSideSellBaseBuyQuote QuoteSide = "sell_base_buy_quote"
)
// QuoteAmountType indicates which leg amount was provided by the caller.
type QuoteAmountType string
const (
QuoteAmountTypeBase QuoteAmountType = "base"
QuoteAmountTypeQuote QuoteAmountType = "quote"
)
// RoundingMode describes how rounding should be applied for a currency.
type RoundingMode string
const (
RoundingModeUnspecified RoundingMode = "unspecified"
RoundingModeHalfEven RoundingMode = "half_even"
RoundingModeHalfUp RoundingMode = "half_up"
RoundingModeDown RoundingMode = "down"
)
// CurrencyPair identifies an FX pair.
type CurrencyPair struct {
Base string `bson:"base" json:"base"`
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.
type QuoteMeta struct {
model.OrganizationBoundBase `bson:",inline" json:",inline"`
RequestRef string `bson:"requestRef,omitempty" json:"requestRef,omitempty"`
TenantRef string `bson:"tenantRef,omitempty" json:"tenantRef,omitempty"`
TraceRef string `bson:"traceRef,omitempty" json:"traceRef,omitempty"`
IdempotencyKey string `bson:"idempotencyKey,omitempty" json:"idempotencyKey,omitempty"`
}

View File

@@ -0,0 +1,115 @@
package mongo
import (
"context"
"time"
"github.com/tech/sendico/fx/storage"
"github.com/tech/sendico/fx/storage/mongo/store"
"github.com/tech/sendico/pkg/db"
"github.com/tech/sendico/pkg/db/transaction"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"go.mongodb.org/mongo-driver/mongo"
"go.uber.org/zap"
)
type Store struct {
logger mlogger.Logger
conn *db.MongoConnection
db *mongo.Database
txFactory transaction.Factory
rates storage.RatesStore
quotes storage.QuotesStore
pairs storage.PairStore
currencies storage.CurrencyStore
}
func New(logger mlogger.Logger, conn *db.MongoConnection) (*Store, error) {
if conn == nil {
return nil, merrors.InvalidArgument("mongo connection is nil")
}
client := conn.Client()
if client == nil {
return nil, merrors.Internal("mongo client not initialised")
}
db := conn.Database()
txFactory := newMongoTransactionFactory(client)
s := &Store{
logger: logger.Named("storage").Named("mongo"),
conn: conn,
db: db,
txFactory: txFactory,
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := s.Ping(ctx); err != nil {
s.logger.Error("mongo ping failed during store init", zap.Error(err))
return nil, err
}
ratesStore, err := store.NewRates(s.logger, db)
if err != nil {
s.logger.Error("failed to initialize rates store", zap.Error(err))
return nil, err
}
quotesStore, err := store.NewQuotes(s.logger, db, txFactory)
if err != nil {
s.logger.Error("failed to initialize quotes store", zap.Error(err))
return nil, err
}
pairsStore, err := store.NewPair(s.logger, db)
if err != nil {
s.logger.Error("failed to initialize pair store", zap.Error(err))
return nil, err
}
currencyStore, err := store.NewCurrency(s.logger, db)
if err != nil {
s.logger.Error("failed to initialize currency store", zap.Error(err))
return nil, err
}
s.rates = ratesStore
s.quotes = quotesStore
s.pairs = pairsStore
s.currencies = currencyStore
s.logger.Info("mongo storage ready")
return s, nil
}
func (s *Store) Ping(ctx context.Context) error {
return s.conn.Ping(ctx)
}
func (s *Store) Rates() storage.RatesStore {
return s.rates
}
func (s *Store) Quotes() storage.QuotesStore {
return s.quotes
}
func (s *Store) Pairs() storage.PairStore {
return s.pairs
}
func (s *Store) Currencies() storage.CurrencyStore {
return s.currencies
}
func (s *Store) Database() *mongo.Database {
return s.db
}
func (s *Store) TransactionFactory() transaction.Factory {
return s.txFactory
}
var _ storage.Repository = (*Store)(nil)

View File

@@ -0,0 +1,113 @@
package store
import (
"context"
"errors"
"github.com/tech/sendico/fx/storage"
"github.com/tech/sendico/fx/storage/model"
"github.com/tech/sendico/pkg/db/repository"
ri "github.com/tech/sendico/pkg/db/repository/index"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"go.mongodb.org/mongo-driver/mongo"
"go.uber.org/zap"
)
type currencyStore struct {
logger mlogger.Logger
repo repository.Repository
}
func NewCurrency(logger mlogger.Logger, db *mongo.Database) (storage.CurrencyStore, error) {
repo := repository.CreateMongoRepository(db, model.CurrenciesCollection)
index := &ri.Definition{
Keys: []ri.Key{
{Field: "code", Sort: ri.Asc},
},
Unique: true,
}
if err := repo.CreateIndex(index); err != nil {
logger.Error("failed to ensure currencies index", zap.Error(err))
return nil, err
}
childLogger := logger.Named(model.CurrenciesCollection)
childLogger.Debug("currency store initialised", zap.String("collection", model.CurrenciesCollection))
return &currencyStore{
logger: childLogger,
repo: repo,
}, nil
}
func (c *currencyStore) Get(ctx context.Context, code string) (*model.Currency, error) {
if code == "" {
c.logger.Warn("attempt to fetch currency with empty code")
return nil, merrors.InvalidArgument("currencyStore: empty code")
}
result := &model.Currency{}
if err := c.repo.FindOneByFilter(ctx, repository.Filter("code", code), result); err != nil {
if errors.Is(err, merrors.ErrNoData) {
c.logger.Debug("currency not found", zap.String("code", code))
}
return nil, err
}
c.logger.Debug("currency loaded", zap.String("code", code))
return result, nil
}
func (c *currencyStore) List(ctx context.Context, codes ...string) ([]*model.Currency, error) {
query := repository.Query()
if len(codes) > 0 {
values := make([]any, len(codes))
for i, code := range codes {
values[i] = code
}
query = query.In(repository.Field("code"), values...)
}
currencies := make([]*model.Currency, 0)
err := c.repo.FindManyByFilter(ctx, query, func(cur *mongo.Cursor) error {
doc := &model.Currency{}
if err := cur.Decode(doc); err != nil {
return err
}
currencies = append(currencies, doc)
return nil
})
if err != nil {
c.logger.Error("failed to list currencies", zap.Error(err))
return nil, err
}
c.logger.Debug("listed currencies", zap.Int("count", len(currencies)))
return currencies, nil
}
func (c *currencyStore) Upsert(ctx context.Context, currency *model.Currency) error {
if currency == nil {
c.logger.Warn("attempt to upsert nil currency")
return merrors.InvalidArgument("currencyStore: nil currency")
}
if currency.Code == "" {
c.logger.Warn("attempt to upsert currency with empty code")
return merrors.InvalidArgument("currencyStore: empty code")
}
existing := &model.Currency{}
filter := repository.Filter("code", currency.Code)
if err := c.repo.FindOneByFilter(ctx, filter, existing); err != nil {
if errors.Is(err, merrors.ErrNoData) {
c.logger.Debug("inserting new currency", zap.String("code", currency.Code))
return c.repo.Insert(ctx, currency, filter)
}
c.logger.Error("failed to fetch currency", zap.Error(err), zap.String("code", currency.Code))
return err
}
if existing.GetID() != nil {
currency.SetID(*existing.GetID())
}
c.logger.Debug("updating currency", zap.String("code", currency.Code))
return c.repo.Update(ctx, currency)
}

View File

@@ -0,0 +1,104 @@
package store
import (
"context"
"errors"
"testing"
"github.com/tech/sendico/fx/storage/model"
"github.com/tech/sendico/pkg/db/repository/builder"
rd "github.com/tech/sendico/pkg/db/repository/decoder"
"github.com/tech/sendico/pkg/db/storable"
"github.com/tech/sendico/pkg/merrors"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.uber.org/zap"
)
func TestCurrencyStoreGet(t *testing.T) {
repo := &repoStub{
findOneFn: func(_ context.Context, _ builder.Query, result storable.Storable) error {
currency := result.(*model.Currency)
currency.Code = "USD"
return nil
},
}
store := &currencyStore{logger: zap.NewNop(), repo: repo}
res, err := store.Get(context.Background(), "USD")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if res.Code != "USD" {
t.Fatalf("unexpected code: %s", res.Code)
}
}
func TestCurrencyStoreList(t *testing.T) {
repo := &repoStub{
findManyFn: func(_ context.Context, _ builder.Query, decode rd.DecodingFunc) error {
return runDecoderWithDocs(t, decode, &model.Currency{Code: "USD"})
},
}
store := &currencyStore{logger: zap.NewNop(), repo: repo}
currencies, err := store.List(context.Background(), "USD")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(currencies) != 1 || currencies[0].Code != "USD" {
t.Fatalf("unexpected list result: %+v", currencies)
}
}
func TestCurrencyStoreUpsertInsert(t *testing.T) {
inserted := false
repo := &repoStub{
findOneFn: func(context.Context, builder.Query, storable.Storable) error {
return merrors.ErrNoData
},
insertFn: func(_ context.Context, obj storable.Storable, _ builder.Query) error {
_ = cloneCurrency(t, obj)
inserted = true
return nil
},
}
store := &currencyStore{logger: zap.NewNop(), repo: repo}
if err := store.Upsert(context.Background(), &model.Currency{Code: "USD"}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !inserted {
t.Fatalf("expected insert to be called")
}
}
func TestCurrencyStoreGetInvalid(t *testing.T) {
store := &currencyStore{logger: zap.NewNop(), repo: &repoStub{}}
if _, err := store.Get(context.Background(), ""); !errors.Is(err, merrors.ErrInvalidArg) {
t.Fatalf("expected invalid argument error")
}
}
func TestCurrencyStoreUpsertUpdate(t *testing.T) {
var updated *model.Currency
repo := &repoStub{
findOneFn: func(_ context.Context, _ builder.Query, result storable.Storable) error {
currency := result.(*model.Currency)
currency.SetID(primitive.NewObjectID())
currency.Code = "USD"
return nil
},
updateFn: func(_ context.Context, obj storable.Storable) error {
updated = cloneCurrency(t, obj)
return nil
},
}
store := &currencyStore{logger: zap.NewNop(), repo: repo}
if err := store.Upsert(context.Background(), &model.Currency{Code: "USD"}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if updated == nil || updated.GetID() == nil {
t.Fatalf("expected update to preserve ID")
}
}

View File

@@ -0,0 +1,111 @@
package store
import (
"context"
"errors"
"github.com/tech/sendico/fx/storage"
"github.com/tech/sendico/fx/storage/model"
"github.com/tech/sendico/pkg/db/repository"
ri "github.com/tech/sendico/pkg/db/repository/index"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"go.mongodb.org/mongo-driver/mongo"
"go.uber.org/zap"
)
type pairStore struct {
logger mlogger.Logger
repo repository.Repository
}
func NewPair(logger mlogger.Logger, db *mongo.Database) (storage.PairStore, error) {
repo := repository.CreateMongoRepository(db, model.PairsCollection)
index := &ri.Definition{
Keys: []ri.Key{
{Field: "pair.base", Sort: ri.Asc},
{Field: "pair.quote", Sort: ri.Asc},
},
Unique: true,
}
if err := repo.CreateIndex(index); err != nil {
logger.Error("failed to ensure pairs index", zap.Error(err))
return nil, err
}
logger.Debug("pair store initialised", zap.String("collection", model.PairsCollection))
return &pairStore{
logger: logger.Named(model.PairsCollection),
repo: repo,
}, nil
}
func (p *pairStore) ListEnabled(ctx context.Context) ([]*model.Pair, error) {
filter := repository.Query().Filter(repository.Field("isEnabled"), true)
pairs := make([]*model.Pair, 0)
err := p.repo.FindManyByFilter(ctx, filter, func(cur *mongo.Cursor) error {
doc := &model.Pair{}
if err := cur.Decode(doc); err != nil {
return err
}
pairs = append(pairs, doc)
return nil
})
if err != nil {
p.logger.Error("failed to list enabled pairs", zap.Error(err))
return nil, err
}
p.logger.Debug("listed enabled pairs", zap.Int("count", len(pairs)))
return pairs, nil
}
func (p *pairStore) Get(ctx context.Context, pair model.CurrencyPair) (*model.Pair, error) {
if pair.Base == "" || pair.Quote == "" {
p.logger.Warn("attempt to fetch pair with empty currency", zap.String("base", pair.Base), zap.String("quote", pair.Quote))
return nil, merrors.InvalidArgument("pairStore: incomplete pair")
}
result := &model.Pair{}
query := repository.Query().
Filter(repository.Field("pair").Dot("base"), pair.Base).
Filter(repository.Field("pair").Dot("quote"), pair.Quote)
if err := p.repo.FindOneByFilter(ctx, query, result); err != nil {
if errors.Is(err, merrors.ErrNoData) {
p.logger.Debug("pair not found", zap.String("base", pair.Base), zap.String("quote", pair.Quote))
}
return nil, err
}
p.logger.Debug("pair loaded", zap.String("base", pair.Base), zap.String("quote", pair.Quote))
return result, nil
}
func (p *pairStore) Upsert(ctx context.Context, pair *model.Pair) error {
if pair == nil {
p.logger.Warn("attempt to upsert nil pair")
return merrors.InvalidArgument("pairStore: nil pair")
}
if pair.Pair.Base == "" || pair.Pair.Quote == "" {
p.logger.Warn("attempt to upsert pair with empty currency", zap.String("base", pair.Pair.Base), zap.String("quote", pair.Pair.Quote))
return merrors.InvalidArgument("pairStore: incomplete pair")
}
existing := &model.Pair{}
query := repository.Query().
Filter(repository.Field("pair").Dot("base"), pair.Pair.Base).
Filter(repository.Field("pair").Dot("quote"), pair.Pair.Quote)
err := p.repo.FindOneByFilter(ctx, query, existing)
if err != nil {
if errors.Is(err, merrors.ErrNoData) {
p.logger.Debug("inserting new pair", zap.String("base", pair.Pair.Base), zap.String("quote", pair.Pair.Quote))
return p.repo.Insert(ctx, pair, query)
}
p.logger.Error("failed to fetch pair", zap.Error(err), zap.String("base", pair.Pair.Base), zap.String("quote", pair.Pair.Quote))
return err
}
if existing.GetID() != nil {
pair.SetID(*existing.GetID())
}
p.logger.Debug("updating pair", zap.String("base", pair.Pair.Base), zap.String("quote", pair.Pair.Quote))
return p.repo.Update(ctx, pair)
}

View File

@@ -0,0 +1,101 @@
package store
import (
"context"
"errors"
"testing"
"github.com/tech/sendico/fx/storage/model"
"github.com/tech/sendico/pkg/db/repository/builder"
rd "github.com/tech/sendico/pkg/db/repository/decoder"
"github.com/tech/sendico/pkg/db/storable"
"github.com/tech/sendico/pkg/merrors"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.uber.org/zap"
)
func TestPairStoreListEnabled(t *testing.T) {
repo := &repoStub{
findManyFn: func(_ context.Context, _ builder.Query, decode rd.DecodingFunc) error {
docs := []interface{}{
&model.Pair{Pair: model.CurrencyPair{Base: "USD", Quote: "EUR"}},
}
return runDecoderWithDocs(t, decode, docs...)
},
}
store := &pairStore{logger: zap.NewNop(), repo: repo}
pairs, err := store.ListEnabled(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(pairs) != 1 || pairs[0].Pair.Base != "USD" {
t.Fatalf("unexpected pairs result: %+v", pairs)
}
}
func TestPairStoreGetInvalid(t *testing.T) {
store := &pairStore{logger: zap.NewNop(), repo: &repoStub{}}
if _, err := store.Get(context.Background(), model.CurrencyPair{}); !errors.Is(err, merrors.ErrInvalidArg) {
t.Fatalf("expected invalid argument error")
}
}
func TestPairStoreGetNotFound(t *testing.T) {
repo := &repoStub{
findOneFn: func(context.Context, builder.Query, storable.Storable) error {
return merrors.ErrNoData
},
}
store := &pairStore{logger: zap.NewNop(), repo: repo}
if _, err := store.Get(context.Background(), model.CurrencyPair{Base: "USD", Quote: "EUR"}); !errors.Is(err, merrors.ErrNoData) {
t.Fatalf("expected ErrNoData, got %v", err)
}
}
func TestPairStoreUpsertInsert(t *testing.T) {
ctx := context.Background()
var inserted *model.Pair
repo := &repoStub{
findOneFn: func(context.Context, builder.Query, storable.Storable) error {
return merrors.ErrNoData
},
insertFn: func(_ context.Context, obj storable.Storable, _ builder.Query) error {
inserted = clonePair(t, obj)
return nil
},
}
store := &pairStore{logger: zap.NewNop(), repo: repo}
pair := &model.Pair{Pair: model.CurrencyPair{Base: "USD", Quote: "EUR"}}
if err := store.Upsert(ctx, pair); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if inserted == nil {
t.Fatalf("expected insert to be called")
}
}
func TestPairStoreUpsertUpdate(t *testing.T) {
ctx := context.Background()
var updated *model.Pair
repo := &repoStub{
findOneFn: func(_ context.Context, _ builder.Query, result storable.Storable) error {
pair := result.(*model.Pair)
pair.SetID(primitive.NewObjectID())
return nil
},
updateFn: func(_ context.Context, obj storable.Storable) error {
updated = clonePair(t, obj)
return nil
},
}
store := &pairStore{logger: zap.NewNop(), repo: repo}
if err := store.Upsert(ctx, &model.Pair{Pair: model.CurrencyPair{Base: "USD", Quote: "EUR"}}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if updated == nil || updated.GetID() == nil {
t.Fatalf("expected update to preserve existing ID")
}
}

View File

@@ -0,0 +1,198 @@
package store
import (
"context"
"errors"
"time"
"github.com/tech/sendico/fx/storage"
"github.com/tech/sendico/fx/storage/model"
"github.com/tech/sendico/pkg/db/repository"
"github.com/tech/sendico/pkg/db/repository/builder"
ri "github.com/tech/sendico/pkg/db/repository/index"
"github.com/tech/sendico/pkg/db/transaction"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"go.mongodb.org/mongo-driver/mongo"
"go.uber.org/zap"
)
type quotesStore struct {
logger mlogger.Logger
repo repository.Repository
txFactory transaction.Factory
}
func NewQuotes(logger mlogger.Logger, db *mongo.Database, txFactory transaction.Factory) (storage.QuotesStore, error) {
repo := repository.CreateMongoRepository(db, model.QuotesCollection)
indexes := []*ri.Definition{
{
Keys: []ri.Key{
{Field: "quoteRef", Sort: ri.Asc},
},
Unique: true,
},
{
Keys: []ri.Key{
{Field: "status", Sort: ri.Asc},
{Field: "expiresAtUnixMs", Sort: ri.Asc},
},
},
{
Keys: []ri.Key{
{Field: "consumedByLedgerTxnRef", Sort: ri.Asc},
},
},
}
ttlSeconds := int32(0)
indexes = append(indexes, &ri.Definition{
Keys: []ri.Key{
{Field: "expiresAt", Sort: ri.Asc},
},
TTL: &ttlSeconds,
Name: "quotes_expires_at_ttl",
})
for _, def := range indexes {
if err := repo.CreateIndex(def); err != nil {
logger.Error("failed to ensure quotes index", zap.Error(err))
return nil, err
}
}
childLogger := logger.Named(model.QuotesCollection)
childLogger.Debug("quotes store initialised", zap.String("collection", model.QuotesCollection))
return &quotesStore{
logger: childLogger,
repo: repo,
txFactory: txFactory,
}, nil
}
func (q *quotesStore) Issue(ctx context.Context, quote *model.Quote) error {
if quote == nil {
q.logger.Warn("attempt to issue nil quote")
return merrors.InvalidArgument("quotesStore: nil quote")
}
if quote.QuoteRef == "" {
q.logger.Warn("attempt to issue quote with empty ref")
return merrors.InvalidArgument("quotesStore: empty quoteRef")
}
if quote.ExpiresAtUnixMs > 0 && quote.ExpiresAt == nil {
expiry := time.UnixMilli(quote.ExpiresAtUnixMs)
quote.ExpiresAt = &expiry
}
quote.Status = model.QuoteStatusIssued
quote.ConsumedByLedgerTxnRef = ""
quote.ConsumedAtUnixMs = nil
if err := q.repo.Insert(ctx, quote, repository.Filter("quoteRef", quote.QuoteRef)); err != nil {
q.logger.Error("failed to insert quote", zap.Error(err), zap.String("quote_ref", quote.QuoteRef))
return err
}
q.logger.Debug("quote issued", zap.String("quote_ref", quote.QuoteRef), zap.Bool("firm", quote.Firm))
return nil
}
func (q *quotesStore) GetByRef(ctx context.Context, quoteRef string) (*model.Quote, error) {
if quoteRef == "" {
q.logger.Warn("attempt to fetch quote with empty ref")
return nil, merrors.InvalidArgument("quotesStore: empty quoteRef")
}
quote := &model.Quote{}
if err := q.repo.FindOneByFilter(ctx, repository.Filter("quoteRef", quoteRef), quote); err != nil {
if errors.Is(err, merrors.ErrNoData) {
q.logger.Debug("quote not found", zap.String("quote_ref", quoteRef))
}
return nil, err
}
q.logger.Debug("quote loaded", zap.String("quote_ref", quoteRef), zap.String("status", string(quote.Status)))
return quote, nil
}
func (q *quotesStore) Consume(ctx context.Context, quoteRef, ledgerTxnRef string, when time.Time) (*model.Quote, error) {
if quoteRef == "" || ledgerTxnRef == "" {
q.logger.Warn("attempt to consume quote with missing identifiers", zap.String("quote_ref", quoteRef), zap.String("ledger_ref", ledgerTxnRef))
return nil, merrors.InvalidArgument("quotesStore: missing identifiers")
}
if when.IsZero() {
when = time.Now()
}
q.logger.Debug("consuming quote", zap.String("quote_ref", quoteRef), zap.String("ledger_ref", ledgerTxnRef))
txn := q.txFactory.CreateTransaction()
result, err := txn.Execute(ctx, func(txCtx context.Context) (any, error) {
quote := &model.Quote{}
if err := q.repo.FindOneByFilter(txCtx, repository.Filter("quoteRef", quoteRef), quote); err != nil {
return nil, err
}
if !quote.Firm {
q.logger.Warn("quote not firm", zap.String("quote_ref", quoteRef))
return nil, storage.ErrQuoteNotFirm
}
if quote.Status == model.QuoteStatusExpired || quote.IsExpired(when) {
quote.MarkExpired()
if err := q.repo.Update(txCtx, quote); err != nil {
return nil, err
}
q.logger.Info("quote expired during consume", zap.String("quote_ref", quoteRef))
return nil, storage.ErrQuoteExpired
}
if quote.Status == model.QuoteStatusConsumed {
if quote.ConsumedByLedgerTxnRef == ledgerTxnRef {
q.logger.Debug("quote already consumed by ledger", zap.String("quote_ref", quoteRef), zap.String("ledger_ref", ledgerTxnRef))
return quote, nil
}
q.logger.Warn("quote consumed by different ledger", zap.String("quote_ref", quoteRef), zap.String("existing_ledger_ref", quote.ConsumedByLedgerTxnRef))
return nil, storage.ErrQuoteConsumed
}
quote.MarkConsumed(ledgerTxnRef, when)
if err := q.repo.Update(txCtx, quote); err != nil {
return nil, err
}
q.logger.Info("quote consumed", zap.String("quote_ref", quoteRef), zap.String("ledger_ref", ledgerTxnRef))
return quote, nil
})
if err != nil {
q.logger.Error("quote consumption failed", zap.Error(err), zap.String("quote_ref", quoteRef), zap.String("ledger_ref", ledgerTxnRef))
return nil, err
}
quote, _ := result.(*model.Quote)
if quote == nil {
return nil, merrors.Internal("quotesStore: transaction returned nil quote")
}
return quote, nil
}
func (q *quotesStore) ExpireIssuedBefore(ctx context.Context, cutoff time.Time) (int, error) {
if cutoff.IsZero() {
q.logger.Warn("attempt to expire quotes with zero cutoff")
return 0, merrors.InvalidArgument("quotesStore: cutoff time is zero")
}
filter := repository.Query().
Filter(repository.Field("status"), model.QuoteStatusIssued).
Comparison(repository.Field("expiresAtUnixMs"), builder.Lt, cutoff.UnixMilli())
patch := repository.Patch().
Set(repository.Field("status"), model.QuoteStatusExpired).
Unset(repository.Field("consumedByLedgerTxnRef")).
Unset(repository.Field("consumedAtUnixMs"))
updated, err := q.repo.PatchMany(ctx, filter, patch)
if err != nil {
q.logger.Error("failed to expire quotes", zap.Error(err))
return 0, err
}
if updated > 0 {
q.logger.Info("quotes expired", zap.Int("count", updated))
}
return updated, nil
}

View File

@@ -0,0 +1,184 @@
package store
import (
"context"
"errors"
"testing"
"time"
"github.com/tech/sendico/fx/storage"
"github.com/tech/sendico/fx/storage/model"
"github.com/tech/sendico/pkg/db/repository/builder"
"github.com/tech/sendico/pkg/db/storable"
"github.com/tech/sendico/pkg/merrors"
"go.uber.org/zap"
)
func TestQuotesStoreIssue(t *testing.T) {
ctx := context.Background()
var inserted *model.Quote
repo := &repoStub{
insertFn: func(_ context.Context, obj storable.Storable, _ builder.Query) error {
inserted = cloneQuote(t, obj)
return nil
},
}
store := &quotesStore{logger: zap.NewNop(), repo: repo, txFactory: &txFactoryStub{}}
quote := &model.Quote{QuoteRef: "q1"}
if err := store.Issue(ctx, quote); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if inserted == nil || inserted.Status != model.QuoteStatusIssued {
t.Fatalf("expected issued quote to be inserted")
}
}
func TestQuotesStoreIssueSetsExpiryDate(t *testing.T) {
ctx := context.Background()
var inserted *model.Quote
repo := &repoStub{
insertFn: func(_ context.Context, obj storable.Storable, _ builder.Query) error {
inserted = cloneQuote(t, obj)
return nil
},
}
store := &quotesStore{logger: zap.NewNop(), repo: repo, txFactory: &txFactoryStub{}}
expiry := time.Now().Add(2 * time.Minute).UnixMilli()
quote := &model.Quote{
QuoteRef: "q1",
ExpiresAtUnixMs: expiry,
}
if err := store.Issue(ctx, quote); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if inserted == nil || inserted.ExpiresAt == nil {
t.Fatalf("expected expiry timestamp to be populated")
}
if inserted.ExpiresAt.UnixMilli() != expiry {
t.Fatalf("expected expiry to equal %d, got %d", expiry, inserted.ExpiresAt.UnixMilli())
}
}
func TestQuotesStoreIssueInvalidInput(t *testing.T) {
store := &quotesStore{logger: zap.NewNop(), repo: &repoStub{}, txFactory: &txFactoryStub{}}
if err := store.Issue(context.Background(), nil); !errors.Is(err, merrors.ErrInvalidArg) {
t.Fatalf("expected invalid argument error, got %v", err)
}
}
func TestQuotesStoreConsumeSuccess(t *testing.T) {
ctx := context.Background()
now := time.Now()
ledgerRef := "ledger-1"
stored := &model.Quote{
QuoteRef: "q1",
Firm: true,
Status: model.QuoteStatusIssued,
ExpiresAtUnixMs: now.Add(5 * time.Minute).UnixMilli(),
}
var updated *model.Quote
repo := &repoStub{
findOneFn: func(_ context.Context, _ builder.Query, result storable.Storable) error {
quote := result.(*model.Quote)
*quote = *stored
return nil
},
updateFn: func(_ context.Context, obj storable.Storable) error {
updated = cloneQuote(t, obj)
return nil
},
}
factory := &txFactoryStub{}
store := &quotesStore{logger: zap.NewNop(), repo: repo, txFactory: factory}
res, err := store.Consume(ctx, "q1", ledgerRef, now)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if res == nil || res.Status != model.QuoteStatusConsumed {
t.Fatalf("expected consumed quote")
}
if updated == nil || updated.ConsumedByLedgerTxnRef != ledgerRef {
t.Fatalf("expected update with ledger ref")
}
}
func TestQuotesStoreConsumeExpired(t *testing.T) {
ctx := context.Background()
stored := &model.Quote{
QuoteRef: "q1",
Firm: true,
Status: model.QuoteStatusIssued,
ExpiresAtUnixMs: time.Now().Add(-time.Minute).UnixMilli(),
}
var updated *model.Quote
repo := &repoStub{
findOneFn: func(_ context.Context, _ builder.Query, result storable.Storable) error {
quote := result.(*model.Quote)
*quote = *stored
return nil
},
updateFn: func(_ context.Context, obj storable.Storable) error {
updated = cloneQuote(t, obj)
return nil
},
}
factory := &txFactoryStub{}
store := &quotesStore{logger: zap.NewNop(), repo: repo, txFactory: factory}
_, err := store.Consume(ctx, "q1", "ledger", time.Now())
if !errors.Is(err, storage.ErrQuoteExpired) {
t.Fatalf("expected ErrQuoteExpired, got %v", err)
}
if updated == nil || updated.Status != model.QuoteStatusExpired {
t.Fatalf("expected quote marked expired")
}
}
func TestQuotesStoreExpireIssuedBefore(t *testing.T) {
repo := &repoStub{
patchManyFn: func(context.Context, builder.Query, builder.Patch) (int, error) {
return 3, nil
},
}
store := &quotesStore{logger: zap.NewNop(), repo: repo, txFactory: &txFactoryStub{}}
count, err := store.ExpireIssuedBefore(context.Background(), time.Now())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if count != 3 {
t.Fatalf("expected 3 expired quotes, got %d", count)
}
}
func TestQuotesStoreExpireZeroCutoff(t *testing.T) {
store := &quotesStore{logger: zap.NewNop(), repo: &repoStub{}, txFactory: &txFactoryStub{}}
if _, err := store.ExpireIssuedBefore(context.Background(), time.Time{}); !errors.Is(err, merrors.ErrInvalidArg) {
t.Fatalf("expected invalid argument error")
}
}
func TestQuotesStoreGetByRefNotFound(t *testing.T) {
repo := &repoStub{
findOneFn: func(context.Context, builder.Query, storable.Storable) error {
return merrors.ErrNoData
},
}
store := &quotesStore{logger: zap.NewNop(), repo: repo, txFactory: &txFactoryStub{}}
if _, err := store.GetByRef(context.Background(), "missing"); !errors.Is(err, merrors.ErrNoData) {
t.Fatalf("expected ErrNoData, got %v", err)
}
}
func TestQuotesStoreGetByRefInvalid(t *testing.T) {
store := &quotesStore{logger: zap.NewNop(), repo: &repoStub{}, txFactory: &txFactoryStub{}}
if _, err := store.GetByRef(context.Background(), ""); !errors.Is(err, merrors.ErrInvalidArg) {
t.Fatalf("expected invalid argument error")
}
}

View File

@@ -0,0 +1,127 @@
package store
import (
"context"
"errors"
"time"
"github.com/tech/sendico/fx/storage"
"github.com/tech/sendico/fx/storage/model"
"github.com/tech/sendico/pkg/db/repository"
ri "github.com/tech/sendico/pkg/db/repository/index"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"go.mongodb.org/mongo-driver/mongo"
"go.uber.org/zap"
)
type ratesStore struct {
logger mlogger.Logger
repo repository.Repository
}
func NewRates(logger mlogger.Logger, db *mongo.Database) (storage.RatesStore, error) {
repo := repository.CreateMongoRepository(db, model.RatesCollection)
indexes := []*ri.Definition{
{
Keys: []ri.Key{
{Field: "pair.base", Sort: ri.Asc},
{Field: "pair.quote", Sort: ri.Asc},
{Field: "provider", Sort: ri.Asc},
{Field: "asOfUnixMs", Sort: ri.Desc},
},
},
{
Keys: []ri.Key{
{Field: "rateRef", Sort: ri.Asc},
},
Unique: true,
},
}
ttlSeconds := int32(24 * 60 * 60)
indexes = append(indexes, &ri.Definition{
Keys: []ri.Key{
{Field: "asOf", Sort: ri.Asc},
},
TTL: &ttlSeconds,
Name: "rates_as_of_ttl",
})
for _, def := range indexes {
if err := repo.CreateIndex(def); err != nil {
logger.Error("failed to ensure rates index", zap.Error(err))
return nil, err
}
}
logger.Debug("rates store initialised", zap.String("collection", model.RatesCollection))
return &ratesStore{
logger: logger.Named(model.RatesCollection),
repo: repo,
}, nil
}
func (r *ratesStore) UpsertSnapshot(ctx context.Context, snapshot *model.RateSnapshot) error {
if snapshot == nil {
r.logger.Warn("attempt to upsert nil snapshot")
return merrors.InvalidArgument("ratesStore: nil snapshot")
}
if snapshot.RateRef == "" {
r.logger.Warn("attempt to upsert snapshot with empty rate_ref")
return merrors.InvalidArgument("ratesStore: empty rateRef")
}
if snapshot.AsOfUnixMs > 0 && snapshot.AsOf == nil {
asOf := time.UnixMilli(snapshot.AsOfUnixMs).UTC()
snapshot.AsOf = &asOf
}
existing := &model.RateSnapshot{}
filter := repository.Filter("rateRef", snapshot.RateRef)
err := r.repo.FindOneByFilter(ctx, filter, existing)
if err != nil {
if errors.Is(err, merrors.ErrNoData) {
r.logger.Debug("inserting new rate snapshot", zap.String("rate_ref", snapshot.RateRef))
return r.repo.Insert(ctx, snapshot, filter)
}
r.logger.Error("failed to query rate snapshot", zap.Error(err), zap.String("rate_ref", snapshot.RateRef))
return err
}
if existing.GetID() != nil {
snapshot.SetID(*existing.GetID())
}
r.logger.Debug("updating rate snapshot", zap.String("rate_ref", snapshot.RateRef))
return r.repo.Update(ctx, snapshot)
}
func (r *ratesStore) LatestSnapshot(ctx context.Context, pair model.CurrencyPair, provider string) (*model.RateSnapshot, error) {
query := repository.Query().
Filter(repository.Field("pair").Dot("base"), pair.Base).
Filter(repository.Field("pair").Dot("quote"), pair.Quote)
if provider != "" {
query = query.Filter(repository.Field("provider"), provider)
}
limit := int64(1)
query = query.Sort(repository.Field("asOfUnixMs"), false).Limit(&limit)
var result *model.RateSnapshot
err := r.repo.FindManyByFilter(ctx, query, func(cur *mongo.Cursor) error {
doc := &model.RateSnapshot{}
if err := cur.Decode(doc); err != nil {
return err
}
result = doc
return nil
})
if err != nil {
return nil, err
}
if result == nil {
return nil, merrors.ErrNoData
}
return result, nil
}

View File

@@ -0,0 +1,87 @@
package store
import (
"context"
"testing"
"time"
"github.com/tech/sendico/fx/storage/model"
"github.com/tech/sendico/pkg/db/repository/builder"
rd "github.com/tech/sendico/pkg/db/repository/decoder"
"github.com/tech/sendico/pkg/db/storable"
"github.com/tech/sendico/pkg/merrors"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.uber.org/zap"
)
func TestRatesStoreUpsertInsert(t *testing.T) {
ctx := context.Background()
var inserted *model.RateSnapshot
repo := &repoStub{
findOneFn: func(context.Context, builder.Query, storable.Storable) error {
return merrors.ErrNoData
},
insertFn: func(_ context.Context, obj storable.Storable, _ builder.Query) error {
inserted = cloneRate(t, obj)
return nil
},
}
store := &ratesStore{logger: zap.NewNop(), repo: repo}
snapshot := &model.RateSnapshot{RateRef: "r1"}
if err := store.UpsertSnapshot(ctx, snapshot); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if inserted == nil || inserted.RateRef != "r1" {
t.Fatalf("expected snapshot to be inserted")
}
}
func TestRatesStoreUpsertUpdate(t *testing.T) {
ctx := context.Background()
existingID := primitive.NewObjectID()
var updated *model.RateSnapshot
repo := &repoStub{
findOneFn: func(_ context.Context, _ builder.Query, result storable.Storable) error {
snap := result.(*model.RateSnapshot)
snap.SetID(existingID)
snap.RateRef = "existing"
return nil
},
updateFn: func(_ context.Context, obj storable.Storable) error {
snap := obj.(*model.RateSnapshot)
updated = snap
return nil
},
}
store := &ratesStore{logger: zap.NewNop(), repo: repo}
toUpdate := &model.RateSnapshot{RateRef: "existing"}
if err := store.UpsertSnapshot(ctx, toUpdate); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if updated == nil || updated.GetID() == nil || *updated.GetID() != existingID {
t.Fatalf("expected update to preserve ID")
}
}
func TestRatesStoreLatestSnapshot(t *testing.T) {
now := time.Now().UnixMilli()
repo := &repoStub{
findManyFn: func(_ context.Context, _ builder.Query, decode rd.DecodingFunc) error {
doc := &model.RateSnapshot{RateRef: "latest", AsOfUnixMs: now}
return runDecoderWithDocs(t, decode, doc)
},
}
store := &ratesStore{logger: zap.NewNop(), repo: repo}
res, err := store.LatestSnapshot(context.Background(), model.CurrencyPair{Base: "USD", Quote: "EUR"}, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if res.RateRef != "latest" || res.AsOfUnixMs != now {
t.Fatalf("unexpected snapshot returned: %+v", res)
}
}

View File

@@ -0,0 +1,189 @@
package store
import (
"context"
"testing"
"github.com/tech/sendico/fx/storage/model"
"github.com/tech/sendico/pkg/db/repository/builder"
rd "github.com/tech/sendico/pkg/db/repository/decoder"
ri "github.com/tech/sendico/pkg/db/repository/index"
"github.com/tech/sendico/pkg/db/storable"
"github.com/tech/sendico/pkg/db/transaction"
"github.com/tech/sendico/pkg/merrors"
pmodel "github.com/tech/sendico/pkg/model"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo"
)
type repoStub struct {
insertFn func(ctx context.Context, obj storable.Storable, filter builder.Query) error
insertManyFn func(ctx context.Context, objects []storable.Storable) error
findOneFn func(ctx context.Context, query builder.Query, result storable.Storable) error
findManyFn func(ctx context.Context, query builder.Query, decoder rd.DecodingFunc) error
updateFn func(ctx context.Context, obj storable.Storable) error
patchManyFn func(ctx context.Context, filter builder.Query, patch builder.Patch) (int, error)
createIdxFn func(def *ri.Definition) error
}
func (r *repoStub) Aggregate(ctx context.Context, b builder.Pipeline, decoder rd.DecodingFunc) error {
return merrors.NotImplemented("Aggregate not used")
}
func (r *repoStub) Insert(ctx context.Context, obj storable.Storable, filter builder.Query) error {
if r.insertFn != nil {
return r.insertFn(ctx, obj, filter)
}
return nil
}
func (r *repoStub) InsertMany(ctx context.Context, objects []storable.Storable) error {
if r.insertManyFn != nil {
return r.insertManyFn(ctx, objects)
}
return nil
}
func (r *repoStub) Get(ctx context.Context, id primitive.ObjectID, result storable.Storable) error {
return merrors.NotImplemented("Get not used")
}
func (r *repoStub) FindOneByFilter(ctx context.Context, query builder.Query, result storable.Storable) error {
if r.findOneFn != nil {
return r.findOneFn(ctx, query, result)
}
return nil
}
func (r *repoStub) FindManyByFilter(ctx context.Context, query builder.Query, decoder rd.DecodingFunc) error {
if r.findManyFn != nil {
return r.findManyFn(ctx, query, decoder)
}
return nil
}
func (r *repoStub) Update(ctx context.Context, obj storable.Storable) error {
if r.updateFn != nil {
return r.updateFn(ctx, obj)
}
return nil
}
func (r *repoStub) Patch(ctx context.Context, id primitive.ObjectID, patch builder.Patch) error {
return merrors.NotImplemented("Patch not used")
}
func (r *repoStub) PatchMany(ctx context.Context, filter builder.Query, patch builder.Patch) (int, error) {
if r.patchManyFn != nil {
return r.patchManyFn(ctx, filter, patch)
}
return 0, nil
}
func (r *repoStub) Delete(ctx context.Context, id primitive.ObjectID) error {
return merrors.NotImplemented("Delete not used")
}
func (r *repoStub) DeleteMany(ctx context.Context, query builder.Query) error {
return merrors.NotImplemented("DeleteMany not used")
}
func (r *repoStub) CreateIndex(def *ri.Definition) error {
if r.createIdxFn != nil {
return r.createIdxFn(def)
}
return nil
}
func (r *repoStub) ListIDs(ctx context.Context, query builder.Query) ([]primitive.ObjectID, error) {
return nil, merrors.NotImplemented("ListIDs not used")
}
func (r *repoStub) ListPermissionBound(ctx context.Context, query builder.Query) ([]pmodel.PermissionBoundStorable, error) {
return nil, merrors.NotImplemented("ListPermissionBound not used")
}
func (r *repoStub) ListAccountBound(ctx context.Context, query builder.Query) ([]pmodel.AccountBoundStorable, error) {
return nil, merrors.NotImplemented("ListAccountBound not used")
}
func (r *repoStub) Collection() string { return "test" }
type txFactoryStub struct {
executeFn func(ctx context.Context, cb transaction.Callback) (any, error)
}
func (f *txFactoryStub) CreateTransaction() transaction.Transaction {
return &txStub{executeFn: f.executeFn}
}
type txStub struct {
executeFn func(ctx context.Context, cb transaction.Callback) (any, error)
}
func (t *txStub) Execute(ctx context.Context, cb transaction.Callback) (any, error) {
if t.executeFn != nil {
return t.executeFn(ctx, cb)
}
return cb(ctx)
}
func cloneRate(t *testing.T, obj storable.Storable) *model.RateSnapshot {
t.Helper()
rate, ok := obj.(*model.RateSnapshot)
if !ok {
t.Fatalf("expected *model.RateSnapshot, got %T", obj)
}
copy := *rate
return &copy
}
func cloneQuote(t *testing.T, obj storable.Storable) *model.Quote {
t.Helper()
quote, ok := obj.(*model.Quote)
if !ok {
t.Fatalf("expected *model.Quote, got %T", obj)
}
copy := *quote
return &copy
}
func clonePair(t *testing.T, obj storable.Storable) *model.Pair {
t.Helper()
pair, ok := obj.(*model.Pair)
if !ok {
t.Fatalf("expected *model.Pair, got %T", obj)
}
copy := *pair
return &copy
}
func cloneCurrency(t *testing.T, obj storable.Storable) *model.Currency {
t.Helper()
currency, ok := obj.(*model.Currency)
if !ok {
t.Fatalf("expected *model.Currency, got %T", obj)
}
copy := *currency
return &copy
}
func runDecoderWithDocs(t *testing.T, decode rd.DecodingFunc, docs ...interface{}) error {
t.Helper()
cur, err := mongo.NewCursorFromDocuments(docs, nil, nil)
if err != nil {
t.Fatalf("failed to create cursor: %v", err)
}
defer cur.Close(context.Background())
if len(docs) > 0 {
if !cur.Next(context.Background()) {
return cur.Err()
}
}
if err := decode(cur); err != nil {
return err
}
return cur.Err()
}

View File

@@ -0,0 +1,38 @@
package mongo
import (
"context"
"github.com/tech/sendico/pkg/db/transaction"
"go.mongodb.org/mongo-driver/mongo"
)
type mongoTransactionFactory struct {
client *mongo.Client
}
func (f *mongoTransactionFactory) CreateTransaction() transaction.Transaction {
return &mongoTransaction{client: f.client}
}
type mongoTransaction struct {
client *mongo.Client
}
func (t *mongoTransaction) Execute(ctx context.Context, cb transaction.Callback) (any, error) {
session, err := t.client.StartSession()
if err != nil {
return nil, err
}
defer session.EndSession(ctx)
run := func(sessCtx mongo.SessionContext) (any, error) {
return cb(sessCtx)
}
return session.WithTransaction(ctx, run)
}
func newMongoTransactionFactory(client *mongo.Client) transaction.Factory {
return &mongoTransactionFactory{client: client}
}

53
api/fx/storage/storage.go Normal file
View File

@@ -0,0 +1,53 @@
package storage
import (
"context"
"time"
"github.com/tech/sendico/fx/storage/model"
)
type storageError string
func (e storageError) Error() string {
return string(e)
}
var (
ErrQuoteExpired = storageError("fx.storage: quote expired")
ErrQuoteConsumed = storageError("fx.storage: quote consumed")
ErrQuoteNotFirm = storageError("fx.storage: quote is not firm")
ErrQuoteConsumptionRace = storageError("fx.storage: quote consumption collision")
)
type Repository interface {
Ping(ctx context.Context) error
Rates() RatesStore
Quotes() QuotesStore
Pairs() PairStore
Currencies() CurrencyStore
}
type RatesStore interface {
UpsertSnapshot(ctx context.Context, snapshot *model.RateSnapshot) error
LatestSnapshot(ctx context.Context, pair model.CurrencyPair, provider string) (*model.RateSnapshot, error)
}
type QuotesStore interface {
Issue(ctx context.Context, quote *model.Quote) error
GetByRef(ctx context.Context, quoteRef string) (*model.Quote, error)
Consume(ctx context.Context, quoteRef, ledgerTxnRef string, when time.Time) (*model.Quote, error)
ExpireIssuedBefore(ctx context.Context, cutoff time.Time) (int, error)
}
type PairStore interface {
ListEnabled(ctx context.Context) ([]*model.Pair, error)
Get(ctx context.Context, pair model.CurrencyPair) (*model.Pair, error)
Upsert(ctx context.Context, p *model.Pair) error
}
type CurrencyStore interface {
Get(ctx context.Context, code string) (*model.Currency, error)
List(ctx context.Context, codes ...string) ([]*model.Currency, error)
Upsert(ctx context.Context, currency *model.Currency) error
}

View File

@@ -0,0 +1,32 @@
# Config file for Air in TOML format
root = "./../.."
tmp_dir = "tmp"
[build]
cmd = "go build -o app -ldflags \"-X 'github.com/tech/sendico/gateway/chain/internal/appversion.BuildUser=$(whoami)' -X 'github.com/tech/sendico/gateway/chain/internal/appversion.Version=$APP_V' -X 'github.com/tech/sendico/gateway/chain/internal/appversion.Branch=$BUILD_BRANCH' -X 'github.com/tech/sendico/gateway/chain/internal/appversion.Revision=$GIT_REV' -X 'github.com/tech/sendico/gateway/chain/internal/appversion.BuildDate=$(date)'\""
bin = "./app"
full_bin = "./app --debug --config.file=config.yml"
include_ext = ["go", "yaml", "yml"]
exclude_dir = ["gateway/chain/tmp", "pkg/.git", "gateway/chain/env"]
exclude_regex = ["_test\\.go"]
exclude_unchanged = true
follow_symlink = true
log = "air.log"
delay = 0
stop_on_error = true
send_interrupt = true
kill_delay = 500
args_bin = []
[log]
time = false
[color]
main = "magenta"
watcher = "cyan"
build = "yellow"
runner = "green"
[misc]
clean_on_exit = true

3
api/gateway/chain/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
internal/generated
.gocache
app

View File

@@ -0,0 +1,148 @@
package client
import (
"context"
"crypto/tls"
"fmt"
"strings"
"time"
"github.com/tech/sendico/pkg/merrors"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
)
// Client exposes typed helpers around the chain gateway gRPC API.
type Client interface {
CreateManagedWallet(ctx context.Context, req *chainv1.CreateManagedWalletRequest) (*chainv1.CreateManagedWalletResponse, error)
GetManagedWallet(ctx context.Context, req *chainv1.GetManagedWalletRequest) (*chainv1.GetManagedWalletResponse, error)
ListManagedWallets(ctx context.Context, req *chainv1.ListManagedWalletsRequest) (*chainv1.ListManagedWalletsResponse, error)
GetWalletBalance(ctx context.Context, req *chainv1.GetWalletBalanceRequest) (*chainv1.GetWalletBalanceResponse, error)
SubmitTransfer(ctx context.Context, req *chainv1.SubmitTransferRequest) (*chainv1.SubmitTransferResponse, error)
GetTransfer(ctx context.Context, req *chainv1.GetTransferRequest) (*chainv1.GetTransferResponse, error)
ListTransfers(ctx context.Context, req *chainv1.ListTransfersRequest) (*chainv1.ListTransfersResponse, error)
EstimateTransferFee(ctx context.Context, req *chainv1.EstimateTransferFeeRequest) (*chainv1.EstimateTransferFeeResponse, error)
Close() error
}
type grpcGatewayClient interface {
CreateManagedWallet(ctx context.Context, in *chainv1.CreateManagedWalletRequest, opts ...grpc.CallOption) (*chainv1.CreateManagedWalletResponse, error)
GetManagedWallet(ctx context.Context, in *chainv1.GetManagedWalletRequest, opts ...grpc.CallOption) (*chainv1.GetManagedWalletResponse, error)
ListManagedWallets(ctx context.Context, in *chainv1.ListManagedWalletsRequest, opts ...grpc.CallOption) (*chainv1.ListManagedWalletsResponse, error)
GetWalletBalance(ctx context.Context, in *chainv1.GetWalletBalanceRequest, opts ...grpc.CallOption) (*chainv1.GetWalletBalanceResponse, error)
SubmitTransfer(ctx context.Context, in *chainv1.SubmitTransferRequest, opts ...grpc.CallOption) (*chainv1.SubmitTransferResponse, error)
GetTransfer(ctx context.Context, in *chainv1.GetTransferRequest, opts ...grpc.CallOption) (*chainv1.GetTransferResponse, error)
ListTransfers(ctx context.Context, in *chainv1.ListTransfersRequest, opts ...grpc.CallOption) (*chainv1.ListTransfersResponse, error)
EstimateTransferFee(ctx context.Context, in *chainv1.EstimateTransferFeeRequest, opts ...grpc.CallOption) (*chainv1.EstimateTransferFeeResponse, error)
}
type chainGatewayClient struct {
cfg Config
conn *grpc.ClientConn
client grpcGatewayClient
}
// New dials the chain gateway endpoint and returns a ready client.
func New(ctx context.Context, cfg Config, opts ...grpc.DialOption) (Client, error) {
cfg.setDefaults()
if strings.TrimSpace(cfg.Address) == "" {
return nil, merrors.InvalidArgument("chain-gateway: address is required")
}
dialCtx, cancel := context.WithTimeout(ctx, cfg.DialTimeout)
defer cancel()
dialOpts := make([]grpc.DialOption, 0, len(opts)+1)
dialOpts = append(dialOpts, opts...)
if cfg.Insecure {
dialOpts = append(dialOpts, grpc.WithTransportCredentials(insecure.NewCredentials()))
} else {
dialOpts = append(dialOpts, grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{})))
}
conn, err := grpc.DialContext(dialCtx, cfg.Address, dialOpts...)
if err != nil {
return nil, merrors.Internal(fmt.Sprintf("chain-gateway: dial %s: %s", cfg.Address, err.Error()))
}
return &chainGatewayClient{
cfg: cfg,
conn: conn,
client: chainv1.NewChainGatewayServiceClient(conn),
}, nil
}
// NewWithClient injects a pre-built gateway client (useful for tests).
func NewWithClient(cfg Config, gc grpcGatewayClient) Client {
cfg.setDefaults()
return &chainGatewayClient{
cfg: cfg,
client: gc,
}
}
func (c *chainGatewayClient) Close() error {
if c.conn != nil {
return c.conn.Close()
}
return nil
}
func (c *chainGatewayClient) CreateManagedWallet(ctx context.Context, req *chainv1.CreateManagedWalletRequest) (*chainv1.CreateManagedWalletResponse, error) {
ctx, cancel := c.callContext(ctx)
defer cancel()
return c.client.CreateManagedWallet(ctx, req)
}
func (c *chainGatewayClient) GetManagedWallet(ctx context.Context, req *chainv1.GetManagedWalletRequest) (*chainv1.GetManagedWalletResponse, error) {
ctx, cancel := c.callContext(ctx)
defer cancel()
return c.client.GetManagedWallet(ctx, req)
}
func (c *chainGatewayClient) ListManagedWallets(ctx context.Context, req *chainv1.ListManagedWalletsRequest) (*chainv1.ListManagedWalletsResponse, error) {
ctx, cancel := c.callContext(ctx)
defer cancel()
return c.client.ListManagedWallets(ctx, req)
}
func (c *chainGatewayClient) GetWalletBalance(ctx context.Context, req *chainv1.GetWalletBalanceRequest) (*chainv1.GetWalletBalanceResponse, error) {
ctx, cancel := c.callContext(ctx)
defer cancel()
return c.client.GetWalletBalance(ctx, req)
}
func (c *chainGatewayClient) SubmitTransfer(ctx context.Context, req *chainv1.SubmitTransferRequest) (*chainv1.SubmitTransferResponse, error) {
ctx, cancel := c.callContext(ctx)
defer cancel()
return c.client.SubmitTransfer(ctx, req)
}
func (c *chainGatewayClient) GetTransfer(ctx context.Context, req *chainv1.GetTransferRequest) (*chainv1.GetTransferResponse, error) {
ctx, cancel := c.callContext(ctx)
defer cancel()
return c.client.GetTransfer(ctx, req)
}
func (c *chainGatewayClient) ListTransfers(ctx context.Context, req *chainv1.ListTransfersRequest) (*chainv1.ListTransfersResponse, error) {
ctx, cancel := c.callContext(ctx)
defer cancel()
return c.client.ListTransfers(ctx, req)
}
func (c *chainGatewayClient) EstimateTransferFee(ctx context.Context, req *chainv1.EstimateTransferFeeRequest) (*chainv1.EstimateTransferFeeResponse, error) {
ctx, cancel := c.callContext(ctx)
defer cancel()
return c.client.EstimateTransferFee(ctx, req)
}
func (c *chainGatewayClient) callContext(ctx context.Context) (context.Context, context.CancelFunc) {
timeout := c.cfg.CallTimeout
if timeout <= 0 {
timeout = 3 * time.Second
}
return context.WithTimeout(ctx, timeout)
}

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