Compare commits

225 Commits

Author SHA1 Message Date
57428c5c56 Merge pull request 'fixed processing status' (#566) from fr-568 into main
All checks were successful
ci/woodpecker/push/frontend Pipeline was successful
Reviewed-on: #566
2026-02-27 12:10:51 +00:00
Stephan D
5113568a1b fixed processing status 2026-02-27 13:08:23 +01:00
5f4e580b2c Merge pull request 'gateway limits fix' (#564) from mntx-563 into main
All checks were successful
ci/woodpecker/push/gateway_mntx Pipeline was successful
Reviewed-on: #564
2026-02-27 02:58:43 +00:00
Stephan D
039a41e6d8 gateway limits fix 2026-02-27 03:57:52 +01:00
98770eda1e Merge pull request '+autoremoval of stale discovery records' (#562) from discovery-566 into main
Some checks failed
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/discovery 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/notification Pipeline is pending
ci/woodpecker/push/payments_methods Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/payments_quotation Pipeline is pending
ci/woodpecker/push/billing_documents Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/gateway_chain Pipeline failed
ci/woodpecker/push/gateway_mntx Pipeline was successful
ci/woodpecker/push/gateway_tgsettle Pipeline failed
ci/woodpecker/push/ledger Pipeline failed
ci/woodpecker/push/gateway_tron Pipeline failed
Reviewed-on: #562
2026-02-27 01:55:18 +00:00
Stephan D
fc5d44364b +autoremoval of stale discovery records 2026-02-27 02:53:55 +01:00
55ddfff4f2 Merge pull request 'fixed rail & operation names' (#560) from rail-559 into main
Some checks failed
ci/woodpecker/push/frontend Pipeline is pending
ci/woodpecker/push/fx_ingestor Pipeline is pending
ci/woodpecker/push/fx_oracle Pipeline is pending
ci/woodpecker/push/gateway_chain Pipeline is pending
ci/woodpecker/push/gateway_mntx Pipeline is pending
ci/woodpecker/push/gateway_tgsettle Pipeline is pending
ci/woodpecker/push/gateway_tron Pipeline is pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_methods Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/payments_quotation Pipeline is pending
ci/woodpecker/push/billing_documents Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/discovery Pipeline failed
ci/woodpecker/push/billing_fees Pipeline failed
Reviewed-on: #560
2026-02-27 01:34:15 +00:00
Stephan D
747153bdbf fixed rail & operation names 2026-02-27 02:33:40 +01:00
82cf91e703 Merge pull request 'fixed mntx rail' (#558) from mntx-557 into main
All checks were successful
ci/woodpecker/push/gateway_mntx Pipeline was successful
Reviewed-on: #558
2026-02-27 00:45:54 +00:00
Stephan D
9fd9cba92a fixed mntx rail 2026-02-27 01:45:29 +01:00
4ea39c44ef Merge pull request 'docs fix' (#556) from docs-555 into main
All checks were successful
ci/woodpecker/push/frontend Pipeline was successful
Reviewed-on: #556
2026-02-27 00:30:22 +00:00
Stephan D
7d6d7c3f56 docs fix 2026-02-27 01:29:57 +01:00
560e2213b5 [infra][rebuild]Merge pull request 'Orchestration / payments v2' (#554) from pqpov2-547 into main
All checks were successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/billing_documents Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/discovery Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/gateway_mntx Pipeline was successful
ci/woodpecker/push/gateway_chain Pipeline was successful
ci/woodpecker/push/gateway_tgsettle Pipeline was successful
ci/woodpecker/push/gateway_tron Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_methods Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/payments_quotation Pipeline was successful
[infra][rebuild]Reviewed-on: #554
2026-02-26 22:45:54 +00:00
Stephan D
a794aff5f3 release version bump 2026-02-26 23:37:09 +01:00
Stephan D
fa9e6f47cf smarter optimizer for batch payments 2026-02-26 23:15:48 +01:00
Stephan D
947cd7f4c9 dto / mapper / model separation of verification purpose 2026-02-26 22:38:31 +01:00
Stephan D
20ce4485e8 missing proto definition 2026-02-26 22:29:18 +01:00
Stephan D
e8d763eb15 removed intent_ref from frontend 2026-02-26 22:20:54 +01:00
Stephan D
4949c4ffe0 Batch payment execution + got rid of intent references 2026-02-26 22:12:32 +01:00
Stephan D
7661038868 intent reference generation + propagation 2026-02-26 18:43:44 +01:00
Stephan D
0f95f898a8 gRPC error translation: invalid argument support 2026-02-26 16:59:09 +01:00
Stephan D
b4b5616de0 removed dead mntx dependency + dead settings 2026-02-26 16:32:10 +01:00
Stephan D
336f352858 Fixed billing fees unreachable error propagation. Added USDT ledger creation. Fixed ledger boundaries operation types 2026-02-26 16:25:52 +01:00
Stephan D
54e5c799e8 fixed ledger boundaries operatoin types 2026-02-26 10:18:40 +01:00
Stephan D
70b1c2a9cc +source currency pick fix +fx side propagation 2026-02-26 02:39:48 +01:00
Stephan D
008427483c - legacy payment template fee lines picking 2026-02-25 23:20:03 +01:00
Stephan D
7235ca1897 restricted CORS settings 2026-02-25 23:02:18 +01:00
Stephan D
53abb24482 +ledger ops 2026-02-25 22:17:12 +01:00
84cd23d5a1 Merge pull request 'fixed caddyfile' (#552) from SEND059 into main
All checks were successful
ci/woodpecker/push/billing_documents Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/discovery Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/gateway_mntx Pipeline was successful
ci/woodpecker/push/gateway_chain Pipeline was successful
ci/woodpecker/push/gateway_tgsettle Pipeline was successful
ci/woodpecker/push/gateway_tron Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_methods Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/payments_quotation Pipeline was successful
Reviewed-on: #552
2026-02-25 20:59:00 +00:00
Arseni
d05a5430f1 final fix 2026-02-25 23:58:02 +03:00
Arseni
70359916b9 fixed caddyfile 2026-02-25 23:49:18 +03:00
ebde599c38 Merge pull request 'woodpecker addition' (#551) from SEND058 into main
All checks were successful
ci/woodpecker/push/gateway_chain Pipeline was successful
ci/woodpecker/push/gateway_tron Pipeline was successful
ci/woodpecker/push/billing_documents Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/discovery Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/gateway_mntx Pipeline was successful
ci/woodpecker/push/gateway_tgsettle Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_methods Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/payments_quotation Pipeline was successful
Reviewed-on: #551
2026-02-25 19:11:18 +00:00
Stephan D
26bedc5743 +implementation of settlement step execution 2026-02-25 20:09:45 +01:00
Arseni
38b347f02e woodpecker addition 2026-02-25 22:09:23 +03:00
Stephan D
af4b68f4c7 Improved payment handling 2026-02-25 19:25:51 +01:00
6c98dc2c0c Merge pull request 'small woodpecker additioin for build to run' (#549) from SEND057 into main
Reviewed-on: #549
2026-02-25 11:36:36 +00:00
Arseni
dd61fe155f small woodpecker additioin for build to run 2026-02-25 12:20:22 +03:00
11c37d286c Merge pull request 'added api docs' (#548) from SEND056 into main
Reviewed-on: #548
Reviewed-by: tech <tech.sendico@proton.me>
2026-02-25 00:39:11 +00:00
Arseni
d70d9e84c9 small fixes 2026-02-25 01:01:28 +03:00
Stephan D
da11be526a removed legacy from bff 2026-02-24 21:18:23 +01:00
Arseni
fa54088b25 added api docs 2026-02-24 21:26:31 +03:00
Stephan D
a998b59072 complete MECE request 2026-02-24 18:33:12 +01:00
Stephan D
4c5677202a mece request / payment economics 2026-02-24 18:02:20 +01:00
Stephan D
2e08ec9b9b fee treatment added 2026-02-24 16:39:08 +01:00
Stephan D
2fe90347a8 quotation service fixed 2026-02-24 16:14:09 +01:00
Stephan D
6444813f38 payment quotation v2 + payment orchestration v2 draft 2026-02-24 13:01:35 +01:00
0646f55189 Merge pull request 'redesigned payment page + a lot of fix' (#546) from SEND055 into main
All checks were successful
ci/woodpecker/push/frontend Pipeline was successful
Reviewed-on: #546
Reviewed-by: tech <tech.sendico@proton.me>
2026-02-23 12:41:44 +00:00
Arseni
0c6fa03aba redesigned payment page + a lot of fixes 2026-02-21 21:55:20 +03:00
a68aa2abff Merge pull request 'set 10 min quotations timeout' (#545) from quote-544 into main
All checks were successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/payments_quotation Pipeline was successful
Reviewed-on: #545
2026-02-20 16:20:11 +00:00
Stephan D
51514159f5 set 10 min quotations timeout 2026-02-20 17:19:31 +01:00
199811ba09 Merge pull request 'cached gateway routing' (#543) from gw-542 into main
All checks were successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/billing_documents Pipeline was successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/discovery Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/gateway_chain Pipeline was successful
ci/woodpecker/push/gateway_mntx Pipeline was successful
ci/woodpecker/push/gateway_tgsettle Pipeline was successful
ci/woodpecker/push/gateway_tron Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_methods Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/payments_quotation Pipeline was successful
Reviewed-on: #543
2026-02-20 14:38:46 +00:00
Stephan D
671ccc55a0 cached gateway routing 2026-02-20 15:38:22 +01:00
bc2bc3770d Merge pull request 'wallets listing dedupe' (#541) from wallet-540 into main
All checks were successful
ci/woodpecker/push/billing_documents Pipeline was successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/gateway_chain Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/discovery Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/payments_methods Pipeline was successful
ci/woodpecker/push/gateway_mntx Pipeline was successful
ci/woodpecker/push/gateway_tgsettle Pipeline was successful
ci/woodpecker/push/gateway_tron Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/payments_quotation Pipeline was successful
Reviewed-on: #541
2026-02-20 12:52:45 +00:00
Stephan D
20cb057618 wallets listing dedupe 2026-02-20 13:52:09 +01:00
e23484ddff Merge pull request 'missing error handling' (#539) from tg-537 into main
All checks were successful
ci/woodpecker/push/gateway_tgsettle Pipeline was successful
Reviewed-on: #539
2026-02-19 20:35:27 +00:00
Stephan D
4344ae89e0 missing error handling 2026-02-19 21:35:04 +01:00
352a4a524b Merge pull request 'extended confirmation flow handling' (#538) from tg-537 into main
All checks were successful
ci/woodpecker/push/gateway_tgsettle Pipeline was successful
ci/woodpecker/push/payments_methods Pipeline was successful
ci/woodpecker/push/payments_quotation Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #538
2026-02-19 20:08:41 +00:00
Stephan D
2bf05ab414 extended confirmation flow handling 2026-02-19 21:08:24 +01:00
14eb99e221 Merge pull request 'migrated raw mongo.Collection to repository.Repository + chain driver resolution fix' (#536) from tg-535 into main
All checks were successful
ci/woodpecker/push/gateway_tgsettle Pipeline was successful
ci/woodpecker/push/gateway_chain Pipeline was successful
Reviewed-on: #536
2026-02-19 19:31:08 +00:00
Stephan D
afd8d8d01e migrated raw mongo.Collection to repository.Repository + chain driver resolution fix 2026-02-19 20:22:41 +01:00
b6cced6947 Merge pull request 'refactored notificatoin / tgsettle responsibility boundaries' (#534) from tg-530 into main
All checks were successful
ci/woodpecker/push/billing_documents Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/discovery Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/gateway_mntx Pipeline was successful
ci/woodpecker/push/gateway_chain Pipeline was successful
ci/woodpecker/push/gateway_tgsettle Pipeline was successful
ci/woodpecker/push/gateway_tron Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_methods Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/payments_quotation Pipeline was successful
Reviewed-on: #534
2026-02-19 17:57:47 +00:00
Stephan D
2fd8a6ebb7 refactored notificatoin / tgsettle responsibility boundaries 2026-02-19 18:56:59 +01:00
6d21f08e10 Merge pull request 'extended logging' (#532) from tg-530 into main
All checks were successful
ci/woodpecker/push/notification Pipeline was successful
Reviewed-on: #532
2026-02-19 17:06:35 +00:00
Stephan D
47f0a3d890 extended logging 2026-02-19 18:06:14 +01:00
9f3ba8f610 Merge pull request 'Logging on webhooks' (#531) from tg-530 into main
All checks were successful
ci/woodpecker/push/notification Pipeline was successful
Reviewed-on: #531
2026-02-19 16:56:41 +00:00
Stephan D
578a2cd1d7 Logging on webhooks 2026-02-19 17:56:17 +01:00
e399e13b56 [infra][rebuild] Merge pull request 'quotation v2 service' (#529) from quote-528 into main
All checks were successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/discovery Pipeline was successful
ci/woodpecker/push/billing_documents Pipeline was successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/gateway_mntx Pipeline was successful
ci/woodpecker/push/gateway_chain Pipeline was successful
ci/woodpecker/push/gateway_tgsettle Pipeline was successful
ci/woodpecker/push/gateway_tron Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_methods Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/payments_quotation Pipeline was successful
[infra][rebuild] Reviewed-on: #529
2026-02-18 21:07:44 +00:00
Stephan D
1c5d3d202b quotation v2 service 2026-02-18 22:06:51 +01:00
a33be56247 Merge pull request 'verification before payment and email fixes' (#526) from SEND054 into main
All checks were successful
ci/woodpecker/push/payments_methods Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/payments_quotation Pipeline was successful
Reviewed-on: #526
Reviewed-by: tech <tech.sendico@proton.me>
2026-02-18 19:40:58 +00:00
3a310caeb6 Merge pull request 'Fixes + stable gateway ids' (#527) from gate-525 into main
Some checks failed
ci/woodpecker/push/billing_fees Pipeline is pending
ci/woodpecker/push/discovery Pipeline is pending
ci/woodpecker/push/frontend Pipeline is pending
ci/woodpecker/push/fx_ingestor Pipeline is pending
ci/woodpecker/push/fx_oracle Pipeline is pending
ci/woodpecker/push/gateway_chain Pipeline is pending
ci/woodpecker/push/gateway_mntx Pipeline is pending
ci/woodpecker/push/gateway_tgsettle Pipeline is pending
ci/woodpecker/push/gateway_tron Pipeline is pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_methods Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/payments_quotation Pipeline is pending
ci/woodpecker/push/bff Pipeline failed
ci/woodpecker/push/billing_documents Pipeline failed
Reviewed-on: #527
2026-02-18 19:38:50 +00:00
Stephan D
770c7b9da9 Fixes + stable gateway ids 2026-02-18 20:38:08 +01:00
Arseni
e901ac3eb6 verification before payment and email fixes 2026-02-18 18:15:38 +03:00
4dc182bfa2 Merge pull request 'outbox for gateways' (#524) from outbox-523 into main
All checks were successful
ci/woodpecker/push/billing_documents Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/discovery Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/gateway_chain Pipeline was successful
ci/woodpecker/push/gateway_tgsettle Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/payments_methods Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/payments_quotation Pipeline was successful
ci/woodpecker/push/gateway_mntx Pipeline was successful
ci/woodpecker/push/gateway_tron Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
Reviewed-on: #524
2026-02-18 00:36:10 +00:00
Stephan D
69531cee73 outbox for gateways 2026-02-18 01:35:28 +01:00
Stephan D
974caf286c Fixed ORG ledger test
All checks were successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
2026-02-17 10:30:22 +01:00
854a6af2a6 Merge pull request 'email registration updated' (#519) from reg-516 into main
Some checks failed
ci/woodpecker/push/bff Pipeline failed
ci/woodpecker/push/frontend Pipeline was successful
Reviewed-on: #519
2026-02-17 09:21:44 +00:00
2707acedae Merge pull request 'reports page' (#518) from SEND053 into main
Some checks failed
ci/woodpecker/push/frontend Pipeline failed
Reviewed-on: #518
Reviewed-by: tech <tech.sendico@proton.me>
2026-02-17 09:21:38 +00:00
Stephan D
9bdb667b08 email registration updated 2026-02-17 10:20:39 +01:00
Arseni
e2e2257167 small fixes 2026-02-17 11:17:19 +03:00
Arseni
0eea39fb97 reports page 2026-02-16 21:05:38 +03:00
11d4b9a608 Merge pull request 'switched USDT / USD rate inversion off' (#515) from quote-513 into main
All checks were successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
Reviewed-on: #515
2026-02-16 12:04:18 +00:00
Stephan D
fe3f6ca79a switched USDT / USD rate inversion off 2026-02-16 13:03:54 +01:00
2070c35c61 Merge pull request 'switched USDT / USD rate inversion off' (#514) from quote-513 into main
Some checks failed
ci/woodpecker/push/fx_ingestor Pipeline failed
Reviewed-on: #514
2026-02-16 12:01:29 +00:00
Stephan D
138ab00474 switched USDT / USD rate inversion off 2026-02-16 13:00:51 +01:00
930e8d08cd Merge pull request '[rebuild] proto sync' (#512) from rb-508 into main
Some checks failed
ci/woodpecker/push/billing_documents Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/discovery 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/gateway_mntx Pipeline was successful
ci/woodpecker/push/gateway_chain Pipeline was successful
ci/woodpecker/push/gateway_tgsettle Pipeline was successful
ci/woodpecker/push/gateway_tron Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_methods Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/payments_quotation Pipeline was successful
Reviewed-on: #512
2026-02-13 16:59:30 +00:00
Stephan D
33ea643dc3 [rebuild] 2026-02-13 17:56:18 +01:00
4c51742ec6 Merge pull request 'payment services build deps fix' (#511) from po-510 into main
Reviewed-on: #511
2026-02-13 16:19:19 +00:00
Stephan D
7c76ca7f56 payment services build deps fix 2026-02-13 17:15:26 +01:00
e77baf1687 Merge branch 'dis-474' into main
All checks were successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
2026-02-13 16:05:04 +00:00
2c2512ead8 Merge pull request 'fixed error definition' (#508) from po-507 into main
Some checks failed
ci/woodpecker/push/payments_quotation Pipeline failed
Reviewed-on: #508
2026-02-13 16:01:45 +00:00
Stephan D
5dd1b5f4d7 fixed error definition 2026-02-13 17:01:16 +01:00
de48205e68 Merge pull request 'fixed error definition' (#506) from fx-505 into main
All checks were successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
Reviewed-on: #506
2026-02-13 15:47:38 +00:00
Stephan D
89ac299688 fixed error definition 2026-02-13 16:47:04 +01:00
1c564daa41 Merge pull request 'test fix' (#504) from test-503 into main
All checks were successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
Reviewed-on: #504
2026-02-13 15:12:26 +00:00
Stephan D
71296800ef test fix 2026-02-13 15:53:52 +01:00
ae873caa57 Merge pull request 'fixed settlement mode import' (#502) from quote-500 into main
Some checks failed
ci/woodpecker/push/bff Pipeline failed
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/payments_quotation Pipeline was successful
Reviewed-on: #502
2026-02-13 14:42:39 +00:00
Stephan D
01020bb694 fixed settlement mode import 2026-02-13 15:41:42 +01:00
e6232f9b1d Merge pull request 'neq quotation definition + priced_at field' (#501) from quote-500 into main
Some checks failed
ci/woodpecker/push/discovery Pipeline is pending
ci/woodpecker/push/frontend Pipeline is pending
ci/woodpecker/push/fx_ingestor Pipeline is pending
ci/woodpecker/push/fx_oracle Pipeline is pending
ci/woodpecker/push/gateway_chain Pipeline is pending
ci/woodpecker/push/gateway_mntx Pipeline is pending
ci/woodpecker/push/gateway_tgsettle Pipeline is pending
ci/woodpecker/push/gateway_tron Pipeline is pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_methods Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/payments_quotation Pipeline is pending
ci/woodpecker/push/bff Pipeline failed
ci/woodpecker/push/billing_fees Pipeline failed
ci/woodpecker/push/billing_documents Pipeline failed
Reviewed-on: #501
2026-02-13 14:35:52 +00:00
Stephan D
52c4c046c9 neq quotation definition + priced_at field 2026-02-13 15:35:17 +01:00
da1636014b Merge pull request 'autotests' (#499) from at-498 into main
Reviewed-on: #499
2026-02-13 11:14:58 +00:00
Stephan D
8704a968d2 autotests 2026-02-13 12:14:38 +01:00
08dccae175 Merge pull request 'certificates fix' (#497) from front-496 into main
All checks were successful
ci/woodpecker/push/frontend Pipeline was successful
Reviewed-on: #497
2026-02-13 08:49:41 +00:00
Stephan D
91dc2bd844 certificates fix 2026-02-13 09:49:22 +01:00
f0b14a8bbd Merge pull request 'fixed tests and compilation' (#495) from fx-494 into main
All checks were successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/payments_methods Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/payments_quotation Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #495
2026-02-13 01:33:51 +00:00
Stephan D
1b3aa0f9ea fixed tests and compilation 2026-02-13 02:33:22 +01:00
71a7a474c8 Merge pull request 'Moved cursor to a separate file' (#493) from cur-492 into main
Some checks failed
ci/woodpecker/push/billing_documents Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/discovery Pipeline was successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline failed
ci/woodpecker/push/gateway_chain Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline failed
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/gateway_mntx Pipeline was successful
ci/woodpecker/push/gateway_tgsettle Pipeline was successful
ci/woodpecker/push/gateway_tron Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/payments_methods Pipeline failed
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/payments_quotation Pipeline was successful
Reviewed-on: #493
2026-02-12 23:27:12 +00:00
Stephan D
16c44ec7d3 Moved cursor to a separate file 2026-02-13 00:23:39 +01:00
fd1f5498b4 Merge pull request 'fix for resend, cooldown and a few small fixes' (#491) from SEND052 into main
All checks were successful
ci/woodpecker/push/frontend Pipeline was successful
Reviewed-on: #491
Reviewed-by: tech <tech.sendico@proton.me>
2026-02-12 23:13:01 +00:00
Arseni
b5db65ef78 fix for resend, cooldown and a few small fixes 2026-02-13 01:03:47 +03:00
44a22ce962 Merge pull request 'new payment methods service' (#490) from pm-489 into main
Some checks failed
ci/woodpecker/push/billing_fees Pipeline is pending
ci/woodpecker/push/discovery Pipeline is pending
ci/woodpecker/push/frontend Pipeline is pending
ci/woodpecker/push/fx_ingestor Pipeline is pending
ci/woodpecker/push/fx_oracle Pipeline is pending
ci/woodpecker/push/gateway_chain Pipeline is pending
ci/woodpecker/push/gateway_mntx Pipeline is pending
ci/woodpecker/push/gateway_tgsettle Pipeline is pending
ci/woodpecker/push/gateway_tron Pipeline is pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_methods Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/payments_quotation Pipeline is pending
ci/woodpecker/push/billing_documents Pipeline failed
ci/woodpecker/push/bff Pipeline failed
Reviewed-on: #490
2026-02-12 20:11:56 +00:00
Stephan D
a862e27087 new payment methods service 2026-02-12 21:10:33 +01:00
b80dca0ce9 Merge pull request 'fixed verificaiton error' (#488) from ver-487 into main
Some checks failed
ci/woodpecker/push/gateway_chain Pipeline is pending
ci/woodpecker/push/gateway_mntx Pipeline is pending
ci/woodpecker/push/gateway_tgsettle Pipeline is pending
ci/woodpecker/push/gateway_tron Pipeline is pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/payments_quotation Pipeline is pending
ci/woodpecker/push/billing_documents Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/discovery Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline failed
ci/woodpecker/push/frontend Pipeline failed
Reviewed-on: #488
2026-02-12 19:46:35 +00:00
Stephan D
e605c734ad fixed verificaiton error 2026-02-12 20:46:11 +01:00
55624aa8b5 Merge pull request 'fixed verificatoin' (#487) from q-477 into main
Some checks failed
ci/woodpecker/push/fx_oracle Pipeline is pending
ci/woodpecker/push/gateway_chain Pipeline is pending
ci/woodpecker/push/gateway_mntx Pipeline is pending
ci/woodpecker/push/gateway_tgsettle Pipeline is pending
ci/woodpecker/push/gateway_tron Pipeline is pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/payments_quotation Pipeline is pending
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/discovery Pipeline was successful
ci/woodpecker/push/billing_documents Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline failed
ci/woodpecker/push/frontend Pipeline failed
Reviewed-on: #487
2026-02-12 19:27:04 +00:00
Stephan D
fcbfa323c8 fixed verificatoin 2026-02-12 20:26:10 +01:00
33c83b6768 Merge pull request 'redisign multiple payouts for better ux and small fixes' (#485) from SEND051 into main
All checks were successful
ci/woodpecker/push/frontend Pipeline was successful
Reviewed-on: #485
Reviewed-by: tech <tech.sendico@proton.me>
2026-02-12 17:03:53 +00:00
Arseni
45d3c3145c redisign multiple payouts for better ux and small fixes 2026-02-12 18:48:57 +03:00
ea68d161d6 Merge pull request 'localization fix for amount' (#484) from SEND050 into main
All checks were successful
ci/woodpecker/push/payments_quotation Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
Reviewed-on: #484
2026-02-12 13:41:40 +00:00
6a0289963b Merge branch 'main' into SEND050 2026-02-12 13:41:13 +00:00
Arseni
853b855049 localization fix for amount 2026-02-12 16:06:01 +03:00
e767436f33 Merge pull request 'q' (#483) from q-477 into main
All checks were successful
ci/woodpecker/push/payments_quotation Pipeline was successful
Reviewed-on: #483
2026-02-12 13:05:39 +00:00
Stephan D
57914c0754 q 2026-02-12 14:05:24 +01:00
b894f92e50 Merge pull request 'q' (#482) from q-477 into main
Reviewed-on: #482
2026-02-12 13:04:56 +00:00
Stephan D
76548032b3 q 2026-02-12 14:04:38 +01:00
d121f4172e Merge pull request 'q' (#481) from q-477 into main
All checks were successful
ci/woodpecker/push/payments_quotation Pipeline was successful
Reviewed-on: #481
2026-02-12 12:53:06 +00:00
Stephan D
e717cabd0f q 2026-02-12 13:52:53 +01:00
8fead967d1 Merge pull request 'q NATS' (#480) from q-477 into main
Reviewed-on: #480
2026-02-12 12:52:04 +00:00
Stephan D
a09fd550ba q NATS 2026-02-12 13:51:49 +01:00
8eae9270f2 Merge pull request 'q NATS' (#479) from q-477 into main
Reviewed-on: #479
2026-02-12 12:51:20 +00:00
Stephan D
dc6a4224a0 q NATS 2026-02-12 13:50:58 +01:00
a99a3c851c Merge pull request 'q' (#478) from q-477 into main
All checks were successful
ci/woodpecker/push/payments_quotation Pipeline was successful
Reviewed-on: #478
2026-02-12 12:44:26 +00:00
Stephan D
9bca523caa q 2026-02-12 13:44:10 +01:00
Stephan D
e36fb88a9a linting 2026-02-12 13:39:25 +01:00
27de7a9655 Merge pull request 'fixed linting config' (#476) from dis-474 into main
Some checks failed
ci/woodpecker/push/billing_fees Pipeline is pending
ci/woodpecker/push/discovery Pipeline is pending
ci/woodpecker/push/frontend Pipeline is pending
ci/woodpecker/push/fx_ingestor Pipeline is pending
ci/woodpecker/push/fx_oracle Pipeline is pending
ci/woodpecker/push/gateway_chain Pipeline is pending
ci/woodpecker/push/gateway_mntx Pipeline is pending
ci/woodpecker/push/gateway_tgsettle Pipeline is pending
ci/woodpecker/push/gateway_tron Pipeline is pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/payments_quotation Pipeline is pending
ci/woodpecker/push/billing_documents Pipeline failed
ci/woodpecker/push/bff Pipeline failed
Reviewed-on: #476
2026-02-12 12:35:02 +00:00
Stephan D
7cbcbb4b3c fixed linting config 2026-02-12 13:00:37 +01:00
fee10afbb8 Merge pull request 'linting' (#475) from dis-474 into main
All checks were successful
ci/woodpecker/push/discovery Pipeline was successful
Reviewed-on: #475
2026-02-12 11:48:36 +00:00
Stephan D
97395acd8f linting 2026-02-12 12:47:39 +01:00
f4b43f7218 Merge pull request 'fixed bff compilation' (#473) from po-473 into main
All checks were successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
Reviewed-on: #473
2026-02-11 17:53:49 +00:00
Stephan D
3c0d709a82 fixed bff compilation 2026-02-11 18:53:27 +01:00
b787cc4d9e Merge pull request 'fix for proto migration' (#472) from po-470 into main
Some checks failed
ci/woodpecker/push/bff Pipeline failed
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/payments_quotation Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
Reviewed-on: #472
2026-02-11 17:16:06 +00:00
Stephan D
7b53ca6cef fix for proto migration 2026-02-11 18:15:04 +01:00
fab07fdc8e Merge pull request 'Fully separated payment quotation and orchestration flows' (#470) from po-469 into main
Some checks failed
ci/woodpecker/push/discovery Pipeline is pending
ci/woodpecker/push/frontend Pipeline is pending
ci/woodpecker/push/fx_ingestor Pipeline is pending
ci/woodpecker/push/fx_oracle Pipeline is pending
ci/woodpecker/push/gateway_chain Pipeline is pending
ci/woodpecker/push/gateway_mntx Pipeline is pending
ci/woodpecker/push/gateway_tgsettle Pipeline is pending
ci/woodpecker/push/gateway_tron Pipeline is pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/payments_quotation Pipeline is pending
ci/woodpecker/push/billing_fees Pipeline failed
ci/woodpecker/push/bff Pipeline failed
ci/woodpecker/push/billing_documents Pipeline failed
Reviewed-on: #470
2026-02-11 16:40:20 +00:00
Stephan D
e116535926 Fully separated payment quotation and orchestration flows 2026-02-11 17:25:44 +01:00
9b8f59e05a Merge pull request 'small fixed for build to run' (#468) from SEND048 into main
All checks were successful
ci/woodpecker/push/frontend Pipeline was successful
Reviewed-on: #468
2026-02-11 16:22:41 +00:00
Arseni
6844d1e587 small fixed for build to run 2026-02-11 19:14:37 +03:00
3225babeca [infra] Merge pull request 'multiple payout page and small fixes' (#464) from SEND047 into main
Some checks failed
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/frontend Pipeline failed
[infra]
Reviewed-on: #464
Reviewed-by: tech <tech.sendico@proton.me>
2026-02-11 15:29:28 +00:00
Arseni
0a1e04d0d6 small fixes 2026-02-11 15:26:11 +03:00
648ace709a Merge pull request 'added proto generation commands' (#467) from rm-464 into main
Reviewed-on: #467
2026-02-11 09:40:29 +00:00
Stephan D
274035ed6d added proto generation commands 2026-02-11 10:40:12 +01:00
0aab5cceb0 Merge pull request 'updated badge' (#466) from rm-464 into main
Reviewed-on: #466
2026-02-11 09:33:58 +00:00
Stephan D
9b97ebfa6c updated badge 2026-02-11 10:33:39 +01:00
b02df379a7 Merge pull request 'Readme' (#465) from rm-464 into main
Reviewed-on: #465
2026-02-11 09:31:42 +00:00
Stephan D
f5052f6cf8 Readme 2026-02-11 10:31:17 +01:00
Arseni
edb43f9909 multiple payout page and small fixes 2026-02-11 02:48:30 +03:00
7524852533 Merge pull request 'account state changes' (#463) from alert-444 into main
All checks were successful
ci/woodpecker/push/billing_documents Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/gateway_mntx Pipeline was successful
ci/woodpecker/push/gateway_tgsettle Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/discovery Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/gateway_chain Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/gateway_tron Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/payments_quotation Pipeline was successful
Reviewed-on: #463
2026-02-10 19:40:39 +00:00
Stephan D
cfb219e206 account state changes 2026-02-10 20:40:23 +01:00
66989ea36c Merge pull request 'separated quotation and payments' (#462) from pq-462 into main
All checks were successful
ci/woodpecker/push/billing_documents Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/discovery Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/gateway_chain Pipeline was successful
ci/woodpecker/push/gateway_mntx Pipeline was successful
ci/woodpecker/push/gateway_tgsettle Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/gateway_tron Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/payments_quotation Pipeline was successful
Reviewed-on: #462
2026-02-10 17:31:21 +00:00
Stephan D
296cc7b86a separated quotation and payments 2026-02-10 18:29:47 +01:00
6745bc0f6f Merge pull request 'sign up page scroll fix' (#461) from SEND046 into main
All checks were successful
ci/woodpecker/push/frontend Pipeline was successful
Reviewed-on: #461
Reviewed-by: tech <tech.sendico@proton.me>
2026-02-10 15:05:26 +00:00
Arseni
c962ac7cbd sign up page scroll fix 2026-02-10 17:41:52 +03:00
817d4357cf Merge pull request 'fixed code duplication' (#459) from mntx-452 into main
All checks were successful
ci/woodpecker/push/gateway_mntx Pipeline was successful
Reviewed-on: #459
2026-02-10 13:40:16 +00:00
Stephan D
2711d601b0 fixed code duplication 2026-02-10 14:39:30 +01:00
74b0976cb7 Merge pull request 'improved storing' (#457) from mntx-452 into main
All checks were successful
ci/woodpecker/push/gateway_mntx Pipeline was successful
Reviewed-on: #457
2026-02-10 13:20:17 +00:00
Stephan D
3c82afbd43 improved storing 2026-02-10 14:19:44 +01:00
32688a2de7 Merge pull request 'improved storing' (#456) from mntx-452 into main
All checks were successful
ci/woodpecker/push/gateway_mntx Pipeline was successful
Reviewed-on: #456
2026-02-10 12:37:09 +00:00
Stephan D
f2938daddd improved storing 2026-02-10 13:36:45 +01:00
7c26698f0d Merge pull request 'extended logging' (#454) from mntx-452 into main
All checks were successful
ci/woodpecker/push/billing_documents Pipeline was successful
ci/woodpecker/push/discovery Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/gateway_chain Pipeline was successful
ci/woodpecker/push/gateway_mntx Pipeline was successful
ci/woodpecker/push/gateway_tgsettle Pipeline was successful
ci/woodpecker/push/gateway_tron Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #454
2026-02-10 10:34:18 +00:00
Stephan D
461a340b08 extended logging 2026-02-10 11:33:47 +01:00
dadf9e2485 Merge pull request 'extended logging' (#453) from mntx-452 into main
All checks were successful
ci/woodpecker/push/gateway_mntx Pipeline was successful
Reviewed-on: #453
2026-02-10 10:25:09 +00:00
Stephan D
f59789ab7a extended logging 2026-02-10 11:24:45 +01:00
5e92d7b9ff Merge pull request 'extended logging' (#452) from mntx-452 into main
All checks were successful
ci/woodpecker/push/gateway_mntx Pipeline was successful
Reviewed-on: #452
2026-02-10 10:14:24 +00:00
Stephan D
3578ddd54f extended logging 2026-02-10 11:14:02 +01:00
9463c7ce1f Merge pull request 'otp-450' (#451) from otp-450 into main
All checks were successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/discovery Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/billing_documents Pipeline was successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/gateway_chain Pipeline was successful
ci/woodpecker/push/gateway_tron Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/gateway_mntx Pipeline was successful
ci/woodpecker/push/gateway_tgsettle Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
Reviewed-on: #451
2026-02-10 01:12:05 +00:00
Stephan D
7c182afd23 fixed token errors 2026-02-10 02:11:22 +01:00
Stephan D
7f540671c1 unified code verification service 2026-02-10 01:55:33 +01:00
Stephan D
76c3bfdea9 fixed verification code
All checks were successful
ci/woodpecker/push/billing_documents Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/discovery Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/gateway_chain Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/gateway_mntx Pipeline was successful
ci/woodpecker/push/gateway_tgsettle Pipeline was successful
ci/woodpecker/push/gateway_tron Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
2026-02-09 16:43:25 +01:00
Stephan D
eda6b75f74 fixed verification code 2026-02-09 16:40:52 +01:00
5caf46ffe1 Merge pull request 'Added debit settlement amount calculation' (#445) from settlement-443 into main
Some checks failed
ci/woodpecker/push/billing_documents Pipeline was successful
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/discovery Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/gateway_chain Pipeline was successful
ci/woodpecker/push/gateway_mntx Pipeline was successful
ci/woodpecker/push/gateway_tgsettle Pipeline was successful
ci/woodpecker/push/gateway_tron Pipeline was successful
ci/woodpecker/push/nats Pipeline failed
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/notification Pipeline failed
ci/woodpecker/push/payments_orchestrator Pipeline was successful
[infra] Reviewed-on: #445
2026-02-06 16:50:53 +00:00
Stephan D
c8b8b1183b Added debit settlement amount calculation 2026-02-06 17:50:11 +01:00
17bc2a2a62 Merge pull request 'fixed payment intent request' (#442) from fx-441 into main
All checks were successful
ci/woodpecker/push/frontend Pipeline was successful
Reviewed-on: #442
2026-02-05 21:39:39 +00:00
Stephan D
706861af5f fixed payment intent request 2026-02-05 22:39:20 +01:00
Stephan D
f8a3bef2e6 implemented verifiaction db 2026-02-05 20:51:03 +01:00
4639b2c610 Merge pull request 'small fixes for single payout and big chunck for multiple payouts' (#439) from SEND045 into main
All checks were successful
ci/woodpecker/push/frontend Pipeline was successful
Reviewed-on: #439
Reviewed-by: tech <tech.sendico@proton.me>
2026-02-05 19:08:02 +00:00
Arseni
b9748b8ab2 small fixes for single payout and big chunck for multiple payouts 2026-02-05 21:58:37 +03:00
8034847e46 Merge pull request 'extended fields' (#438) from bff-437 into main
All checks were successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/gateway_tron Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
Reviewed-on: #438
2026-02-05 18:04:05 +00:00
Stephan D
49b0b63f55 extended fields 2026-02-05 19:03:35 +01:00
5984f2c2f7 Merge pull request 'fixed upsert' (#435) from mntx-433 into main
All checks were successful
ci/woodpecker/push/gateway_mntx Pipeline was successful
Reviewed-on: #435
2026-02-05 15:35:10 +00:00
Stephan D
bc50391fe7 fixed upsert 2026-02-05 16:34:34 +01:00
67598449e6 Merge pull request 'fixed db operations' (#434) from mntx-433 into main
Some checks failed
ci/woodpecker/push/gateway_mntx Pipeline was successful
ci/woodpecker/push/gateway_tron Pipeline failed
Reviewed-on: #434
2026-02-05 15:28:22 +00:00
Stephan D
761dda9377 fixed db operations 2026-02-05 16:27:43 +01:00
42da0260b0 Merge pull request 'fixed status orchestration' (#432) from po-431 into main
All checks were successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/gateway_tron Pipeline was successful
Reviewed-on: #432
2026-02-05 14:34:17 +00:00
Stephan D
5df02baa80 fixed status orchestration 2026-02-05 15:33:41 +01:00
7417b33de3 Merge pull request 'fixed transactions' (#430) from tron-430 into main
All checks were successful
ci/woodpecker/push/gateway_tron Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
Reviewed-on: #430
2026-02-05 13:37:00 +00:00
Stephan D
217542ec14 fixed transactions 2026-02-05 14:36:38 +01:00
e394770eb1 Merge pull request 'SEND044' (#429) from SEND044 into main
All checks were successful
ci/woodpecker/push/frontend Pipeline was successful
Reviewed-on: #429
2026-02-05 12:53:54 +00:00
Arseni
611bde214f removed podfile 2026-02-05 15:38:48 +03:00
Arseni
d3e69bcd62 removed payment methods page for now 2026-02-05 15:36:43 +03:00
fb9def8c19 Merge pull request 'fixed balance fetch' (#428) from tron-428 into main
All checks were successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/gateway_tron Pipeline was successful
Reviewed-on: #428
2026-02-05 11:39:24 +00:00
Stephan D
6e3115e7fa fixed balance fetch 2026-02-05 12:39:06 +01:00
0675978bd1 Merge pull request 'fixed tron address conversion' (#427) from tron-425 into main
All checks were successful
ci/woodpecker/push/gateway_tron Pipeline was successful
Reviewed-on: #427
2026-02-05 11:24:09 +00:00
Stephan D
04391cbd8d fixed tron address conversion 2026-02-05 12:23:39 +01:00
87f320802d Merge pull request 'TRON driver update' (#426) from tron-425 into main
All checks were successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/gateway_tron Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
Reviewed-on: #426
2026-02-05 10:48:04 +00:00
Stephan D
3a4f1c7e3f TRON driver update 2026-02-05 11:47:41 +01:00
542d88750d Merge pull request 'name ui fix and removed parts of the app that are not ready' (#422) from SEND043 into main
All checks were successful
ci/woodpecker/push/frontend Pipeline was successful
Reviewed-on: #422
2026-02-05 10:05:24 +00:00
Arseni
b27eed31b7 removed status from recipient address book 2026-02-05 13:04:00 +03:00
Arseni
69ed9f25cb deleted single payout that we will not use 2026-02-05 12:02:48 +03:00
Arseni
e4fb270390 fix for signup router 2026-02-05 12:00:05 +03:00
Arseni
81ffdd4291 sneaky email verification fix 2026-02-05 02:54:13 +03:00
Arseni
0ce90eef21 name ui fix and removed parts of the app that are not ready 2026-02-05 02:42:00 +03:00
509af9bc5c Merge pull request 'fixed payments access requireing organization reference' (#419) from po-418 into main
Some checks failed
ci/woodpecker/push/billing_documents Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/discovery Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/gateway_chain Pipeline failed
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/gateway_mntx Pipeline was successful
ci/woodpecker/push/gateway_tgsettle Pipeline was successful
ci/woodpecker/push/gateway_tron Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
Reviewed-on: #419
2026-02-04 20:38:45 +00:00
Stephan D
bc0fd6ab2f fixed payments access requireing organization reference 2026-02-04 21:38:19 +01:00
ca0e45ee5f Merge pull request 'clear instuctions for password in signup' (#413) from SEND042 into main
All checks were successful
ci/woodpecker/push/frontend Pipeline was successful
Reviewed-on: #413
2026-02-04 17:31:24 +00:00
3cf6b10ea7 Merge pull request 'fixed gs config' (#415) from bff-414 into main
All checks were successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
Reviewed-on: #415
2026-02-04 13:13:24 +00:00
Stephan D
1bdbbf65a4 fixed gs config 2026-02-04 14:13:02 +01:00
Arseni
fe9133c206 clear instuctions for password in signup 2026-02-04 16:07:40 +03:00
b722d61c4f Merge pull request 'Fixed default gateway for bff' (#412) from bff-411 into main
Some checks failed
ci/woodpecker/push/gateway_mntx Pipeline is pending
ci/woodpecker/push/gateway_tgsettle Pipeline is pending
ci/woodpecker/push/gateway_tron Pipeline is pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/billing_documents Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/discovery Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/gateway_chain Pipeline failed
ci/woodpecker/push/frontend Pipeline failed
Reviewed-on: #412
2026-02-04 12:44:36 +00:00
Stephan D
f56a3d4611 Fixed default gateway for bff 2026-02-04 13:44:20 +01:00
afa842ba65 Merge pull request 'fixed tg message' (#410) from tg-409 into main
All checks were successful
ci/woodpecker/push/gateway_tgsettle Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #410
2026-02-04 12:31:30 +00:00
Stephan D
ccc9737d1b fixed tg message 2026-02-04 13:31:10 +01:00
a569757b7f Merge pull request 'fixed message' (#406) from mntx-404 into main
All checks were successful
ci/woodpecker/push/frontend Pipeline was successful
Reviewed-on: #406
2026-02-04 11:13:19 +00:00
Stephan D
43b252859e fixed message 2026-02-04 12:13:04 +01:00
e1c439fb85 Merge pull request 'fixed mntx op provisioning' (#405) from tg-403 into main
All checks were successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/gateway_mntx Pipeline was successful
Reviewed-on: #405
2026-02-04 10:59:36 +00:00
Stephan D
571cea3b37 fixed mntx op provisioning 2026-02-04 11:58:30 +01:00
6198ceb9a4 Merge pull request 'tgsettle status optimization, + build optimization' (#404) from tg-403 into main
All checks were successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/gateway_tgsettle Pipeline was successful
Reviewed-on: #404
2026-02-04 09:55:08 +00:00
Stephan D
f02f28d53f tgsettle status optimization, + build optimization 2026-02-04 10:54:45 +01:00
1384 changed files with 79407 additions and 23146 deletions

View File

@@ -9,6 +9,17 @@ matrix:
when:
- event: push
branch: main
path:
include:
- api/server/**
- api/payments/methods/client/**
- api/payments/methods/go.mod
- api/payments/methods/go.sum
- api/proto/**
- api/pkg/**
- ci/prod/**
- .woodpecker/bff.yml
ignore_message: '[rebuild]'
steps:
- name: version
@@ -35,6 +46,14 @@ steps:
- export PATH="$(go env GOPATH)/bin:$PATH"
- bash ci/scripts/proto/generate.sh
- name: backend-tests
image: golang:alpine
depends_on: [ proto ]
commands:
- set -eu
- apk add --no-cache bash git build-base
- sh ci/scripts/common/run_backend_tests.sh bff
- name: secrets
image: alpine:latest
depends_on: [ version ]
@@ -55,7 +74,7 @@ steps:
- name: build-image
image: gcr.io/kaniko-project/executor:debug
depends_on: [ proto, secrets ]
depends_on: [ backend-tests, secrets ]
commands:
- sh ci/scripts/bff/build-image.sh

View File

@@ -8,6 +8,14 @@ matrix:
when:
- event: push
branch: main
path:
include:
- api/billing/documents/**
- api/proto/**
- api/pkg/**
- ci/prod/**
- .woodpecker/billing_documents.yml
ignore_message: '[rebuild]'
steps:
- name: version
@@ -34,6 +42,14 @@ steps:
- export PATH="$(go env GOPATH)/bin:$PATH"
- bash ci/scripts/proto/generate.sh
- name: backend-tests
image: golang:alpine
depends_on: [ proto ]
commands:
- set -eu
- apk add --no-cache bash git build-base
- sh ci/scripts/common/run_backend_tests.sh billing_documents
- name: secrets
image: alpine:latest
depends_on: [ version ]
@@ -54,7 +70,7 @@ steps:
- name: build-image
image: gcr.io/kaniko-project/executor:debug
depends_on: [ proto, secrets ]
depends_on: [ backend-tests, secrets ]
commands:
- sh ci/scripts/billing_documents/build-image.sh

View File

@@ -8,6 +8,14 @@ matrix:
when:
- event: push
branch: main
path:
include:
- api/billing/fees/**
- api/proto/**
- api/pkg/**
- ci/prod/**
- .woodpecker/billing_fees.yml
ignore_message: '[rebuild]'
steps:
- name: version
@@ -34,6 +42,14 @@ steps:
- export PATH="$(go env GOPATH)/bin:$PATH"
- bash ci/scripts/proto/generate.sh
- name: backend-tests
image: golang:alpine
depends_on: [ proto ]
commands:
- set -eu
- apk add --no-cache bash git build-base
- sh ci/scripts/common/run_backend_tests.sh billing_fees
- name: secrets
image: alpine:latest
depends_on: [ version ]
@@ -54,7 +70,7 @@ steps:
- name: build-image
image: gcr.io/kaniko-project/executor:debug
depends_on: [ proto, secrets ]
depends_on: [ backend-tests, secrets ]
commands:
- sh ci/scripts/billing_fees/build-image.sh

View File

@@ -1,6 +1,9 @@
when:
- event: push
branch: main
path:
exclude: ['**']
ignore_message: '[infra]'
steps:
- name: version

View File

@@ -7,6 +7,14 @@ matrix:
when:
- event: push
branch: main
path:
include:
- api/discovery/**
- api/proto/**
- api/pkg/**
- ci/prod/**
- .woodpecker/discovery.yml
ignore_message: '[rebuild]'
steps:
- name: version
@@ -33,6 +41,14 @@ steps:
- export PATH="$(go env GOPATH)/bin:$PATH"
- bash ci/scripts/proto/generate.sh
- name: backend-tests
image: golang:alpine
depends_on: [ proto ]
commands:
- set -eu
- apk add --no-cache bash git build-base
- sh ci/scripts/common/run_backend_tests.sh discovery
- name: secrets
image: alpine:latest
depends_on: [ version ]
@@ -53,7 +69,7 @@ steps:
- name: build-image
image: gcr.io/kaniko-project/executor:debug
depends_on: [ proto, secrets ]
depends_on: [ backend-tests, secrets ]
commands:
- sh ci/scripts/discovery/build-image.sh

View File

@@ -7,6 +7,16 @@ matrix:
when:
- event: push
branch: main
path:
include:
- api/server/**
- api/pkg/**
- api/proto/**
- frontend/**
- interface/**
- ci/prod/**
- .woodpecker/frontend.yml
ignore_message: '[rebuild]'
steps:
- name: version

View File

@@ -11,6 +11,15 @@ matrix:
when:
- event: push
branch: main
path:
include:
- api/fx/ingestor/**
- api/fx/storage/**
- api/proto/**
- api/pkg/**
- ci/prod/**
- .woodpecker/fx_ingestor.yml
ignore_message: '[rebuild]'
steps:
- name: version
@@ -38,6 +47,14 @@ steps:
- export PATH="$(go env GOPATH)/bin:$PATH"
- bash ci/scripts/proto/generate.sh
- name: backend-tests
image: golang:alpine
depends_on: [ proto ]
commands:
- set -eu
- apk add --no-cache bash git build-base
- sh ci/scripts/common/run_backend_tests.sh fx_ingestor
- name: secrets
image: alpine:latest
depends_on: [ version ]
@@ -58,7 +75,7 @@ steps:
- name: build-image
image: gcr.io/kaniko-project/executor:debug
depends_on: [ proto, secrets ]
depends_on: [ backend-tests, secrets ]
commands:
- sh ci/scripts/fx/build-image.sh

View File

@@ -11,6 +11,16 @@ matrix:
when:
- event: push
branch: main
path:
include:
- api/fx/oracle/**
- api/fx/storage/**
- api/proto/**
- api/pkg/**
- ci/prod/**
- .woodpecker/fx_oracle.yml
ignore_message: '[rebuild]'
steps:
- name: version
@@ -38,6 +48,14 @@ steps:
- export PATH="$(go env GOPATH)/bin:$PATH"
- bash ci/scripts/proto/generate.sh
- name: backend-tests
image: golang:alpine
depends_on: [ proto ]
commands:
- set -eu
- apk add --no-cache bash git build-base
- sh ci/scripts/common/run_backend_tests.sh fx_oracle
- name: secrets
image: alpine:latest
depends_on: [ version ]
@@ -58,7 +76,7 @@ steps:
- name: build-image
image: gcr.io/kaniko-project/executor:debug
depends_on: [ proto, secrets ]
depends_on: [ backend-tests, secrets ]
commands:
- sh ci/scripts/fx/build-image.sh

View File

@@ -11,6 +11,15 @@ matrix:
when:
- event: push
branch: main
path:
include:
- api/gateway/chain/**
- api/gateway/common/**
- api/proto/**
- api/pkg/**
- ci/prod/**
- .woodpecker/gateway_chain.yml
ignore_message: '[rebuild]'
steps:
- name: version
@@ -37,6 +46,14 @@ steps:
- export PATH="$(go env GOPATH)/bin:$PATH"
- bash ci/scripts/proto/generate.sh
- name: backend-tests
image: golang:alpine
depends_on: [ proto ]
commands:
- set -eu
- apk add --no-cache bash git build-base
- sh ci/scripts/common/run_backend_tests.sh gateway_chain
- name: secrets
image: alpine:latest
depends_on: [ version ]
@@ -57,7 +74,7 @@ steps:
- name: build-image
image: gcr.io/kaniko-project/executor:debug
depends_on: [ proto, secrets ]
depends_on: [ backend-tests, secrets ]
commands:
- sh ci/scripts/chain_gateway/build-image.sh

View File

@@ -10,6 +10,15 @@ matrix:
when:
- event: push
branch: main
path:
include:
- api/gateway/mntx/**
- api/gateway/common/**
- api/proto/**
- api/pkg/**
- ci/prod/**
- .woodpecker/gateway_mntx.yml
ignore_message: '[rebuild]'
steps:
- name: version
@@ -36,6 +45,14 @@ steps:
- export PATH="$(go env GOPATH)/bin:$PATH"
- bash ci/scripts/proto/generate.sh
- name: backend-tests
image: golang:alpine
depends_on: [ proto ]
commands:
- set -eu
- apk add --no-cache bash git build-base
- sh ci/scripts/common/run_backend_tests.sh gateway_mntx
- name: secrets
image: alpine:latest
depends_on: [ version ]
@@ -56,7 +73,7 @@ steps:
- name: build-image
image: gcr.io/kaniko-project/executor:debug
depends_on: [ proto, secrets ]
depends_on: [ backend-tests, secrets ]
commands:
- sh ci/scripts/mntx/build-image.sh

View File

@@ -8,6 +8,15 @@ matrix:
when:
- event: push
branch: main
path:
include:
- api/gateway/tgsettle/**
- api/gateway/common/**
- api/proto/**
- api/pkg/**
- ci/prod/**
- .woodpecker/gateway_tgsettle.yml
ignore_message: '[rebuild]'
steps:
- name: version
@@ -34,6 +43,14 @@ steps:
- export PATH="$(go env GOPATH)/bin:$PATH"
- bash ci/scripts/proto/generate.sh
- name: backend-tests
image: golang:alpine
depends_on: [ proto ]
commands:
- set -eu
- apk add --no-cache bash git build-base
- sh ci/scripts/common/run_backend_tests.sh gateway_tgsettle
- name: secrets
image: alpine:latest
depends_on: [ version ]
@@ -54,7 +71,7 @@ steps:
- name: build-image
image: gcr.io/kaniko-project/executor:debug
depends_on: [ proto, secrets ]
depends_on: [ backend-tests, secrets ]
commands:
- sh ci/scripts/tgsettle/build-image.sh

View File

@@ -11,6 +11,15 @@ matrix:
when:
- event: push
branch: main
path:
include:
- api/gateway/tron/**
- api/gateway/common/**
- api/proto/**
- api/pkg/**
- ci/prod/**
- .woodpecker/gateway_tron.yml
ignore_message: '[rebuild]'
steps:
- name: version
@@ -37,6 +46,14 @@ steps:
- export PATH="$(go env GOPATH)/bin:$PATH"
- bash ci/scripts/proto/generate.sh
- name: backend-tests
image: golang:alpine
depends_on: [ proto ]
commands:
- set -eu
- apk add --no-cache bash git build-base
- sh ci/scripts/common/run_backend_tests.sh gateway_tron
- name: secrets
image: alpine:latest
depends_on: [ version ]
@@ -57,7 +74,7 @@ steps:
- name: build-image
image: gcr.io/kaniko-project/executor:debug
depends_on: [ proto, secrets ]
depends_on: [ backend-tests, secrets ]
commands:
- sh ci/scripts/tron_gateway/build-image.sh

View File

@@ -8,6 +8,14 @@ matrix:
when:
- event: push
branch: main
path:
include:
- api/ledger/**
- api/proto/**
- api/pkg/**
- ci/prod/**
- .woodpecker/ledger.yml
ignore_message: '[rebuild]'
steps:
- name: version
@@ -34,6 +42,14 @@ steps:
- export PATH="$(go env GOPATH)/bin:$PATH"
- bash ci/scripts/proto/generate.sh
- name: backend-tests
image: golang:alpine
depends_on: [ proto ]
commands:
- set -eu
- apk add --no-cache bash git build-base
- sh ci/scripts/common/run_backend_tests.sh ledger
- name: secrets
image: alpine:latest
depends_on: [ version ]
@@ -54,7 +70,7 @@ steps:
- name: build-image
image: gcr.io/kaniko-project/executor:debug
depends_on: [ proto, secrets ]
depends_on: [ backend-tests, secrets ]
commands:
- sh ci/scripts/ledger/build-image.sh

View File

@@ -1,6 +1,10 @@
when:
- event: push
branch: main
path:
exclude: ['**']
ignore_message: '[infra]'
steps:
- name: version

View File

@@ -11,6 +11,14 @@ matrix:
when:
- event: push
branch: main
path:
include:
- api/notification/**
- api/proto/**
- api/pkg/**
- ci/prod/**
- .woodpecker/notification.yml
ignore_message: '[rebuild]'
steps:
- name: version
@@ -37,6 +45,14 @@ steps:
- export PATH="$(go env GOPATH)/bin:$PATH"
- bash ci/scripts/proto/generate.sh
- name: backend-tests
image: golang:alpine
depends_on: [ proto ]
commands:
- set -eu
- apk add --no-cache bash git build-base
- sh ci/scripts/common/run_backend_tests.sh notification
- name: secrets
image: alpine:latest
depends_on: [ version ]
@@ -57,7 +73,7 @@ steps:
- name: build-image
image: gcr.io/kaniko-project/executor:debug
depends_on: [ proto, secrets ]
depends_on: [ backend-tests, secrets ]
commands:
- sh ci/scripts/notification/build-image.sh

View File

@@ -0,0 +1,90 @@
matrix:
include:
- PAYMENTS_METHODS_IMAGE_PATH: payments/methods
PAYMENTS_METHODS_DOCKERFILE: ci/prod/compose/payments_methods.dockerfile
PAYMENTS_METHODS_MONGO_SECRET_PATH: sendico/db
PAYMENTS_METHODS_ENV: prod
when:
- event: push
branch: main
path:
include:
- api/payments/methods/**
- api/payments/storage/**
- api/proto/**
- api/pkg/**
- ci/prod/**
- .woodpecker/payments_methods.yml
ignore_message: '[rebuild]'
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: backend-tests
image: golang:alpine
depends_on: [ proto ]
commands:
- set -eu
- apk add --no-cache bash git build-base
- sh ci/scripts/common/run_backend_tests.sh payments_methods
- 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: [ backend-tests, secrets ]
commands:
- sh ci/scripts/payments_methods/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_methods/deploy.sh

View File

@@ -8,6 +8,15 @@ matrix:
when:
- event: push
branch: main
path:
include:
- api/payments/orchestrator/**
- api/payments/storage/**
- api/proto/**
- api/pkg/**
- ci/prod/**
- .woodpecker/payments_orchestrator.yml
ignore_message: '[rebuild]'
steps:
- name: version
@@ -34,6 +43,14 @@ steps:
- export PATH="$(go env GOPATH)/bin:$PATH"
- bash ci/scripts/proto/generate.sh
- name: backend-tests
image: golang:alpine
depends_on: [ proto ]
commands:
- set -eu
- apk add --no-cache bash git build-base
- sh ci/scripts/common/run_backend_tests.sh payments_orchestrator
- name: secrets
image: alpine:latest
depends_on: [ version ]
@@ -54,7 +71,7 @@ steps:
- name: build-image
image: gcr.io/kaniko-project/executor:debug
depends_on: [ proto, secrets ]
depends_on: [ backend-tests, secrets ]
commands:
- sh ci/scripts/payments_orchestrator/build-image.sh

View File

@@ -0,0 +1,90 @@
matrix:
include:
- PAYMENTS_QUOTATION_IMAGE_PATH: payments/quotation
PAYMENTS_QUOTATION_DOCKERFILE: ci/prod/compose/payments_quotation.dockerfile
PAYMENTS_QUOTATION_MONGO_SECRET_PATH: sendico/db
PAYMENTS_QUOTATION_ENV: prod
when:
- event: push
branch: main
path:
include:
- api/payments/quotation/**
- api/payments/storage/**
- api/proto/**
- api/pkg/**
- ci/prod/**
- .woodpecker/payments_quotation.yml
ignore_message: '[rebuild]'
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: backend-tests
image: golang:alpine
depends_on: [ proto ]
commands:
- set -eu
- apk add --no-cache bash git build-base
- sh ci/scripts/common/run_backend_tests.sh payments_quotation
- 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: [ backend-tests, secrets ]
commands:
- sh ci/scripts/payments_quotation/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_quotation/deploy.sh

View File

@@ -1,7 +1,7 @@
# Sendico Development Environment - Makefile
# Docker Compose + Makefile build system
.PHONY: help init build up down restart logs rebuild clean vault-init proto
.PHONY: help init build up down restart logs rebuild clean vault-init proto generate generate-api generate-frontend update update-api update-frontend test test-api test-frontend
COMPOSE := docker compose -f docker-compose.dev.yml --env-file .env.dev
SERVICE ?=
@@ -43,6 +43,15 @@ help:
@echo ""
@echo "$(YELLOW)Development:$(NC)"
@echo " make proto Generate protobuf code"
@echo " make generate Generate all code (protobuf + Flutter)"
@echo " make generate-api Generate protobuf code only"
@echo " make generate-frontend Generate Flutter code only"
@echo " make update Update all dependencies (Go + Flutter)"
@echo " make update-api Update Go dependencies only"
@echo " make update-frontend Update Flutter dependencies only"
@echo " make test Run all tests (API + frontend)"
@echo " make test-api Run Go API tests only"
@echo " make test-frontend Run Flutter tests only"
@echo " make health Check service health"
@echo ""
@echo "Examples:"
@@ -76,7 +85,7 @@ init:
@echo "$(GREEN)Verifying .env.dev...$(NC)"
@cat .env.dev | grep -q "MONGO_USER=" || (echo "$(YELLOW)Error: .env.dev is incomplete$(NC)" && exit 1)
@echo "$(GREEN)Running proto generation...$(NC)"
@./generate_protos.sh
@./ci/scripts/proto/generate.sh
@echo "$(GREEN)Building Docker images...$(NC)"
@$(COMPOSE) build
@echo "$(GREEN)✅ Initialization complete!$(NC)"
@@ -88,7 +97,7 @@ init:
# Build all images
build:
@echo "$(GREEN)Building all service images...$(NC)"
@./generate_protos.sh
@./ci/scripts/proto/generate.sh
@$(COMPOSE) build
# Start all services
@@ -136,12 +145,25 @@ endif
@echo "$(GREEN)✅ $(SERVICE) rebuilt$(NC)"
@echo "View logs: make logs SERVICE=$(SERVICE)"
# Generate protobuf code (alias)
proto: generate-api
# Generate all code
generate: generate-api generate-frontend
# Generate protobuf code
proto:
generate-api:
@echo "$(GREEN)Generating protobuf code...$(NC)"
@./generate_protos.sh
@./ci/scripts/proto/generate.sh
@echo "$(GREEN)✅ Protobuf generation complete$(NC)"
# Generate Flutter code (json_serializable, etc.)
generate-frontend:
@echo "$(GREEN)Generating Flutter code...$(NC)"
@cd frontend/pshared && dart run build_runner build --delete-conflicting-outputs
@cd frontend/pweb && dart run build_runner build --delete-conflicting-outputs
@echo "$(GREEN)✅ Flutter code generation complete$(NC)"
# Clean everything
clean:
@echo "$(YELLOW)WARNING: This will remove all containers and volumes!$(NC)"
@@ -196,6 +218,8 @@ services-up:
dev-billing-documents \
dev-ledger \
dev-payments-orchestrator \
dev-payments-quotation \
dev-payments-methods \
dev-chain-gateway \
dev-tron-gateway \
dev-mntx-gateway \
@@ -223,6 +247,8 @@ list-services:
@echo " - dev-billing-documents :50061, :9409 (Billing Documents)"
@echo " - dev-ledger :50052, :9401 (Double-Entry Ledger)"
@echo " - dev-payments-orchestrator :50062, :9403 (Payment Orchestration)"
@echo " - dev-payments-quotation :50064, :9414 (Payment Quotation)"
@echo " - dev-payments-methods :50066, :9416 (Payment Methods)"
@echo " - dev-chain-gateway :50070, :9404 (EVM Blockchain Gateway)"
@echo " - dev-tron-gateway :50071, :9408 (TRON Blockchain Gateway)"
@echo " - dev-mntx-gateway :50075, :9405, :8084 (Card Payouts)"
@@ -251,7 +277,7 @@ build-fx:
build-payments:
@echo "$(GREEN)Building payment services...$(NC)"
@$(COMPOSE) build dev-payments-orchestrator
@$(COMPOSE) build dev-payments-orchestrator dev-payments-quotation dev-payments-methods
build-gateways:
@echo "$(GREEN)Building gateway services...$(NC)"
@@ -264,3 +290,46 @@ build-api:
build-frontend:
@echo "$(GREEN)Building frontend...$(NC)"
@$(COMPOSE) build dev-frontend
# Update all dependencies
update: update-api update-frontend
# Update Go API dependencies
update-api:
@echo "$(GREEN)Updating Go dependencies...$(NC)"
@for dir in $$(find api -name go.mod -exec dirname {} \;); do \
echo "Updating $$dir..."; \
(cd "$$dir" && go get -u ./... && go mod tidy); \
done
@echo "$(GREEN)✅ Go dependencies updated$(NC)"
# Update Flutter dependencies
update-frontend:
@echo "$(GREEN)Updating Flutter dependencies...$(NC)"
@cd frontend/pshared && flutter pub upgrade --major-versions
@cd frontend/pweb && flutter pub upgrade --major-versions
@echo "$(GREEN)✅ Flutter dependencies updated$(NC)"
# Run all tests
test: test-api test-frontend
# Run Go API tests
test-api:
@echo "$(GREEN)Running API tests...$(NC)"
@failed=""; \
for dir in $$(find api -name go.mod -exec dirname {} \;); do \
echo "Testing $$dir..."; \
(cd "$$dir" && go test ./...) || failed="$$failed $$dir"; \
done; \
if [ -n "$$failed" ]; then \
echo "$(YELLOW)Failed:$$failed$(NC)"; \
exit 1; \
fi
@echo "$(GREEN)✅ All API tests passed$(NC)"
# Run Flutter tests
test-frontend:
@echo "$(GREEN)Running frontend tests...$(NC)"
@cd frontend/pshared && flutter test
@cd frontend/pweb && flutter test
@echo "$(GREEN)✅ All frontend tests passed$(NC)"

100
README.md Normal file
View File

@@ -0,0 +1,100 @@
# Sendico [![Build Status](https://ci.sendico.io/api/badges/1/status.svg?branch=main)](https://ci.sendico.io/repos/1)
Financial services platform providing payment orchestration, ledger accounting, FX conversion, and multi-rail payment processing.
## Architecture
- **Backend**: Go microservices with gRPC inter-service communication
- **Frontend**: Flutter/Dart web application
- **Infrastructure**: Woodpecker CI/CD, Docker, MongoDB, NATS, Vault
## Services
| Service | Path | Description |
|---------|------|-------------|
| Discovery | `api/discovery/` | Service registry |
| Ledger | `api/ledger/` | Double-entry accounting |
| Orchestrator | `api/payments/orchestrator/` | Payment orchestration |
| Quotation | `api/payments/quotation/` | Payment quotation |
| Payment Methods | `api/payments/methods/` | Payment methods |
| Billing Fees | `api/billing/fees/` | Fee calculation |
| Billing Documents | `api/billing/documents/` | Billing documents |
| FX Oracle | `api/fx/oracle/` | FX quote provider |
| FX Ingestor | `api/fx/ingestor/` | FX rate ingestion |
| Gateway Chain | `api/gateway/chain/` | EVM blockchain gateway |
| Gateway TRON | `api/gateway/tron/` | TRON blockchain gateway |
| Gateway MNTX | `api/gateway/mntx/` | Card payouts |
| Gateway TGSettle | `api/gateway/tgsettle/` | Telegram settlements with MNTX |
| Notification | `api/notification/` | Notifications |
| BFF | `api/server/` | Backend for frontend |
| Frontend | `frontend/pweb/` | Flutter web UI |
## Development
Development uses Docker Compose via the Makefile. Run `make help` for all available commands.
### Quick Start
```bash
make init # First-time setup (generates keys, .env.dev, builds images)
make up # Start all services
make vault-init # Initialize Vault (if needed)
```
### Common Commands
```bash
make build # Build all service images
make up # Start all services
make down # Stop all services
make restart # Restart all services
make status # Show service status
make logs # View all logs
make logs SERVICE=dev-ledger # View logs for a specific service
make rebuild SERVICE=dev-ledger # Rebuild and restart a specific service
make clean # Remove all containers and volumes
```
### Selective Start
```bash
make infra-up # Start infrastructure only (MongoDB, NATS, Vault)
make services-up # Start application services only (assumes infra is running)
```
### Build Groups
```bash
make build-core # discovery, ledger, fees, documents
make build-fx # oracle, ingestor
make build-payments # orchestrator
make build-gateways # chain, tron, mntx, tgsettle
make build-api # notification, bff
make build-frontend # Flutter web UI
```
### Code Generation
```bash
make generate # Generate all code (protobuf + Flutter)
make generate-api # Generate protobuf code only
make generate-frontend # Generate Flutter code only (build_runner)
make proto # Alias for generate-api
```
### Testing
```bash
make test # Run all tests (API + frontend)
make test-api # Run Go API tests only
make test-frontend # Run Flutter tests only
```
### Update Dependencies
```bash
make update # Update all Go and Flutter dependencies
make update-api # Update Go dependencies only
make update-frontend # Update Flutter dependencies only
```

View File

@@ -0,0 +1,196 @@
# See the dedicated "version" documentation section.
version: "2"
linters:
# Default set of linters.
# The value can be:
# - `standard`: https://golangci-lint.run/docs/linters/#enabled-by-default
# - `all`: enables all linters by default.
# - `none`: disables all linters by default.
# - `fast`: enables only linters considered as "fast" (`golangci-lint help linters --json | jq '[ .[] | select(.fast==true) ] | map(.name)'`).
# Default: standard
default: all
# Enable specific linter.
enable:
- arangolint
- asasalint
- asciicheck
- bidichk
- bodyclose
- canonicalheader
- containedctx
- contextcheck
- copyloopvar
- cyclop
- decorder
- dogsled
- dupl
- dupword
- durationcheck
- embeddedstructfieldcheck
- err113
- errcheck
- errchkjson
- errname
- errorlint
- exhaustive
- exptostd
- fatcontext
- forbidigo
- forcetypeassert
- funcorder
- funlen
- ginkgolinter
- gocheckcompilerdirectives
- gochecknoglobals
- gochecknoinits
- gochecksumtype
- gocognit
- goconst
- gocritic
- gocyclo
- godoclint
- godot
- godox
- goheader
- gomodguard
- goprintffuncname
- gosec
- gosmopolitan
- govet
- grouper
- iface
- importas
- inamedparam
- ineffassign
- interfacebloat
- intrange
- iotamixing
- ireturn
- lll
- loggercheck
- maintidx
- makezero
- mirror
- misspell
- mnd
- modernize
- musttag
- nakedret
- nestif
- nilerr
- nilnesserr
- nilnil
- nlreturn
- noctx
- noinlineerr
- nolintlint
- nonamedreturns
- nosprintfhostport
- paralleltest
- perfsprint
- prealloc
- predeclared
- promlinter
- protogetter
- reassign
- recvcheck
- revive
- rowserrcheck
- sloglint
- spancheck
- sqlclosecheck
- staticcheck
- tagalign
- tagliatelle
- testableexamples
- testifylint
- testpackage
- thelper
- tparallel
- unconvert
- unparam
- unqueryvet
- unused
- usestdlibvars
- usetesting
- varnamelen
- wastedassign
- whitespace
- wsl_v5
- zerologlint
# Disable specific linters.
disable:
- depguard
- exhaustruct
- gochecknoglobals
- gomoddirectives
- wsl
- wrapcheck
# All available settings of specific linters.
# See the dedicated "linters.settings" documentation section.
settings:
wsl_v5:
allow-first-in-block: true
allow-whole-block: false
branch-max-lines: 2
# Defines a set of rules to ignore issues.
# It does not skip the analysis, and so does not ignore "typecheck" errors.
exclusions:
# Mode of the generated files analysis.
#
# - `strict`: sources are excluded by strictly following the Go generated file convention.
# Source files that have lines matching only the following regular expression will be excluded: `^// Code generated .* DO NOT EDIT\.$`
# This line must appear before the first non-comment, non-blank text in the file.
# https://go.dev/s/generatedcode
# - `lax`: sources are excluded if they contain lines like `autogenerated file`, `code generated`, `do not edit`, etc.
# - `disable`: disable the generated files exclusion.
#
# Default: strict
generated: lax
# Log a warning if an exclusion rule is unused.
# Default: false
warn-unused: true
# Predefined exclusion rules.
# Default: []
presets:
- comments
- std-error-handling
- common-false-positives
- legacy
# Excluding configuration per-path, per-linter, per-text and per-source.
rules:
# Exclude some linters from running on tests files.
- path: _test\.go
linters:
- funlen
- gocyclo
- errcheck
- dupl
- gosec
# Run some linter only for test files by excluding its issues for everything else.
- path-except: _test\.go
linters:
- forbidigo
# Exclude known linters from partially hard-vendored code,
# which is impossible to exclude via `nolint` comments.
# `/` will be replaced by the current OS file path separator to properly work on Windows.
- path: internal/hmac/
text: "weak cryptographic primitive"
linters:
- gosec
# Exclude some `staticcheck` messages.
- linters:
- staticcheck
text: "SA9003:"
# Exclude `lll` issues for long lines with `go:generate`.
- linters:
- lll
source: "^//go:generate "
# Which file paths to exclude: they will be analyzed, but issues from them won't be reported.
# "/" will be replaced by the current OS file path separator to properly work on Windows.
# Default: []
paths: []
# Which file paths to not exclude.
# Default: []
paths-except: []

View File

@@ -1,70 +1,70 @@
module github.com/tech/sendico/billing/documents
go 1.25.6
go 1.25.7
replace github.com/tech/sendico/pkg => ../../pkg
require (
github.com/aws/aws-sdk-go-v2 v1.41.1
github.com/aws/aws-sdk-go-v2/config v1.32.7
github.com/aws/aws-sdk-go-v2/credentials v1.19.7
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0
github.com/aws/aws-sdk-go-v2 v1.41.2
github.com/aws/aws-sdk-go-v2/config v1.32.10
github.com/aws/aws-sdk-go-v2/credentials v1.19.10
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.2
github.com/jung-kurt/gofpdf v1.16.2
github.com/prometheus/client_golang v1.23.2
github.com/shopspring/decimal v1.4.0
github.com/tech/sendico/pkg v0.1.0
go.mongodb.org/mongo-driver/v2 v2.5.0
go.uber.org/zap v1.27.1
google.golang.org/grpc v1.78.0
google.golang.org/grpc v1.79.1
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect
github.com/aws/smithy-go v1.24.0 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.18 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.5 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.10 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.18 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.18 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.6 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.11 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.7 // indirect
github.com/aws/smithy-go v1.24.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect
github.com/casbin/casbin/v2 v2.135.0 // indirect
github.com/casbin/govaluate v1.10.0 // indirect
github.com/casbin/mongodb-adapter/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/go-chi/chi/v5 v5.2.4 // indirect
github.com/go-chi/chi/v5 v5.2.5 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/compress v1.18.3 // indirect
github.com/klauspost/compress v1.18.4 // 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/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nats-io/nats.go v1.48.0 // indirect
github.com/nats-io/nats.go v1.49.0 // indirect
github.com/nats-io/nkeys v0.4.15 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/procfs v0.19.2 // indirect
github.com/prometheus/procfs v0.20.0 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.2.0 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
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.47.0 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)

View File

@@ -4,44 +4,44 @@ github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU=
github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4=
github.com/aws/aws-sdk-go-v2/config v1.32.7 h1:vxUyWGUwmkQ2g19n7JY/9YL8MfAIl7bTesIUykECXmY=
github.com/aws/aws-sdk-go-v2/config v1.32.7/go.mod h1:2/Qm5vKUU/r7Y+zUk/Ptt2MDAEKAfUtKc1+3U1Mo3oY=
github.com/aws/aws-sdk-go-v2/credentials v1.19.7 h1:tHK47VqqtJxOymRrNtUXN5SP/zUTvZKeLx4tH6PGQc8=
github.com/aws/aws-sdk-go-v2/credentials v1.19.7/go.mod h1:qOZk8sPDrxhf+4Wf4oT2urYJrYt3RejHSzgAquYeppw=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 h1:I0GyV8wiYrP8XpA70g1HBcQO1JlQxCMTW9npl5UbDHY=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17/go.mod h1:tyw7BOl5bBe/oqvoIeECFJjMdzXoa/dfVz3QQ5lgHGA=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17/go.mod h1:5M5CI3D12dNOtH3/mk6minaRwI2/37ifCURZISxA/IQ=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 h1:WWLqlh79iO48yLkj1v3ISRNiv+3KdQoZ6JWyfcsyQik=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17/go.mod h1:EhG22vHRrvF8oXSTYStZhJc1aUgKtnJe+aOiFEV90cM=
github.com/aws/aws-sdk-go-v2 v1.41.2 h1:LuT2rzqNQsauaGkPK/7813XxcZ3o3yePY0Iy891T2ls=
github.com/aws/aws-sdk-go-v2 v1.41.2/go.mod h1:IvvlAZQXvTXznUPfRVfryiG1fbzE2NGK6m9u39YQ+S4=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 h1:zWFmPmgw4sveAYi1mRqG+E/g0461cJ5M4bJ8/nc6d3Q=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5/go.mod h1:nVUlMLVV8ycXSb7mSkcNu9e3v/1TJq2RTlrPwhYWr5c=
github.com/aws/aws-sdk-go-v2/config v1.32.10 h1:9DMthfO6XWZYLfzZglAgW5Fyou2nRI5CuV44sTedKBI=
github.com/aws/aws-sdk-go-v2/config v1.32.10/go.mod h1:2rUIOnA2JaiqYmSKYmRJlcMWy6qTj1vuRFscppSBMcw=
github.com/aws/aws-sdk-go-v2/credentials v1.19.10 h1:EEhmEUFCE1Yhl7vDhNOI5OCL/iKMdkkYFTRpZXNw7m8=
github.com/aws/aws-sdk-go-v2/credentials v1.19.10/go.mod h1:RnnlFCAlxQCkN2Q379B67USkBMu1PipEEiibzYN5UTE=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18 h1:Ii4s+Sq3yDfaMLpjrJsqD6SmG/Wq/P5L/hw2qa78UAY=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18/go.mod h1:6x81qnY++ovptLE6nWQeWrpXxbnlIex+4H4eYYGcqfc=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18 h1:F43zk1vemYIqPAwhjTjYIz0irU2EY7sOb/F5eJ3HuyM=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18/go.mod h1:w1jdlZXrGKaJcNoL+Nnrj+k5wlpGXqnNrKoP22HvAug=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18 h1:xCeWVjj0ki0l3nruoyP2slHsGArMxeiiaoPN5QZH6YQ=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18/go.mod h1:r/eLGuGCBw6l36ZRWiw6PaZwPXb6YOj+i/7MizNl5/k=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 h1:JqcdRG//czea7Ppjb+g/n4o8i/R50aTBHkA7vu0lK+k=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17/go.mod h1:CO+WeGmIdj/MlPel2KwID9Gt7CNq4M65HUfBW97liM0=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 h1:Z5EiPIzXKewUQK0QTMkutjiaPVeVYXX7KIqhXu/0fXs=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8/go.mod h1:FsTpJtvC4U1fyDXk7c71XoDv3HlRm8V3NiYLeYLh5YE=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 h1:RuNSMoozM8oXlgLG/n6WLaFGoea7/CddrCfIiSA+xdY=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17/go.mod h1:F2xxQ9TZz5gDWsclCtPQscGpP0VUOc8RqgFM3vDENmU=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 h1:bGeHBsGZx0Dvu/eJC0Lh9adJa3M1xREcndxLNZlve2U=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17/go.mod h1:dcW24lbU0CzHusTE8LLHhRLI42ejmINN8Lcr22bwh/g=
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0 h1:oeu8VPlOre74lBA/PMhxa5vewaMIMmILM+RraSyB8KA=
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0/go.mod h1:5jggDlZ2CLQhwJBiZJb4vfk4f0GxWdEDruWKEJ1xOdo=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 h1:VrhDvQib/i0lxvr3zqlUwLwJP4fpmpyD9wYG1vfSu+Y=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5/go.mod h1:k029+U8SY30/3/ras4G/Fnv/b88N4mAfliNn08Dem4M=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 h1:v6EiMvhEYBoHABfbGB4alOYmCIrcgyPPiBE1wZAEbqk=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.9/go.mod h1:yifAsgBxgJWn3ggx70A3urX2AN49Y5sJTD1UQFlfqBw=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 h1:gd84Omyu9JLriJVCbGApcLzVR3XtmC4ZDPcAI6Ftvds=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13/go.mod h1:sTGThjphYE4Ohw8vJiRStAcu3rbjtXRsdNB0TvZ5wwo=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/pJ1jOWYlFDJTjRQ=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ=
github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.18 h1:eZioDaZGJ0tMM4gzmkNIO2aAoQd+je7Ug7TkvAzlmkU=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.18/go.mod h1:CCXwUKAJdoWr6/NcxZ+zsiPr6oH/Q5aTooRGYieAyj4=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.5 h1:CeY9LUdur+Dxoeldqoun6y4WtJ3RQtzk0JMP2gfUay0=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.5/go.mod h1:AZLZf2fMaahW5s/wMRciu1sYbdsikT/UHwbUjOdEVTc=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.10 h1:fJvQ5mIBVfKtiyx0AHY6HeWcRX5LGANLpq8SVR+Uazs=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.10/go.mod h1:Kzm5e6OmNH8VMkgK9t+ry5jEih4Y8whqs+1hrkxim1I=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.18 h1:LTRCYFlnnKFlKsyIQxKhJuDuA3ZkrDQMRYm6rXiHlLY=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.18/go.mod h1:XhwkgGG6bHSd00nO/mexWTcTjgd6PjuvWQMqSn2UaEk=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.18 h1:/A/xDuZAVD2BpsS2fftFRo/NoEKQJ8YTnJDEHBy2Gtg=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.18/go.mod h1:hWe9b4f+djUQGmyiGEeOnZv69dtMSgpDRIvNMvuvzvY=
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.2 h1:M1A9AjcFwlxTLuf0Faj88L8Iqw0n/AJHjpZTQzMMsSc=
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.2/go.mod h1:KsdTV6Q9WKUZm2mNJnUFmIoXfZux91M3sr/a4REX8e0=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.6 h1:MzORe+J94I+hYu2a6XmV5yC9huoTv8NRcCrUNedDypQ=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.6/go.mod h1:hXzcHLARD7GeWnifd8j9RWqtfIgxj4/cAtIVIK7hg8g=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.11 h1:7oGD8KPfBOJGXiCoRKrrrQkbvCp8N++u36hrLMPey6o=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.11/go.mod h1:0DO9B5EUJQlIDif+XJRWCljZRKsAFKh3gpFz7UnDtOo=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15 h1:edCcNp9eGIUDUCrzoCu1jWAXLGFIizeqkdkKgRlJwWc=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15/go.mod h1:lyRQKED9xWfgkYC/wmmYfv7iVIM68Z5OQ88ZdcV1QbU=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.7 h1:NITQpgo9A5NrDZ57uOWj+abvXSb83BbyggcUBVksN7c=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.7/go.mod h1:sks5UWBhEuWYDPdwlnRFn1w7xWdH29Jcpe+/PJQefEs=
github.com/aws/smithy-go v1.24.1 h1:VbyeNfmYkWoxMVpGUAbQumkODcYmfMRfZ8yQiH30SK0=
github.com/aws/smithy-go v1.24.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
@@ -78,8 +78,8 @@ 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.4 h1:WtFKPHwlywe8Srng8j2BhOD9312j9cGUxG1SP4V2cR4=
github.com/go-chi/chi/v5 v5.2.4/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
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=
@@ -100,8 +100,8 @@ github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+
github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes=
github.com/jung-kurt/gofpdf v1.16.2 h1:jgbatWHfRlPYiK85qgevsZTHviWXKwB1TTiKdz5PtRc=
github.com/jung-kurt/gofpdf v1.16.2/go.mod h1:1hl7y57EsiPAkLbOwzpzqgx1A30nQCk/YmFV8S2vmK0=
github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.4/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=
@@ -134,8 +134,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
github.com/nats-io/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE=
github.com/nats-io/nats.go v1.49.0/go.mod h1:fDCn3mN5cY8HooHwE2ukiLb4p4G4ImmzvXyJt+tGwdw=
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
@@ -158,8 +158,8 @@ github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNw
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
github.com/prometheus/procfs v0.20.0 h1:AA7aCvjxwAquZAlonN7888f2u4IN8WVeFgBi4k82M4Q=
github.com/prometheus/procfs v0.20.0/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
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/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w=
@@ -201,16 +201,16 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 h1:UP6IpuHFkUgOQL9FFQFrZ+5LiwhhYRbi7VZSIx6Nj5s=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM=
go.opentelemetry.io/otel v1.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.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
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=
@@ -221,16 +221,16 @@ 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.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
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.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
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.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
@@ -241,16 +241,16 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
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.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.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.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
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=
@@ -258,10 +258,10 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b h1:GZxXGdFaHX27ZSMHudWc4FokdD+xl8BC2UJm1OVIEzs=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -24,5 +24,6 @@ func Create() version.Printer {
BuildDate: BuildDate,
Version: Version,
}
return vf.Create(&info)
}

View File

@@ -21,11 +21,13 @@ func NewLocalStore(logger mlogger.Logger, cfg LocalConfig) (*LocalStore, error)
if root == "" {
return nil, merrors.InvalidArgument("docstore: local root_path is empty")
}
store := &LocalStore{
logger: logger.Named("docstore").Named("local"),
rootPath: root,
}
store.logger.Info("Document storage initialised", zap.String("root_path", root))
return store, nil
}
@@ -33,15 +35,19 @@ func (s *LocalStore) Save(ctx context.Context, key string, data []byte) error {
if err := ctx.Err(); err != nil {
return err
}
path := filepath.Join(s.rootPath, filepath.Clean(key))
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
s.logger.Warn("Failed to create document directory", zap.Error(err), zap.String("path", path))
return err
}
if err := os.WriteFile(path, data, 0o600); err != nil {
s.logger.Warn("Failed to write document file", zap.Error(err), zap.String("path", path))
return err
}
return nil
}
@@ -49,12 +55,16 @@ func (s *LocalStore) Load(ctx context.Context, key string) ([]byte, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
path := filepath.Join(s.rootPath, filepath.Clean(key))
data, err := os.ReadFile(path)
if err != nil {
s.logger.Warn("Failed to read document file", zap.Error(err), zap.String("path", path))
return nil, err
}
return data, nil
}

View File

@@ -32,6 +32,7 @@ func NewS3Store(logger mlogger.Logger, cfg S3Config) (*S3Store, error) {
if accessKey == "" && cfg.AccessKeyEnv != "" {
accessKey = strings.TrimSpace(os.Getenv(cfg.AccessKeyEnv))
}
secretKey := strings.TrimSpace(cfg.SecretAccessKey)
if secretKey == "" && cfg.SecretKeyEnv != "" {
secretKey = strings.TrimSpace(os.Getenv(cfg.SecretKeyEnv))
@@ -62,23 +63,21 @@ func NewS3Store(logger mlogger.Logger, cfg S3Config) (*S3Store, error) {
endpoint = "http://" + endpoint
}
}
resolver := aws.EndpointResolverWithOptionsFunc(func(service, region string, _ ...interface{}) (aws.Endpoint, error) {
if service == s3.ServiceID {
return aws.Endpoint{URL: endpoint, SigningRegion: region, HostnameImmutable: true}, nil
}
return aws.Endpoint{}, &aws.EndpointNotFoundError{}
})
loadOpts = append(loadOpts, config.WithEndpointResolverWithOptions(resolver))
}
awsCfg, err := config.LoadDefaultConfig(context.Background(), loadOpts...)
if err != nil {
logger.Warn("Failed to create AWS config", zap.Error(err), zap.String("bucket", bucket))
return nil, err
}
client := s3.NewFromConfig(awsCfg, func(opts *s3.Options) {
opts.UsePathStyle = cfg.ForcePathStyle
if endpoint != "" {
opts.BaseEndpoint = aws.String(endpoint)
}
})
store := &S3Store{
@@ -87,6 +86,7 @@ func NewS3Store(logger mlogger.Logger, cfg S3Config) (*S3Store, error) {
bucket: bucket,
}
store.logger.Info("Document storage initialised", zap.String("bucket", bucket), zap.String("endpoint", endpoint))
return store, nil
}
@@ -94,6 +94,7 @@ func (s *S3Store) Save(ctx context.Context, key string, data []byte) error {
if err := ctx.Err(); err != nil {
return err
}
_, err := s.client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
@@ -101,8 +102,10 @@ func (s *S3Store) Save(ctx context.Context, key string, data []byte) error {
})
if err != nil {
s.logger.Warn("Failed to upload document", zap.Error(err), zap.String("key", key))
return err
}
return nil
}
@@ -110,15 +113,19 @@ func (s *S3Store) Load(ctx context.Context, key string) ([]byte, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
obj, err := s.client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
})
if err != nil {
s.logger.Warn("Failed to fetch document", zap.Error(err), zap.String("key", key))
return nil, err
}
defer obj.Body.Close()
return io.ReadAll(obj.Body)
}

View File

@@ -36,7 +36,7 @@ type S3Config struct {
Bucket string `yaml:"bucket"`
AccessKeyEnv string `yaml:"access_key_env"`
SecretKeyEnv string `yaml:"secret_access_key_env"`
AccessKey string `yaml:"access_key"`
AccessKey string `yaml:"access_key"` //nolint:gosec // config field, not a hardcoded secret
SecretAccessKey string `yaml:"secret_access_key"`
UseSSL bool `yaml:"use_ssl"`
ForcePathStyle bool `yaml:"force_path_style"`
@@ -55,11 +55,13 @@ func New(logger mlogger.Logger, cfg Config) (Store, error) {
if cfg.Local == nil {
return nil, merrors.InvalidArgument("docstore: local config missing")
}
return NewLocalStore(logger, *cfg.Local)
case string(DriverS3), string(DriverMinio):
if cfg.S3 == nil {
return nil, merrors.InvalidArgument("docstore: s3 config missing")
}
return NewS3Store(logger, *cfg.S3)
default:
return nil, merrors.InvalidArgument("docstore: unsupported driver")

View File

@@ -29,7 +29,8 @@ type Imp struct {
type config struct {
*grpcapp.Config `yaml:",inline"`
Documents documents.Config `yaml:"documents"`
Documents documents.Config `yaml:"documents"`
}
// Create initialises the billing documents server implementation.
@@ -46,6 +47,7 @@ func (i *Imp) Shutdown() {
if i.service != nil {
i.service.Shutdown()
}
return
}
@@ -68,6 +70,7 @@ func (i *Imp) Start() error {
if err != nil {
return err
}
i.config = cfg
repoFactory := func(logger mlogger.Logger, conn *db.MongoConnection) (storage.Repository, error) {
@@ -77,20 +80,23 @@ func (i *Imp) Start() error {
docStore, err := docstore.New(i.logger, cfg.Documents.Storage)
if err != nil {
i.logger.Error("Failed to initialise document storage", zap.Error(err))
return err
}
serviceFactory := func(logger mlogger.Logger, repo storage.Repository, producer msg.Producer) (grpcapp.Service, error) {
serviceFactory := func(logger mlogger.Logger, repo storage.Repository, producer msg.Producer) (grpcapp.Service, error) { //nolint:lll // factory signature dictated by grpcapp
invokeURI := ""
if cfg.GRPC != nil {
invokeURI = cfg.GRPC.DiscoveryInvokeURI()
}
svc := documents.NewService(logger, repo, producer,
documents.WithDiscoveryInvokeURI(invokeURI),
documents.WithConfig(cfg.Documents),
documents.WithDocumentStore(docStore),
)
i.service = svc
return svc, nil
}
@@ -98,6 +104,7 @@ func (i *Imp) Start() error {
if err != nil {
return err
}
i.app = app
return i.app.Start()
@@ -107,12 +114,14 @@ 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
}

View File

@@ -29,5 +29,6 @@ func (c Config) AcceptanceTemplatePath() string {
if strings.TrimSpace(c.Templates.AcceptancePath) == "" {
return "templates/acceptance.tpl"
}
return c.Templates.AcceptancePath
}

View File

@@ -85,14 +85,18 @@ 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())
}
@@ -101,5 +105,6 @@ func docTypeLabel(docType documentsv1.DocumentType) string {
if label == "" {
return "DOCUMENT_TYPE_UNSPECIFIED"
}
return label
}

View File

@@ -41,6 +41,7 @@ func WithDiscoveryInvokeURI(uri string) Option {
if s == nil {
return
}
s.invokeURI = strings.TrimSpace(uri)
}
}
@@ -51,6 +52,7 @@ func WithProducer(producer msg.Producer) Option {
if s == nil {
return
}
s.producer = producer
}
}
@@ -61,6 +63,7 @@ func WithConfig(cfg Config) Option {
if s == nil {
return
}
s.config = cfg
}
}
@@ -71,6 +74,7 @@ func WithDocumentStore(store docstore.Store) Option {
if s == nil {
return
}
s.docStore = store
}
}
@@ -81,12 +85,15 @@ func WithTemplateRenderer(renderer TemplateRenderer) Option {
if s == nil {
return
}
s.template = renderer
}
}
// Service provides billing document metadata and retrieval endpoints.
type Service struct {
documentsv1.UnimplementedDocumentServiceServer
logger mlogger.Logger
storage storage.Repository
docStore docstore.Store
@@ -95,12 +102,12 @@ type Service struct {
invokeURI string
config Config
template TemplateRenderer
documentsv1.UnimplementedDocumentServiceServer
}
// NewService constructs a documents service with optional configuration.
func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Producer, opts ...Option) *Service {
initMetrics()
svc := &Service{
logger: logger.Named("documents"),
storage: repo,
@@ -109,14 +116,17 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro
for _, opt := range opts {
opt(svc)
}
if svc.template == nil {
if tmpl, err := newTemplateRenderer(svc.config.AcceptanceTemplatePath()); err != nil {
svc.logger.Warn("failed to load acceptance template", zap.Error(err))
svc.logger.Warn("Failed to load acceptance template", zap.Error(err))
} else {
svc.template = tmpl
}
}
svc.startDiscoveryAnnouncer()
return svc
}
@@ -130,32 +140,22 @@ func (s *Service) Shutdown() {
if s == nil {
return
}
if s.announcer != nil {
s.announcer.Stop()
}
}
func (s *Service) startDiscoveryAnnouncer() {
if s == nil || s.producer == nil {
return
}
announce := discovery.Announcement{
Service: "BILLING_DOCUMENTS",
Operations: []string{"documents.batch_resolve", "documents.get"},
InvokeURI: s.invokeURI,
Version: appversion.Create().Short(),
}
s.announcer = discovery.NewAnnouncer(s.logger, s.producer, string(mservice.BillingDocuments), announce)
s.announcer.Start()
}
func (s *Service) BatchResolveDocuments(ctx context.Context, req *documentsv1.BatchResolveDocumentsRequest) (resp *documentsv1.BatchResolveDocumentsResponse, err error) {
start := time.Now()
var paymentRefs []string
if req != nil {
paymentRefs = req.GetPaymentRefs()
}
logger := s.logger.With(zap.Int("payment_refs", len(paymentRefs)))
defer func() {
statusLabel := statusFromError(err)
observeRequest("batch_resolve", documentsv1.DocumentType_DOCUMENT_TYPE_UNSPECIFIED, statusLabel, time.Since(start))
@@ -165,38 +165,48 @@ func (s *Service) BatchResolveDocuments(ctx context.Context, req *documentsv1.Ba
if resp != nil {
itemsCount = len(resp.GetItems())
}
fields := []zap.Field{
zap.String("status", statusLabel),
zap.Duration("duration", time.Since(start)),
zap.Int("items", itemsCount),
}
if err != nil {
logger.Warn("BatchResolveDocuments failed", append(fields, zap.Error(err))...)
return
}
logger.Info("BatchResolveDocuments finished", fields...)
}()
if len(paymentRefs) == 0 {
resp = &documentsv1.BatchResolveDocumentsResponse{}
return resp, nil
}
if s.storage == nil {
err = status.Error(codes.Unavailable, errStorageUnavailable.Error())
return nil, err
}
refs := make([]string, 0, len(paymentRefs))
for _, ref := range paymentRefs {
clean := strings.TrimSpace(ref)
if clean == "" {
continue
}
refs = append(refs, clean)
}
if len(refs) == 0 {
resp = &documentsv1.BatchResolveDocumentsResponse{}
return resp, nil
}
@@ -206,10 +216,12 @@ func (s *Service) BatchResolveDocuments(ctx context.Context, req *documentsv1.Ba
}
recordByRef := map[string]*model.DocumentRecord{}
for _, record := range records {
if record == nil {
continue
}
recordByRef[record.PaymentRef] = record
}
@@ -218,18 +230,23 @@ func (s *Service) BatchResolveDocuments(ctx context.Context, req *documentsv1.Ba
meta := &documentsv1.DocumentMeta{PaymentRef: ref}
if record := recordByRef[ref]; record != nil {
record.Normalize()
available := []model.DocumentType{model.DocumentTypeAct}
ready := make([]model.DocumentType, 0, 1)
if path, ok := record.StoragePaths[model.DocumentTypeAct]; ok && path != "" {
ready = append(ready, model.DocumentTypeAct)
}
meta.AvailableTypes = toProtoTypes(available)
meta.ReadyTypes = toProtoTypes(ready)
}
items = append(items, meta)
}
resp = &documentsv1.BatchResolveDocumentsResponse{Items: items}
return resp, nil
}
@@ -237,10 +254,12 @@ func (s *Service) GetDocument(ctx context.Context, req *documentsv1.GetDocumentR
start := time.Now()
docType := documentsv1.DocumentType_DOCUMENT_TYPE_UNSPECIFIED
paymentRef := ""
if req != nil {
docType = req.GetType()
paymentRef = strings.TrimSpace(req.GetPaymentRef())
}
logger := s.logger.With(
zap.String("payment_ref", paymentRef),
zap.String("document_type", docTypeLabel(docType)),
@@ -249,6 +268,7 @@ func (s *Service) GetDocument(ctx context.Context, req *documentsv1.GetDocumentR
defer func() {
statusLabel := statusFromError(err)
observeRequest("get_document", docType, statusLabel, time.Since(start))
if resp != nil {
observeDocumentBytes(docType, len(resp.GetContent()))
}
@@ -257,36 +277,49 @@ func (s *Service) GetDocument(ctx context.Context, req *documentsv1.GetDocumentR
if resp != nil {
contentBytes = len(resp.GetContent())
}
fields := []zap.Field{
zap.String("status", statusLabel),
zap.Duration("duration", time.Since(start)),
zap.Int("content_bytes", contentBytes),
}
if err != nil {
logger.Warn("GetDocument failed", append(fields, zap.Error(err))...)
return
}
logger.Info("GetDocument finished", fields...)
}()
if paymentRef == "" {
err = status.Error(codes.InvalidArgument, "payment_ref is required")
return nil, err
}
if docType == documentsv1.DocumentType_DOCUMENT_TYPE_UNSPECIFIED {
err = status.Error(codes.InvalidArgument, "document type is required")
return nil, err
}
if s.storage == nil {
err = status.Error(codes.Unavailable, errStorageUnavailable.Error())
return nil, err
}
if s.docStore == nil {
err = status.Error(codes.Unavailable, errDocStoreUnavailable.Error())
return nil, err
}
if s.template == nil {
err = status.Error(codes.FailedPrecondition, errTemplateUnavailable.Error())
return nil, err
}
@@ -295,8 +328,10 @@ func (s *Service) GetDocument(ctx context.Context, req *documentsv1.GetDocumentR
if errors.Is(err, storage.ErrDocumentNotFound) {
return nil, status.Error(codes.NotFound, "document record not found")
}
return nil, status.Error(codes.Internal, err.Error())
}
record.Normalize()
targetType := model.DocumentTypeFromProto(docType)
@@ -310,6 +345,7 @@ func (s *Service) GetDocument(ctx context.Context, req *documentsv1.GetDocumentR
if loadErr != nil {
return nil, status.Error(codes.Internal, loadErr.Error())
}
return &documentsv1.GetDocumentResponse{
Content: content,
Filename: documentFilename(docType, paymentRef),
@@ -320,19 +356,23 @@ func (s *Service) GetDocument(ctx context.Context, req *documentsv1.GetDocumentR
content, hash, genErr := s.generateActPDF(record.Snapshot)
if genErr != nil {
logger.Warn("Failed to generate document", zap.Error(genErr))
return nil, status.Error(codes.Internal, genErr.Error())
}
path := documentStoragePath(paymentRef, docType)
if saveErr := s.docStore.Save(ctx, path, content); saveErr != nil {
logger.Warn("Failed to store document", zap.Error(saveErr))
return nil, status.Error(codes.Internal, saveErr.Error())
}
record.StoragePaths[targetType] = path
record.Hashes[targetType] = hash
if updateErr := s.storage.Documents().Update(ctx, record); updateErr != nil {
logger.Warn("Failed to update document record", zap.Error(updateErr))
return nil, status.Error(codes.Internal, updateErr.Error())
}
@@ -341,9 +381,25 @@ func (s *Service) GetDocument(ctx context.Context, req *documentsv1.GetDocumentR
Filename: documentFilename(docType, paymentRef),
MimeType: "application/pdf",
}
return resp, nil
}
func (s *Service) startDiscoveryAnnouncer() {
if s == nil || s.producer == nil {
return
}
announce := discovery.Announcement{
Service: "BILLING_DOCUMENTS",
Operations: []string{discovery.OperationDocumentsBatchResolve, discovery.OperationDocumentsGet},
InvokeURI: s.invokeURI,
Version: appversion.Create().Short(),
}
s.announcer = discovery.NewAnnouncer(s.logger, s.producer, mservice.BillingDocuments, announce)
s.announcer.Start()
}
type serviceError string
func (e serviceError) Error() string {
@@ -361,15 +417,18 @@ func (s *Service) generateActPDF(snapshot model.ActSnapshot) ([]byte, string, er
if err != nil {
return nil, "", err
}
generated := renderer.Renderer{
Issuer: s.config.Issuer,
OwnerPassword: s.config.Protection.OwnerPassword,
}
placeholder := strings.Repeat("0", 64)
firstPass, err := generated.Render(blocks, placeholder)
if err != nil {
return nil, "", err
}
footerHash := sha256.Sum256(firstPass)
footerHex := hex.EncodeToString(footerHash[:])
@@ -377,6 +436,7 @@ func (s *Service) generateActPDF(snapshot model.ActSnapshot) ([]byte, string, er
if err != nil {
return nil, "", err
}
return finalBytes, footerHex, nil
}
@@ -384,15 +444,18 @@ func toProtoTypes(types []model.DocumentType) []documentsv1.DocumentType {
if len(types) == 0 {
return nil
}
result := make([]documentsv1.DocumentType, 0, len(types))
for _, t := range types {
result = append(result, t.Proto())
}
return result
}
func documentStoragePath(paymentRef string, docType documentsv1.DocumentType) string {
suffix := "document.pdf"
switch docType {
case documentsv1.DocumentType_DOCUMENT_TYPE_ACT:
suffix = "act.pdf"
@@ -400,12 +463,16 @@ func documentStoragePath(paymentRef string, docType documentsv1.DocumentType) st
suffix = "invoice.pdf"
case documentsv1.DocumentType_DOCUMENT_TYPE_RECEIPT:
suffix = "receipt.pdf"
case documentsv1.DocumentType_DOCUMENT_TYPE_UNSPECIFIED:
// default suffix used
}
return filepath.ToSlash(filepath.Join("documents", paymentRef, suffix))
}
func documentFilename(docType documentsv1.DocumentType, paymentRef string) string {
name := "document"
switch docType {
case documentsv1.DocumentType_DOCUMENT_TYPE_ACT:
name = "act"
@@ -413,6 +480,9 @@ func documentFilename(docType documentsv1.DocumentType, paymentRef string) strin
name = "invoice"
case documentsv1.DocumentType_DOCUMENT_TYPE_RECEIPT:
name = "receipt"
case documentsv1.DocumentType_DOCUMENT_TYPE_UNSPECIFIED:
// default name used
}
return fmt.Sprintf("%s_%s.pdf", name, paymentRef)
}

View File

@@ -18,7 +18,7 @@ type stubRepo struct {
store storage.DocumentsStore
}
func (s *stubRepo) Ping(ctx context.Context) error { return nil }
func (s *stubRepo) Ping(_ context.Context) error { return nil }
func (s *stubRepo) Documents() storage.DocumentsStore { return s.store }
var _ storage.Repository = (*stubRepo)(nil)
@@ -28,22 +28,24 @@ type stubDocumentsStore struct {
updateCalls int
}
func (s *stubDocumentsStore) Create(ctx context.Context, record *model.DocumentRecord) error {
func (s *stubDocumentsStore) Create(_ context.Context, record *model.DocumentRecord) error {
s.record = record
return nil
}
func (s *stubDocumentsStore) Update(ctx context.Context, record *model.DocumentRecord) error {
func (s *stubDocumentsStore) Update(_ context.Context, record *model.DocumentRecord) error {
s.record = record
s.updateCalls++
return nil
}
func (s *stubDocumentsStore) GetByPaymentRef(ctx context.Context, paymentRef string) (*model.DocumentRecord, error) {
func (s *stubDocumentsStore) GetByPaymentRef(_ context.Context, _ string) (*model.DocumentRecord, error) {
return s.record, nil
}
func (s *stubDocumentsStore) ListByPaymentRefs(ctx context.Context, paymentRefs []string) ([]*model.DocumentRecord, error) {
func (s *stubDocumentsStore) ListByPaymentRefs(_ context.Context, _ []string) ([]*model.DocumentRecord, error) {
return []*model.DocumentRecord{s.record}, nil
}
@@ -59,19 +61,21 @@ func newMemDocStore() *memDocStore {
return &memDocStore{data: map[string][]byte{}}
}
func (m *memDocStore) Save(ctx context.Context, key string, data []byte) error {
func (m *memDocStore) Save(_ context.Context, key string, data []byte) error {
m.saveCount++
copyData := make([]byte, len(data))
copy(copyData, data)
m.data[key] = copyData
return nil
}
func (m *memDocStore) Load(ctx context.Context, key string) ([]byte, error) {
func (m *memDocStore) Load(_ context.Context, key string) ([]byte, error) {
m.loadCount++
data := m.data[key]
copyData := make([]byte, len(data))
copy(copyData, data)
return copyData, nil
}
@@ -84,8 +88,9 @@ type stubTemplate struct {
calls int
}
func (s *stubTemplate) Render(snapshot model.ActSnapshot) ([]renderer.Block, error) {
func (s *stubTemplate) Render(_ model.ActSnapshot) ([]renderer.Block, error) {
s.calls++
return s.blocks, nil
}
@@ -135,18 +140,23 @@ func TestGetDocument_IdempotentAndHashed(t *testing.T) {
if err != nil {
t.Fatalf("GetDocument first call: %v", err)
}
if len(resp1.Content) == 0 {
if len(resp1.GetContent()) == 0 {
t.Fatalf("expected content on first call")
}
stored := record.Hashes[model.DocumentTypeAct]
if stored == "" {
t.Fatalf("expected stored hash")
}
footerHash := extractFooterHash(resp1.Content)
footerHash := extractFooterHash(resp1.GetContent())
if footerHash == "" {
t.Fatalf("expected footer hash in PDF")
}
if stored != footerHash {
t.Fatalf("stored hash mismatch: got %s", stored)
}
@@ -158,16 +168,19 @@ func TestGetDocument_IdempotentAndHashed(t *testing.T) {
if err != nil {
t.Fatalf("GetDocument second call: %v", err)
}
if !bytes.Equal(resp1.Content, resp2.Content) {
if !bytes.Equal(resp1.GetContent(), resp2.GetContent()) {
t.Fatalf("expected identical PDF bytes on second call")
}
if tmpl.calls != 1 {
t.Fatalf("expected template to be rendered once, got %d", tmpl.calls)
}
if store.saveCount != 1 {
t.Fatalf("expected document save once, got %d", store.saveCount)
}
if store.loadCount == 0 {
t.Fatalf("expected document load on second call")
}
@@ -176,17 +189,23 @@ func TestGetDocument_IdempotentAndHashed(t *testing.T) {
func extractFooterHash(pdf []byte) string {
prefix := []byte("Document integrity hash: ")
idx := bytes.Index(pdf, prefix)
if idx == -1 {
return ""
}
start := idx + len(prefix)
end := start
for end < len(pdf) && isHexDigit(pdf[end]) {
end++
}
if end-start != 64 {
return ""
}
return string(pdf[start:end])
}

View File

@@ -41,6 +41,7 @@ func (r *templateRenderer) Render(snapshot model.ActSnapshot) ([]renderer.Block,
if err := r.tpl.Execute(&buf, snapshot); err != nil {
return nil, fmt.Errorf("execute template: %w", err)
}
return renderer.ParseBlocks(buf.String())
}
@@ -49,6 +50,7 @@ func formatMoney(amount decimal.Decimal, currency string) string {
if currency == "" {
return amount.String()
}
return fmt.Sprintf("%s %s", amount.String(), currency)
}
@@ -56,5 +58,6 @@ func formatDate(t time.Time) string {
if t.IsZero() {
return ""
}
return t.Format("2006-01-02")
}

View File

@@ -2,6 +2,7 @@ package documents
import (
"path/filepath"
"slices"
"testing"
"time"
@@ -12,6 +13,7 @@ import (
func TestTemplateRenderer_Render(t *testing.T) {
path := filepath.Join("..", "..", "..", "templates", "acceptance.tpl")
tmpl, err := newTemplateRenderer(path)
if err != nil {
t.Fatalf("newTemplateRenderer: %v", err)
@@ -29,22 +31,18 @@ func TestTemplateRenderer_Render(t *testing.T) {
if err != nil {
t.Fatalf("Render: %v", err)
}
if len(blocks) == 0 {
t.Fatalf("expected blocks, got none")
}
title := findBlock(blocks, renderer.TagTitle)
if title == nil {
t.Fatalf("expected title block")
}
foundTitle := false
for _, line := range title.Lines {
if line == "ACT OF ACCEPTANCE OF SERVICES" {
foundTitle = true
break
}
}
if !foundTitle {
if !slices.Contains(title.Lines, "ACT OF ACCEPTANCE OF SERVICES") {
t.Fatalf("expected title content not found")
}
@@ -52,13 +50,17 @@ func TestTemplateRenderer_Render(t *testing.T) {
if kv == nil {
t.Fatalf("expected kv block")
}
foundExecutor := false
for _, row := range kv.Rows {
if len(row) >= 2 && row[0] == "Executor" && row[1] == snapshot.ExecutorFullName {
foundExecutor = true
break
}
}
if !foundExecutor {
t.Fatalf("expected executor name in kv block")
}
@@ -67,13 +69,17 @@ func TestTemplateRenderer_Render(t *testing.T) {
if table == nil {
t.Fatalf("expected table block")
}
foundAmount := false
for _, row := range table.Rows {
if len(row) >= 2 && row[1] == "123.45 USD" {
foundAmount = true
break
}
}
if !foundAmount {
t.Fatalf("expected amount in table block")
}
@@ -85,5 +91,6 @@ func findBlock(blocks []renderer.Block, tag renderer.Tag) *renderer.Block {
return &blocks[i]
}
}
return nil
}

View File

@@ -27,6 +27,7 @@ func drawHeader(pdf *gofpdf.Fpdf, issuer Issuer, marginLeft, marginTop float64)
if logoWidth > 0 {
textX = startX + logoWidth + 6
}
pdf.SetXY(textX, startY)
pdf.SetFont("Helvetica", "B", 12)
pdf.CellFormat(0, 5, issuer.LegalName, "", 1, "L", false, 0, "")
@@ -39,6 +40,7 @@ func drawHeader(pdf *gofpdf.Fpdf, issuer Issuer, marginLeft, marginTop float64)
}
currentY := pdf.GetY()
if logoWidth > 0 {
logoBottom := startY + logoWidth
if logoBottom > currentY {

View File

@@ -2,7 +2,6 @@ package renderer
import (
"bytes"
"fmt"
"math"
"strings"
@@ -39,18 +38,22 @@ func (r Renderer) Render(blocks []Block, footerHash string) ([]byte, error) {
pdf.SetFooterFunc(func() {
pdf.SetY(-15)
pdf.SetFont("Helvetica", "", 8)
footer := fmt.Sprintf("Document integrity hash: %s", footerHash)
footer := "Document integrity hash: " + footerHash
pdf.CellFormat(0, 5, footer, "", 0, "L", false, 0, "")
})
pdf.AddPage()
if _, err := drawHeader(pdf, r.Issuer, pageMarginLeft, pageMarginTop); err != nil {
return nil, err
}
pdf.Ln(6)
for _, block := range blocks {
renderBlock(pdf, block)
if pdf.Error() != nil {
return nil, pdf.Error()
}
@@ -60,6 +63,7 @@ func (r Renderer) Render(blocks []Block, footerHash string) ([]byte, error) {
if err := pdf.Output(buf); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
@@ -69,47 +73,64 @@ func renderBlock(pdf *gofpdf.Fpdf, block Block) {
pdf.Ln(6)
case TagTitle:
pdf.SetFont("Helvetica", "B", 14)
for _, line := range block.Lines {
if strings.TrimSpace(line) == "" {
pdf.Ln(4)
continue
}
pdf.CellFormat(0, 7, line, "", 1, "C", false, 0, "")
}
pdf.Ln(2)
case TagSubtitle:
pdf.SetFont("Helvetica", "", 11)
for _, line := range block.Lines {
if strings.TrimSpace(line) == "" {
pdf.Ln(3)
continue
}
pdf.CellFormat(0, 6, line, "", 1, "C", false, 0, "")
}
pdf.Ln(2)
case TagMeta:
pdf.SetFont("Helvetica", "", 9)
for _, line := range block.Lines {
if strings.TrimSpace(line) == "" {
pdf.Ln(2)
continue
}
pdf.CellFormat(0, 4.5, line, "", 1, "R", false, 0, "")
}
pdf.Ln(2)
case TagSection:
pdf.Ln(2)
pdf.SetFont("Helvetica", "B", 11)
for _, line := range block.Lines {
if strings.TrimSpace(line) == "" {
pdf.Ln(3)
continue
}
pdf.CellFormat(0, 6, line, "", 1, "L", false, 0, "")
}
pdf.Ln(1)
case TagText:
pdf.SetFont("Helvetica", "", 10)
text := strings.Join(block.Lines, "\n")
pdf.MultiCell(0, 5, text, "", "L", false)
pdf.Ln(1)
@@ -119,12 +140,14 @@ func renderBlock(pdf *gofpdf.Fpdf, block Block) {
renderTable(pdf, block)
case TagSign:
pdf.SetFont("Helvetica", "", 10)
text := strings.Join(block.Lines, "\n")
pdf.MultiCell(0, 6, text, "", "L", false)
pdf.Ln(2)
default:
// Unknown tag: treat as plain text for resilience.
pdf.SetFont("Helvetica", "", 10)
text := strings.Join(block.Lines, "\n")
pdf.MultiCell(0, 5, text, "", "L", false)
pdf.Ln(1)
@@ -133,6 +156,7 @@ func renderBlock(pdf *gofpdf.Fpdf, block Block) {
func renderKeyValue(pdf *gofpdf.Fpdf, block Block) {
pdf.SetFont("Helvetica", "", 10)
usable := usableWidth(pdf)
keyWidth := math.Round(usable * 0.35)
valueWidth := usable - keyWidth
@@ -142,11 +166,14 @@ func renderKeyValue(pdf *gofpdf.Fpdf, block Block) {
if len(row) == 0 {
continue
}
key := row[0]
value := ""
if len(row) > 1 {
value = row[1]
}
x := pdf.GetX()
y := pdf.GetY()
@@ -162,6 +189,7 @@ func renderKeyValue(pdf *gofpdf.Fpdf, block Block) {
pdf.SetY(maxFloat(leftY, rightY))
}
pdf.Ln(1)
}
@@ -169,6 +197,7 @@ func renderTable(pdf *gofpdf.Fpdf, block Block) {
if len(block.Rows) == 0 {
return
}
usable := usableWidth(pdf)
col1 := math.Round(usable * 0.7)
col2 := usable - col1
@@ -176,9 +205,11 @@ func renderTable(pdf *gofpdf.Fpdf, block Block) {
header := block.Rows[0]
pdf.SetFont("Helvetica", "B", 10)
if len(header) > 0 {
pdf.CellFormat(col1, lineHeight, header[0], "1", 0, "L", false, 0, "")
}
if len(header) > 1 {
pdf.CellFormat(col2, lineHeight, header[1], "1", 1, "R", false, 0, "")
} else {
@@ -186,15 +217,19 @@ func renderTable(pdf *gofpdf.Fpdf, block Block) {
}
pdf.SetFont("Helvetica", "", 10)
for _, row := range block.Rows[1:] {
colA := ""
colB := ""
if len(row) > 0 {
colA = row[0]
}
if len(row) > 1 {
colB = row[1]
}
x := pdf.GetX()
y := pdf.GetY()
pdf.MultiCell(col1, lineHeight, colA, "1", "L", false)
@@ -204,12 +239,14 @@ func renderTable(pdf *gofpdf.Fpdf, block Block) {
rightY := pdf.GetY()
pdf.SetY(maxFloat(leftY, rightY))
}
pdf.Ln(2)
}
func usableWidth(pdf *gofpdf.Fpdf) float64 {
pageW, _ := pdf.GetPageSize()
left, _, right, _ := pdf.GetMargins()
return pageW - left - right
}
@@ -217,5 +254,6 @@ func maxFloat(a, b float64) float64 {
if a > b {
return a
}
return b
}

View File

@@ -26,11 +26,13 @@ func TestRenderer_RenderContainsText(t *testing.T) {
if err != nil {
t.Fatalf("Render: %v", err)
}
if len(pdfBytes) == 0 {
t.Fatalf("expected PDF bytes")
}
checks := []string{"Sendico Ltd", "Jane Doe", "100 USD", "Document integrity hash"}
for _, token := range checks {
if !containsPDFText(pdfBytes, token) {
t.Fatalf("expected PDF to contain %q", token)
@@ -42,22 +44,29 @@ func containsPDFText(pdfBytes []byte, text string) bool {
if bytes.Contains(pdfBytes, []byte(text)) {
return true
}
hexText := hex.EncodeToString([]byte(text))
if bytes.Contains(pdfBytes, []byte(strings.ToUpper(hexText))) {
return true
}
if bytes.Contains(pdfBytes, []byte(strings.ToLower(hexText))) {
return true
}
utf16Bytes := encodeUTF16BE(text, false)
if bytes.Contains(pdfBytes, utf16Bytes) {
return true
}
utf16Hex := hex.EncodeToString(utf16Bytes)
if bytes.Contains(pdfBytes, []byte(strings.ToUpper(utf16Hex))) {
return true
}
if bytes.Contains(pdfBytes, []byte(strings.ToLower(utf16Hex))) {
return true
}
@@ -66,25 +75,33 @@ func containsPDFText(pdfBytes []byte, text string) bool {
if bytes.Contains(pdfBytes, utf16BytesBOM) {
return true
}
utf16HexBOM := hex.EncodeToString(utf16BytesBOM)
if bytes.Contains(pdfBytes, []byte(strings.ToUpper(utf16HexBOM))) {
return true
}
return bytes.Contains(pdfBytes, []byte(strings.ToLower(utf16HexBOM)))
}
func encodeUTF16BE(text string, withBOM bool) []byte {
encoded := utf16.Encode([]rune(text))
length := len(encoded) * 2
if withBOM {
length += 2
}
out := make([]byte, 0, length)
if withBOM {
out = append(out, 0xFE, 0xFF)
}
for _, v := range encoded {
out = append(out, byte(v>>8), byte(v))
}
return out
}

View File

@@ -32,6 +32,7 @@ type Block struct {
func ParseBlocks(input string) ([]Block, error) {
scanner := bufio.NewScanner(strings.NewReader(input))
blocks := make([]Block, 0)
var current *Block
flush := func() {
@@ -44,17 +45,24 @@ func ParseBlocks(input string) ([]Block, error) {
for scanner.Scan() {
line := strings.TrimRight(scanner.Text(), "\r")
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "#") {
flush()
tag := Tag(strings.TrimSpace(strings.TrimPrefix(trimmed, "#")))
if tag == "" {
continue
}
if tag == TagSpacer {
blocks = append(blocks, Block{Tag: TagSpacer})
continue
}
current = &Block{Tag: tag}
continue
}
@@ -62,16 +70,19 @@ func ParseBlocks(input string) ([]Block, error) {
continue
}
switch current.Tag {
switch current.Tag { //nolint:exhaustive // only KV and Table need row parsing
case TagKV, TagTable:
if trimmed == "" {
continue
}
parts := strings.Split(line, "|")
row := make([]string, 0, len(parts))
for _, part := range parts {
row = append(row, strings.TrimSpace(part))
}
current.Rows = append(current.Rows, row)
default:
current.Lines = append(current.Lines, line)
@@ -83,5 +94,6 @@ func ParseBlocks(input string) ([]Block, error) {
}
flush()
return blocks, nil
}

View File

@@ -28,6 +28,7 @@ func DocumentTypeFromProto(t documentsv1.DocumentType) DocumentType {
if name, ok := documentsv1.DocumentType_name[int32(t)]; ok {
return DocumentType(name)
}
return DocumentTypeUnspecified
}
@@ -36,22 +37,24 @@ func (t DocumentType) Proto() documentsv1.DocumentType {
if value, ok := documentsv1.DocumentType_value[string(t)]; ok {
return documentsv1.DocumentType(value)
}
return documentsv1.DocumentType_DOCUMENT_TYPE_UNSPECIFIED
}
// ActSnapshot captures the immutable data needed to generate an acceptance act.
type ActSnapshot struct {
PaymentID string `bson:"paymentId" json:"paymentId"`
Date time.Time `bson:"date" json:"date"`
PaymentID string `bson:"paymentId" json:"paymentId"`
Date time.Time `bson:"date" json:"date"`
ExecutorFullName string `bson:"executorFullName" json:"executorFullName"`
Amount decimal.Decimal `bson:"amount" json:"amount"`
Currency string `bson:"currency" json:"currency"`
Amount decimal.Decimal `bson:"amount" json:"amount"`
Currency string `bson:"currency" json:"currency"`
}
func (s *ActSnapshot) Normalize() {
if s == nil {
return
}
s.PaymentID = strings.TrimSpace(s.PaymentID)
s.ExecutorFullName = strings.TrimSpace(s.ExecutorFullName)
s.Currency = strings.TrimSpace(s.Currency)
@@ -60,21 +63,25 @@ func (s *ActSnapshot) Normalize() {
// DocumentRecord stores document metadata and cached artefacts for a payment.
type DocumentRecord struct {
storable.Base `bson:",inline" json:",inline"`
PaymentRef string `bson:"paymentRef" json:"paymentRef"`
Snapshot ActSnapshot `bson:"snapshot" json:"snapshot"`
StoragePaths map[DocumentType]string `bson:"storagePaths,omitempty" json:"storagePaths,omitempty"`
Hashes map[DocumentType]string `bson:"hashes,omitempty" json:"hashes,omitempty"`
PaymentRef string `bson:"paymentRef" json:"paymentRef"`
Snapshot ActSnapshot `bson:"snapshot" json:"snapshot"`
StoragePaths map[DocumentType]string `bson:"storagePaths,omitempty" json:"storagePaths,omitempty"`
Hashes map[DocumentType]string `bson:"hashes,omitempty" json:"hashes,omitempty"`
}
func (r *DocumentRecord) Normalize() {
if r == nil {
return
}
r.PaymentRef = strings.TrimSpace(r.PaymentRef)
r.Snapshot.Normalize()
if r.StoragePaths == nil {
r.StoragePaths = map[DocumentType]string{}
}
if r.Hashes == nil {
r.Hashes = map[DocumentType]string{}
}

View File

@@ -42,18 +42,22 @@ func New(logger mlogger.Logger, conn *db.MongoConnection) (*Store, error) {
defer cancel()
if err := result.Ping(ctx); err != nil {
result.logger.Error("mongo ping failed during store init", zap.Error(err))
result.logger.Error("Mongo ping failed during store init", zap.Error(err))
return nil, err
}
documentsStore, err := store.NewDocuments(result.logger, database)
if err != nil {
result.logger.Error("failed to initialise documents store", zap.Error(err))
result.logger.Error("Failed to initialise documents store", zap.Error(err))
return nil, err
}
result.documents = documentsStore
result.logger.Info("Billing documents MongoDB storage initialised")
return result, nil
}

View File

@@ -38,13 +38,14 @@ func NewDocuments(logger mlogger.Logger, db *mongo.Database) (*Documents, error)
for _, def := range indexes {
if err := repo.CreateIndex(def); err != nil {
logger.Error("failed to ensure documents index", zap.Error(err), zap.String("collection", repo.Collection()))
logger.Error("Failed to ensure documents index", zap.Error(err), zap.String("collection", repo.Collection()))
return nil, err
}
}
childLogger := logger.Named("documents")
childLogger.Debug("documents store initialised")
childLogger.Debug("Documents store initialised")
return &Documents{
logger: childLogger,
@@ -56,7 +57,9 @@ func (d *Documents) Create(ctx context.Context, record *model.DocumentRecord) er
if record == nil {
return merrors.InvalidArgument("documentsStore: nil record")
}
record.Normalize()
if record.PaymentRef == "" {
return merrors.InvalidArgument("documentsStore: empty paymentRef")
}
@@ -66,9 +69,12 @@ func (d *Documents) Create(ctx context.Context, record *model.DocumentRecord) er
if errors.Is(err, merrors.ErrDataConflict) {
return storage.ErrDuplicateDocument
}
return err
}
d.logger.Debug("document record created", zap.String("payment_ref", record.PaymentRef))
d.logger.Debug("Document record created", zap.String("payment_ref", record.PaymentRef))
return nil
}
@@ -76,17 +82,21 @@ func (d *Documents) Update(ctx context.Context, record *model.DocumentRecord) er
if record == nil {
return merrors.InvalidArgument("documentsStore: nil record")
}
if record.ID.IsZero() {
return merrors.InvalidArgument("documentsStore: missing record id")
}
record.Normalize()
record.Update()
if err := d.repo.Update(ctx, record); err != nil {
if errors.Is(err, merrors.ErrNoData) {
return storage.ErrDocumentNotFound
}
return err
}
return nil
}
@@ -101,8 +111,10 @@ func (d *Documents) GetByPaymentRef(ctx context.Context, paymentRef string) (*mo
if errors.Is(err, merrors.ErrNoData) {
return nil, storage.ErrDocumentNotFound
}
return nil, err
}
return entity, nil
}
@@ -113,26 +125,34 @@ func (d *Documents) ListByPaymentRefs(ctx context.Context, paymentRefs []string)
if clean == "" {
continue
}
refs = append(refs, clean)
}
if len(refs) == 0 {
return []*model.DocumentRecord{}, nil
}
query := repository.Query().Comparison(repository.Field("paymentRef"), builder.In, refs)
records := make([]*model.DocumentRecord, 0)
decoder := func(cur *mongo.Cursor) error {
var rec model.DocumentRecord
if err := cur.Decode(&rec); err != nil {
d.logger.Warn("failed to decode document record", zap.Error(err))
d.logger.Warn("Failed to decode document record", zap.Error(err))
return err
}
records = append(records, &rec)
return nil
}
if err := d.repo.FindManyByFilter(ctx, query, decoder); err != nil {
return nil, err
}
return records, nil
}

View File

@@ -0,0 +1,198 @@
# See the dedicated "version" documentation section.
version: "2"
linters:
# Default set of linters.
# The value can be:
# - `standard`: https://golangci-lint.run/docs/linters/#enabled-by-default
# - `all`: enables all linters by default.
# - `none`: disables all linters by default.
# - `fast`: enables only linters considered as "fast" (`golangci-lint help linters --json | jq '[ .[] | select(.fast==true) ] | map(.name)'`).
# Default: standard
default: all
# Enable specific linter.
enable:
- arangolint
- asasalint
- asciicheck
- bidichk
- bodyclose
- canonicalheader
- containedctx
- contextcheck
- copyloopvar
- cyclop
- decorder
- dogsled
- dupl
- dupword
- durationcheck
- embeddedstructfieldcheck
- err113
- errcheck
- errchkjson
- errname
- errorlint
- exhaustive
- exptostd
- fatcontext
- forbidigo
- forcetypeassert
- funcorder
- funlen
- ginkgolinter
- gocheckcompilerdirectives
- gochecknoglobals
- gochecknoinits
- gochecksumtype
- gocognit
- goconst
- gocritic
- gocyclo
- godoclint
- godot
- godox
- goheader
- gomodguard
- goprintffuncname
- gosec
- gosmopolitan
- govet
- grouper
- iface
- importas
- inamedparam
- ineffassign
- interfacebloat
- intrange
- iotamixing
- ireturn
- lll
- loggercheck
- maintidx
- makezero
- mirror
- misspell
- mnd
- modernize
- musttag
- nakedret
- nestif
- nilerr
- nilnesserr
- nilnil
- nlreturn
- noctx
- noinlineerr
- nolintlint
- nonamedreturns
- nosprintfhostport
- paralleltest
- perfsprint
- prealloc
- predeclared
- promlinter
- protogetter
- reassign
- recvcheck
- revive
- rowserrcheck
- sloglint
- spancheck
- sqlclosecheck
- staticcheck
- tagalign
- tagliatelle
- testableexamples
- testifylint
- testpackage
- thelper
- tparallel
- unconvert
- unparam
- unqueryvet
- unused
- usestdlibvars
- usetesting
- varnamelen
- wastedassign
- whitespace
- wsl_v5
- zerologlint
# Disable specific linters.
disable:
- depguard
- exhaustruct
- gochecknoglobals
- gomoddirectives
- noinlineerr
- wsl
- wrapcheck
# All available settings of specific linters.
# See the dedicated "linters.settings" documentation section.
settings:
wsl_v5:
allow-first-in-block: true
allow-whole-block: false
branch-max-lines: 2
# Defines a set of rules to ignore issues.
# It does not skip the analysis, and so does not ignore "typecheck" errors.
exclusions:
# Mode of the generated files analysis.
#
# - `strict`: sources are excluded by strictly following the Go generated file convention.
# Source files that have lines matching only the following regular expression will be excluded: `^// Code generated .* DO NOT EDIT\.$`
# This line must appear before the first non-comment, non-blank text in the file.
# https://go.dev/s/generatedcode
# - `lax`: sources are excluded if they contain lines like `autogenerated file`, `code generated`, `do not edit`, etc.
# - `disable`: disable the generated files exclusion.
#
# Default: strict
generated: lax
# Log a warning if an exclusion rule is unused.
# Default: false
warn-unused: true
# Predefined exclusion rules.
# Default: []
presets:
- comments
- std-error-handling
- common-false-positives
- legacy
# Excluding configuration per-path, per-linter, per-text and per-source.
rules:
# Exclude some linters from running on tests files.
- path: _test\.go
linters:
- cyclop
- funlen
- gocyclo
- errcheck
- dupl
- gosec
# Run some linter only for test files by excluding its issues for everything else.
- path-except: _test\.go
linters:
- forbidigo
# Exclude known linters from partially hard-vendored code,
# which is impossible to exclude via `nolint` comments.
# `/` will be replaced by the current OS file path separator to properly work on Windows.
- path: internal/hmac/
text: "weak cryptographic primitive"
linters:
- gosec
# Exclude some `staticcheck` messages.
- linters:
- staticcheck
text: "SA9003:"
# Exclude `lll` issues for long lines with `go:generate`.
- linters:
- lll
source: "^//go:generate "
# Which file paths to exclude: they will be analyzed, but issues from them won't be reported.
# "/" will be replaced by the current OS file path separator to properly work on Windows.
# Default: []
paths: []
# Which file paths to not exclude.
# Default: []
paths-except: []

View File

@@ -1,6 +1,6 @@
module github.com/tech/sendico/billing/fees
go 1.25.6
go 1.25.7
replace github.com/tech/sendico/pkg => ../../pkg
@@ -10,7 +10,7 @@ require (
github.com/tech/sendico/fx/oracle v0.0.0
github.com/tech/sendico/pkg v0.1.0
go.uber.org/zap v1.27.1
google.golang.org/grpc v1.78.0
google.golang.org/grpc v1.79.1
gopkg.in/yaml.v3 v3.0.1
)
@@ -25,31 +25,31 @@ require (
github.com/casbin/casbin/v2 v2.135.0 // indirect
github.com/casbin/govaluate v1.10.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/go-chi/chi/v5 v5.2.4 // indirect
github.com/go-chi/chi/v5 v5.2.5 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/compress v1.18.3 // indirect
github.com/klauspost/compress v1.18.4 // 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/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nats-io/nats.go v1.48.0 // indirect
github.com/nats-io/nats.go v1.49.0 // indirect
github.com/nats-io/nkeys v0.4.15 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
github.com/prometheus/client_golang v1.23.2
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/procfs v0.19.2 // indirect
github.com/prometheus/procfs v0.20.0 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.2.0 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
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.47.0 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
google.golang.org/protobuf v1.36.11
)

View File

@@ -38,8 +38,8 @@ 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.4 h1:WtFKPHwlywe8Srng8j2BhOD9312j9cGUxG1SP4V2cR4=
github.com/go-chi/chi/v5 v5.2.4/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
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=
@@ -57,8 +57,8 @@ 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.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.4/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=
@@ -91,8 +91,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
github.com/nats-io/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE=
github.com/nats-io/nats.go v1.49.0/go.mod h1:fDCn3mN5cY8HooHwE2ukiLb4p4G4ImmzvXyJt+tGwdw=
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
@@ -113,8 +113,8 @@ github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNw
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
github.com/prometheus/procfs v0.20.0 h1:AA7aCvjxwAquZAlonN7888f2u4IN8WVeFgBi4k82M4Q=
github.com/prometheus/procfs v0.20.0/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
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=
@@ -152,16 +152,16 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 h1:UP6IpuHFkUgOQL9FFQFrZ+5LiwhhYRbi7VZSIx6Nj5s=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM=
go.opentelemetry.io/otel v1.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.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
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=
@@ -172,15 +172,15 @@ 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.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
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.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
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.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
@@ -191,16 +191,16 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
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.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.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.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
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=
@@ -208,10 +208,10 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b h1:GZxXGdFaHX27ZSMHudWc4FokdD+xl8BC2UJm1OVIEzs=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -24,5 +24,6 @@ func Create() version.Printer {
BuildDate: BuildDate,
Version: Version,
}
return vf.Create(&info)
}

View File

@@ -31,7 +31,8 @@ type Imp struct {
type config struct {
*grpcapp.Config `yaml:",inline"`
Oracle OracleConfig `yaml:"oracle"`
Oracle OracleConfig `yaml:"oracle"`
}
type OracleConfig struct {
@@ -45,6 +46,7 @@ func (c OracleConfig) dialTimeout() time.Duration {
if c.DialTimeoutSecs <= 0 {
return 5 * time.Second
}
return time.Duration(c.DialTimeoutSecs) * time.Second
}
@@ -52,6 +54,7 @@ func (c OracleConfig) callTimeout() time.Duration {
if c.CallTimeoutSecs <= 0 {
return 3 * time.Second
}
return time.Duration(c.CallTimeoutSecs) * time.Second
}
@@ -69,9 +72,11 @@ func (i *Imp) Shutdown() {
if i.service != nil {
i.service.Shutdown()
}
if i.oracleClient != nil {
_ = i.oracleClient.Close()
}
return
}
@@ -98,6 +103,7 @@ func (i *Imp) Start() error {
if err != nil {
return err
}
i.config = cfg
repoFactory := func(logger mlogger.Logger, conn *db.MongoConnection) (storage.Repository, error) {
@@ -105,22 +111,23 @@ func (i *Imp) Start() error {
}
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{
oracleConn, 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))
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))
oracleClient = oracleConn
i.oracleClient = oracleConn
i.logger.Info("Connected to oracle service", zap.String("address", addr))
}
}
@@ -129,13 +136,16 @@ func (i *Imp) Start() error {
if oracleClient != nil {
opts = append(opts, fees.WithOracleClient(oracleClient))
}
if cfg.GRPC != nil {
if invokeURI := cfg.GRPC.DiscoveryInvokeURI(); invokeURI != "" {
opts = append(opts, fees.WithDiscoveryInvokeURI(invokeURI))
}
}
svc := fees.NewService(logger, repo, producer, opts...)
i.service = svc
return svc, nil
}
@@ -143,6 +153,7 @@ func (i *Imp) Start() error {
if err != nil {
return err
}
i.app = app
return i.app.Start()
@@ -152,12 +163,14 @@ 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
}

View File

@@ -3,6 +3,7 @@ package calculator
import (
"context"
"errors"
"maps"
"math/big"
"sort"
"strconv"
@@ -33,6 +34,7 @@ func New(logger mlogger.Logger, oracle fxOracle) *quoteCalculator {
if logger == nil {
logger = zap.NewNop()
}
return &quoteCalculator{
logger: logger.Named("calculator"),
oracle: oracle,
@@ -45,27 +47,10 @@ type quoteCalculator struct {
}
func (c *quoteCalculator) Compute(ctx context.Context, plan *model.FeePlan, intent *feesv1.Intent, bookedAt time.Time, _ *tracev1.TraceContext) (*types.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())
baseAmount, baseScale, trigger, err := validateComputeInputs(plan, intent)
if err != nil {
return nil, merrors.InvalidArgument("invalid base amount")
return nil, err
}
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)
@@ -73,81 +58,37 @@ func (c *quoteCalculator) Compute(ctx context.Context, plan *model.FeePlan, inte
if rules[i].Priority == rules[j].Priority {
return rules[i].RuleID < rules[j].RuleID
}
return rules[i].Priority < rules[j].Priority
})
planID := planIDFrom(plan)
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)
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))
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
}
currency := resolvedCurrency(intent.GetBaseAmount().GetCurrency(), rule.Currency)
entrySide := resolvedEntrySide(rule)
entrySide := mapEntrySide(rule.EntrySide)
if entrySide == accountingv1.EntrySide_ENTRY_SIDE_UNSPECIFIED {
// Default fees to debit (i.e. charge the customer) when entry side is not specified.
entrySide = accountingv1.EntrySide_ENTRY_SIDE_DEBIT
}
meta := map[string]string{
"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),
})
lines = append(lines, buildPostingLine(rule, amount, scale, currency, entrySide, planID))
applied = append(applied, buildAppliedRule(rule, planID))
}
var fxUsed *feesv1.FXUsed
@@ -170,40 +111,24 @@ func (c *quoteCalculator) calculateRuleAmount(baseAmount *big.Rat, baseScale uin
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))
result, err = applyPercentage(result, baseAmount, rule.Percentage)
if err != nil {
return nil, 0, err
}
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)
result, err = applyFixed(result, rule.FixedAmount)
if err != nil {
return nil, 0, err
}
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)
}
result, err = applyMin(result, rule.MinimumAmount)
if err != nil {
return nil, 0, err
}
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)
}
result, err = applyMax(result, rule.MaximumAmount)
if err != nil {
return nil, 0, err
}
if result.Sign() < 0 {
@@ -218,6 +143,66 @@ func (c *quoteCalculator) calculateRuleAmount(baseAmount *big.Rat, baseScale uin
return rounded, scale, nil
}
func applyPercentage(result, baseAmount *big.Rat, percentage string) (*big.Rat, error) {
if strings.TrimSpace(percentage) == "" {
return result, nil
}
percentageRat, err := dmath.RatFromString(percentage)
if err != nil {
return nil, merrors.InvalidArgument("invalid percentage")
}
return dmath.AddRat(result, dmath.MulRat(baseAmount, percentageRat)), nil
}
func applyFixed(result *big.Rat, fixed string) (*big.Rat, error) {
if strings.TrimSpace(fixed) == "" {
return result, nil
}
fixedRat, err := dmath.RatFromString(fixed)
if err != nil {
return nil, merrors.InvalidArgument("invalid fixed amount")
}
return dmath.AddRat(result, fixedRat), nil
}
func applyMin(result *big.Rat, minStr string) (*big.Rat, error) {
if strings.TrimSpace(minStr) == "" {
return result, nil
}
minRat, err := dmath.RatFromString(minStr)
if err != nil {
return nil, merrors.InvalidArgument("invalid minimum amount")
}
if dmath.CmpRat(result, minRat) < 0 {
return new(big.Rat).Set(minRat), nil
}
return result, nil
}
func applyMax(result *big.Rat, maxStr string) (*big.Rat, error) {
if strings.TrimSpace(maxStr) == "" {
return result, nil
}
maxRat, err := dmath.RatFromString(maxStr)
if err != nil {
return nil, merrors.InvalidArgument("invalid maximum amount")
}
if dmath.CmpRat(result, maxRat) > 0 {
return new(big.Rat).Set(maxRat), nil
}
return result, nil
}
const (
attrFxBaseCurrency = "fx_base_currency"
attrFxQuoteCurrency = "fx_quote_currency"
@@ -232,7 +217,9 @@ func (c *quoteCalculator) buildFxUsed(ctx context.Context, intent *feesv1.Intent
}
attrs := intent.GetAttributes()
base := strings.TrimSpace(attrs[attrFxBaseCurrency])
quote := strings.TrimSpace(attrs[attrFxQuoteCurrency])
if base == "" || quote == "" {
return nil
@@ -247,20 +234,26 @@ func (c *quoteCalculator) buildFxUsed(ctx context.Context, intent *feesv1.Intent
Provider: provider,
})
if err != nil {
c.logger.Warn("fees: failed to fetch FX context", zap.Error(err))
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
}
@@ -292,15 +285,19 @@ func inferScale(amount string) uint32 {
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:]))
if _, after, found := strings.Cut(value, "."); found {
return uint32(len(after)) //nolint:gosec // decimal scale; cannot overflow
}
return 0
}
@@ -308,12 +305,15 @@ func shouldApplyRule(rule model.FeeRule, trigger model.Trigger, attributes map[s
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)
}
@@ -325,6 +325,7 @@ func resolveRuleScale(rule model.FeeRule, fallback uint32) (uint32, error) {
}
}
}
return fallback, nil
}
@@ -333,17 +334,115 @@ func parseScale(field, value string) (uint32, error) {
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 validateComputeInputs(plan *model.FeePlan, intent *feesv1.Intent) (*big.Rat, uint32, model.Trigger, error) {
if plan == nil {
return nil, 0, model.TriggerUnspecified, merrors.InvalidArgument("plan is required")
}
if intent == nil {
return nil, 0, model.TriggerUnspecified, merrors.InvalidArgument("intent is required")
}
trigger := convertTrigger(intent.GetTrigger())
if trigger == model.TriggerUnspecified {
return nil, 0, model.TriggerUnspecified, merrors.InvalidArgument("unsupported trigger")
}
baseAmount, err := dmath.RatFromString(intent.GetBaseAmount().GetAmount())
if err != nil {
return nil, 0, trigger, merrors.InvalidArgument("invalid base amount")
}
if baseAmount.Sign() < 0 {
return nil, 0, trigger, merrors.InvalidArgument("base amount cannot be negative")
}
return baseAmount, inferScale(intent.GetBaseAmount().GetAmount()), trigger, nil
}
func planIDFrom(plan *model.FeePlan) string {
if planRef := plan.GetID(); planRef != nil && !planRef.IsZero() {
return planRef.Hex()
}
return ""
}
func resolvedCurrency(baseCurrency, ruleCurrency string) string {
if override := strings.TrimSpace(ruleCurrency); override != "" {
return override
}
return baseCurrency
}
func resolvedEntrySide(rule model.FeeRule) accountingv1.EntrySide {
// Default fees to debit (i.e. charge the customer) when entry side is not specified.
if side := mapEntrySide(rule.EntrySide); side != accountingv1.EntrySide_ENTRY_SIDE_UNSPECIFIED {
return side
}
return accountingv1.EntrySide_ENTRY_SIDE_DEBIT
}
func buildPostingLine(rule model.FeeRule, amount *big.Rat, scale uint32, currency string, entrySide accountingv1.EntrySide, planID string) *feesv1.DerivedPostingLine {
return &feesv1.DerivedPostingLine{
LedgerAccountRef: strings.TrimSpace(rule.LedgerAccountRef),
Money: &moneyv1.Money{
Amount: dmath.FormatRat(amount, scale),
Currency: currency,
},
LineType: mapLineType(rule.LineType),
Side: entrySide,
Meta: buildRuleMeta(rule, planID),
}
}
func buildAppliedRule(rule model.FeeRule, planID string) *feesv1.AppliedRule {
return &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),
}
}
func buildRuleMeta(rule model.FeeRule, planID string) map[string]string {
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
}
}
return meta
}
func metadataValue(meta map[string]string, key string) string {
if meta == nil {
return ""
}
return strings.TrimSpace(meta[key])
}
@@ -351,10 +450,10 @@ 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
}
maps.Copy(cloned, src)
return cloned
}
@@ -362,18 +461,22 @@ func ruleMatchesAttributes(rule model.FeeRule, attributes map[string]string) boo
if len(rule.AppliesTo) == 0 {
return true
}
for key, value := range rule.AppliesTo {
if attributes == nil {
return false
}
attrValue, ok := attributes[key]
if !ok {
return false
}
if !matchesAttributeValue(value, attrValue) {
return false
}
}
return true
}
@@ -383,16 +486,17 @@ func matchesAttributeValue(expected, actual string) bool {
return actual == ""
}
values := strings.Split(trimmed, ",")
for _, value := range values {
for value := range strings.SplitSeq(trimmed, ",") {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if value == "*" || value == actual {
return true
}
}
return false
}
@@ -446,6 +550,8 @@ func mapRoundingMode(mode string) moneyv1.RoundingMode {
func convertTrigger(trigger feesv1.Trigger) model.Trigger {
switch trigger {
case feesv1.Trigger_TRIGGER_UNSPECIFIED:
return model.TriggerUnspecified
case feesv1.Trigger_TRIGGER_CAPTURE:
return model.TriggerCapture
case feesv1.Trigger_TRIGGER_REFUND:

View File

@@ -9,6 +9,7 @@ import (
"github.com/tech/sendico/billing/fees/storage"
"github.com/tech/sendico/billing/fees/storage/model"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mutil/mzap"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
@@ -22,17 +23,19 @@ type planFinder interface {
type feeResolver struct {
plans storage.PlansStore
finder planFinder
logger *zap.Logger
logger mlogger.Logger
}
func New(plans storage.PlansStore, logger *zap.Logger) *feeResolver {
func New(plans storage.PlansStore, logger mlogger.Logger) *feeResolver {
var finder planFinder
if pf, ok := plans.(planFinder); ok {
finder = pf
}
if logger == nil {
logger = zap.NewNop()
}
return &feeResolver{
plans: plans,
finder: finder,
@@ -40,63 +43,100 @@ func New(plans storage.PlansStore, logger *zap.Logger) *feeResolver {
}
}
func (r *feeResolver) ResolveFeeRule(ctx context.Context, orgRef *bson.ObjectID, trigger model.Trigger, at time.Time, attrs map[string]string) (*model.FeePlan, *model.FeeRule, error) {
func (r *feeResolver) ResolveFeeRule(ctx context.Context, orgRef *bson.ObjectID, trigger model.Trigger, asOf time.Time, attrs map[string]string) (*model.FeePlan, *model.FeeRule, error) {
if r.plans == nil {
return nil, nil, merrors.InvalidArgument("fees: plans store is required")
}
// Try org-specific first if provided.
if orgRef != nil && !orgRef.IsZero() {
if plan, err := r.getOrgPlan(ctx, *orgRef, at); err == nil {
if rule, selErr := selectRule(plan, trigger, at, attrs); selErr == nil {
return plan, rule, nil
} else if !errors.Is(selErr, ErrNoFeeRuleFound) {
r.logger.Warn("Failed selecting rule for org plan", zap.Error(selErr), mzap.ObjRef("org_ref", *orgRef))
return nil, nil, selErr
}
orgFields := []zap.Field{
mzap.ObjRef("org_ref", *orgRef),
zap.String("trigger", string(trigger)),
zap.Time("booked_at", at),
zap.Any("attributes", attrs),
}
orgFields = append(orgFields, zapFieldsForPlan(plan)...)
r.logger.Debug("No matching rule in org plan; falling back to global", orgFields...)
} else if !errors.Is(err, storage.ErrFeePlanNotFound) {
r.logger.Warn("Failed resolving org fee plan", zap.Error(err), mzap.ObjRef("org_ref", *orgRef))
if isOrgRef(orgRef) {
plan, rule, err := r.tryOrgRule(ctx, *orgRef, trigger, asOf, attrs)
if err != nil {
return nil, nil, err
}
if rule != nil {
return plan, rule, nil
}
}
plan, err := r.getGlobalPlan(ctx, at)
plan, err := r.getGlobalPlan(ctx, asOf)
if err != nil {
if errors.Is(err, storage.ErrFeePlanNotFound) {
r.logger.Debug("No applicable global fee plan found", zap.String("trigger", string(trigger)),
zap.Time("booked_at", at), zap.Any("attributes", attrs),
zap.Time("booked_at", asOf), zap.Any("attributes", attrs),
)
return nil, nil, merrors.NoData("fees: no applicable fee rule found")
}
r.logger.Warn("Failed resolving global fee plan", zap.Error(err))
return nil, nil, err
}
rule, err := selectRule(plan, trigger, at, attrs)
rule, err := selectRule(plan, trigger, asOf, attrs)
if err != nil {
if !errors.Is(err, ErrNoFeeRuleFound) {
r.logger.Warn("Failed selecting rule in global plan", zap.Error(err))
} else {
globalFields := []zap.Field{
globalFields := zapFieldsForPlan(plan)
globalFields = append([]zap.Field{
zap.String("trigger", string(trigger)),
zap.Time("booked_at", at),
zap.Time("booked_at", asOf),
zap.Any("attributes", attrs),
}
globalFields = append(globalFields, zapFieldsForPlan(plan)...)
}, globalFields...)
r.logger.Debug("No matching rule in global plan", globalFields...)
}
return nil, nil, err
}
selectedFields := []zap.Field{
r.logSelectedRule(orgRef, trigger, asOf, attrs, rule, plan)
return plan, rule, nil
}
// tryOrgRule attempts to find a matching rule in the org-specific plan.
// Returns (plan, rule, nil) on success, (nil, nil, nil) to signal fallthrough to global,
// or (nil, nil, err) on a hard error.
func (r *feeResolver) tryOrgRule(ctx context.Context, orgRef bson.ObjectID, trigger model.Trigger, asOf time.Time, attrs map[string]string) (*model.FeePlan, *model.FeeRule, error) {
plan, err := r.getOrgPlan(ctx, orgRef, asOf)
if err != nil {
if errors.Is(err, storage.ErrFeePlanNotFound) {
return nil, nil, nil
}
r.logger.Warn("Failed resolving org fee plan", zap.Error(err), mzap.ObjRef("org_ref", orgRef))
return nil, nil, err
}
rule, selErr := selectRule(plan, trigger, asOf, attrs)
if selErr == nil {
return plan, rule, nil
}
if !errors.Is(selErr, ErrNoFeeRuleFound) {
r.logger.Warn("Failed selecting rule for org plan", zap.Error(selErr), mzap.ObjRef("org_ref", orgRef))
return nil, nil, selErr
}
orgFields := zapFieldsForPlan(plan)
orgFields = append([]zap.Field{
mzap.ObjRef("org_ref", orgRef),
zap.String("trigger", string(trigger)),
zap.Time("booked_at", asOf),
zap.Any("attributes", attrs),
}, orgFields...)
r.logger.Debug("No matching rule in org plan; falling back to global", orgFields...)
return nil, nil, nil
}
func (r *feeResolver) logSelectedRule(orgRef *bson.ObjectID, trigger model.Trigger, at time.Time, attrs map[string]string, rule *model.FeeRule, plan *model.FeePlan) {
fields := []zap.Field{
zap.String("trigger", string(trigger)),
zap.Time("booked_at", at),
zap.Any("attributes", attrs),
@@ -106,59 +146,65 @@ func (r *feeResolver) ResolveFeeRule(ctx context.Context, orgRef *bson.ObjectID,
zap.Time("rule_effective_from", rule.EffectiveFrom),
}
if rule.EffectiveTo != nil {
selectedFields = append(selectedFields, zap.Time("rule_effective_to", *rule.EffectiveTo))
fields = append(fields, zap.Time("rule_effective_to", *rule.EffectiveTo))
}
if orgRef != nil && !orgRef.IsZero() {
selectedFields = append(selectedFields, mzap.ObjRef("org_ref", *orgRef))
}
selectedFields = append(selectedFields, zapFieldsForPlan(plan)...)
r.logger.Debug("Selected fee rule", selectedFields...)
return plan, rule, nil
if isOrgRef(orgRef) {
fields = append(fields, mzap.ObjRef("org_ref", *orgRef))
}
fields = append(fields, zapFieldsForPlan(plan)...)
r.logger.Debug("Selected fee rule", fields...)
}
func isOrgRef(ref *bson.ObjectID) bool {
return ref != nil && !ref.IsZero()
}
func (r *feeResolver) getOrgPlan(ctx context.Context, orgRef bson.ObjectID, at time.Time) (*model.FeePlan, error) {
if r.finder != nil {
return r.finder.FindActiveOrgPlan(ctx, orgRef, at)
}
return r.plans.GetActivePlan(ctx, orgRef, at)
}
func (r *feeResolver) getGlobalPlan(ctx context.Context, at time.Time) (*model.FeePlan, error) {
func (r *feeResolver) getGlobalPlan(ctx context.Context, asOf time.Time) (*model.FeePlan, error) {
if r.finder != nil {
return r.finder.FindActiveGlobalPlan(ctx, at)
return r.finder.FindActiveGlobalPlan(ctx, asOf)
}
// Treat zero ObjectID as global in legacy path.
return r.plans.GetActivePlan(ctx, bson.NilObjectID, at)
return r.plans.GetActivePlan(ctx, bson.NilObjectID, asOf)
}
func selectRule(plan *model.FeePlan, trigger model.Trigger, at time.Time, attrs map[string]string) (*model.FeeRule, error) {
func selectRule(plan *model.FeePlan, trigger model.Trigger, asOf time.Time, attrs map[string]string) (*model.FeeRule, error) {
if plan == nil {
return nil, merrors.NoData("fees: no applicable fee rule found")
}
var selected *model.FeeRule
var highestPriority int
var (
selected *model.FeeRule
highestPriority int
)
for _, rule := range plan.Rules {
if rule.Trigger != trigger {
continue
}
if rule.EffectiveFrom.After(at) {
continue
}
if rule.EffectiveTo != nil && !rule.EffectiveTo.After(at) {
if !ruleIsActive(rule, trigger, asOf) {
continue
}
if !matchesAppliesTo(rule.AppliesTo, attrs) {
continue
}
if selected == nil || rule.Priority > highestPriority {
copy := rule
selected = &copy
matched := rule
selected = &matched
highestPriority = rule.Priority
continue
}
if rule.Priority == highestPriority {
return nil, merrors.DataConflict("fees: conflicting fee rules")
}
@@ -167,25 +213,46 @@ func selectRule(plan *model.FeePlan, trigger model.Trigger, at time.Time, attrs
if selected == nil {
return nil, merrors.NoData("fees: no applicable fee rule found")
}
return selected, nil
}
func ruleIsActive(rule model.FeeRule, trigger model.Trigger, asOf time.Time) bool {
if rule.Trigger != trigger {
return false
}
if rule.EffectiveFrom.After(asOf) {
return false
}
if rule.EffectiveTo != nil && !rule.EffectiveTo.After(asOf) {
return false
}
return true
}
func matchesAppliesTo(appliesTo map[string]string, attrs map[string]string) bool {
if len(appliesTo) == 0 {
return true
}
for key, value := range appliesTo {
if attrs == nil {
return false
}
attrValue, ok := attrs[key]
if !ok {
return false
}
if !matchesAppliesValue(value, attrValue) {
return false
}
}
return true
}
@@ -195,16 +262,17 @@ func matchesAppliesValue(expected, actual string) bool {
return actual == ""
}
values := strings.Split(trimmed, ",")
for _, value := range values {
for value := range strings.SplitSeq(trimmed, ",") {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if value == "*" || value == actual {
return true
}
}
return false
}
@@ -212,6 +280,7 @@ func zapFieldsForPlan(plan *model.FeePlan) []zap.Field {
if plan == nil {
return []zap.Field{zap.Bool("plan_present", false)}
}
fields := []zap.Field{
zap.Bool("plan_present", true),
zap.Bool("plan_active", plan.Active),
@@ -223,13 +292,16 @@ func zapFieldsForPlan(plan *model.FeePlan) []zap.Field {
} else {
fields = append(fields, zap.Bool("plan_effective_to_set", false))
}
if plan.OrganizationRef != nil && !plan.OrganizationRef.IsZero() {
fields = append(fields, mzap.ObjRef("plan_org_ref", *plan.OrganizationRef))
} else {
fields = append(fields, zap.Bool("plan_org_ref_set", false))
}
if plan.GetID() != nil && !plan.GetID().IsZero() {
fields = append(fields, mzap.StorableRef(plan))
}
return fields
}

View File

@@ -13,7 +13,7 @@ import (
)
func TestResolver_GlobalFallbackWhenOrgMissing(t *testing.T) {
t.Helper()
t.Parallel()
now := time.Now()
globalPlan := &model.FeePlan{
@@ -28,20 +28,23 @@ func TestResolver_GlobalFallbackWhenOrgMissing(t *testing.T) {
resolver := New(store, zap.NewNop())
orgA := bson.NewObjectID()
plan, rule, err := resolver.ResolveFeeRule(context.Background(), &orgA, model.TriggerCapture, now, nil)
if err != nil {
t.Fatalf("expected fallback to global, got error: %v", err)
}
if plan.OrganizationRef != nil && !plan.OrganizationRef.IsZero() {
t.Fatalf("expected global plan, got orgRef %s", plan.OrganizationRef.Hex())
}
if rule.RuleID != "global_capture" {
t.Fatalf("unexpected rule selected: %s", rule.RuleID)
}
}
func TestResolver_OrgOverridesGlobal(t *testing.T) {
t.Helper()
t.Parallel()
now := time.Now()
org := bson.NewObjectID()
@@ -67,22 +70,25 @@ func TestResolver_OrgOverridesGlobal(t *testing.T) {
if err != nil {
t.Fatalf("expected org plan rule, got error: %v", err)
}
if rule.RuleID != "org_capture" {
t.Fatalf("expected org rule, got %s", rule.RuleID)
}
otherOrg := bson.NewObjectID()
_, rule, err = resolver.ResolveFeeRule(context.Background(), &otherOrg, model.TriggerCapture, now, nil)
if err != nil {
t.Fatalf("expected global fallback for other org, got error: %v", err)
}
if rule.RuleID != "global_capture" {
t.Fatalf("expected global rule, got %s", rule.RuleID)
}
}
func TestResolver_SelectsHighestPriority(t *testing.T) {
t.Helper()
t.Parallel()
now := time.Now()
org := bson.NewObjectID()
@@ -103,6 +109,7 @@ func TestResolver_SelectsHighestPriority(t *testing.T) {
if err != nil {
t.Fatalf("expected rule resolution, got error: %v", err)
}
if rule.RuleID != "high" {
t.Fatalf("expected highest priority rule, got %s", rule.RuleID)
}
@@ -121,7 +128,7 @@ func TestResolver_SelectsHighestPriority(t *testing.T) {
}
func TestResolver_EffectiveDateFiltering(t *testing.T) {
t.Helper()
t.Parallel()
now := time.Now()
org := bson.NewObjectID()
@@ -152,13 +159,14 @@ func TestResolver_EffectiveDateFiltering(t *testing.T) {
if err != nil {
t.Fatalf("expected fallback to global, got error: %v", err)
}
if rule.RuleID != "current" {
t.Fatalf("expected current global rule, got %s", rule.RuleID)
}
}
func TestResolver_AppliesToFiltering(t *testing.T) {
t.Helper()
t.Parallel()
now := time.Now()
plan := &model.FeePlan{
@@ -176,6 +184,7 @@ func TestResolver_AppliesToFiltering(t *testing.T) {
if err != nil {
t.Fatalf("expected card rule, got error: %v", err)
}
if rule.RuleID != "card" {
t.Fatalf("expected card rule, got %s", rule.RuleID)
}
@@ -184,13 +193,14 @@ func TestResolver_AppliesToFiltering(t *testing.T) {
if err != nil {
t.Fatalf("expected default rule, got error: %v", err)
}
if rule.RuleID != "default" {
t.Fatalf("expected default rule, got %s", rule.RuleID)
}
}
func TestResolver_AppliesToFilteringSupportsListsAndWildcard(t *testing.T) {
t.Helper()
t.Parallel()
now := time.Now()
plan := &model.FeePlan{
@@ -209,6 +219,7 @@ func TestResolver_AppliesToFilteringSupportsListsAndWildcard(t *testing.T) {
if err != nil {
t.Fatalf("expected list match rule, got error: %v", err)
}
if rule.RuleID != "network_multi" {
t.Fatalf("expected network list rule, got %s", rule.RuleID)
}
@@ -217,6 +228,7 @@ func TestResolver_AppliesToFilteringSupportsListsAndWildcard(t *testing.T) {
if err != nil {
t.Fatalf("expected wildcard rule, got error: %v", err)
}
if rule.RuleID != "asset_any" {
t.Fatalf("expected asset wildcard rule, got %s", rule.RuleID)
}
@@ -225,13 +237,14 @@ func TestResolver_AppliesToFilteringSupportsListsAndWildcard(t *testing.T) {
if err != nil {
t.Fatalf("expected default rule, got error: %v", err)
}
if rule.RuleID != "default" {
t.Fatalf("expected default rule, got %s", rule.RuleID)
}
}
func TestResolver_MissingTriggerReturnsErr(t *testing.T) {
t.Helper()
t.Parallel()
now := time.Now()
plan := &model.FeePlan{
@@ -250,28 +263,28 @@ func TestResolver_MissingTriggerReturnsErr(t *testing.T) {
}
func TestResolver_MultipleActivePlansConflict(t *testing.T) {
t.Helper()
t.Parallel()
now := time.Now()
org := bson.NewObjectID()
p1 := &model.FeePlan{
plan1 := &model.FeePlan{
Active: true,
EffectiveFrom: now.Add(-time.Hour),
Rules: []model.FeeRule{
{RuleID: "r1", Trigger: model.TriggerCapture, Priority: 10, Percentage: "0.05", EffectiveFrom: now.Add(-time.Hour)},
},
}
p1.OrganizationRef = &org
p2 := &model.FeePlan{
plan1.OrganizationRef = &org
plan2 := &model.FeePlan{
Active: true,
EffectiveFrom: now.Add(-30 * time.Minute),
Rules: []model.FeeRule{
{RuleID: "r2", Trigger: model.TriggerCapture, Priority: 20, Percentage: "0.03", EffectiveFrom: now.Add(-time.Hour)},
},
}
p2.OrganizationRef = &org
plan2.OrganizationRef = &org
store := &memoryPlansStore{plans: []*model.FeePlan{p1, p2}}
store := &memoryPlansStore{plans: []*model.FeePlan{plan1, plan2}}
resolver := New(store, zap.NewNop())
if _, _, err := resolver.ResolveFeeRule(context.Background(), &org, model.TriggerCapture, now, nil); !errors.Is(err, storage.ErrConflictingFeePlans) {
@@ -289,66 +302,83 @@ func (m *memoryPlansStore) Get(context.Context, bson.ObjectID) (*model.FeePlan,
return nil, storage.ErrFeePlanNotFound
}
func (m *memoryPlansStore) GetActivePlan(ctx context.Context, orgRef bson.ObjectID, at time.Time) (*model.FeePlan, error) {
func (m *memoryPlansStore) GetActivePlan(ctx context.Context, orgRef bson.ObjectID, asOf time.Time) (*model.FeePlan, error) {
if !orgRef.IsZero() {
if plan, err := m.FindActiveOrgPlan(ctx, orgRef, at); err == nil {
if plan, err := m.FindActiveOrgPlan(ctx, orgRef, asOf); err == nil {
return plan, nil
} else if !errors.Is(err, storage.ErrFeePlanNotFound) {
return nil, err
}
}
return m.FindActiveGlobalPlan(ctx, at)
return m.FindActiveGlobalPlan(ctx, asOf)
}
func (m *memoryPlansStore) FindActiveOrgPlan(_ context.Context, orgRef bson.ObjectID, at time.Time) (*model.FeePlan, error) {
func (m *memoryPlansStore) FindActiveOrgPlan(_ context.Context, orgRef bson.ObjectID, asOf time.Time) (*model.FeePlan, error) {
var matches []*model.FeePlan
for _, plan := range m.plans {
if plan == nil || plan.OrganizationRef == nil || plan.OrganizationRef.IsZero() || (*plan.OrganizationRef != orgRef) {
continue
}
if !plan.Active {
continue
}
if plan.EffectiveFrom.After(at) {
if plan.EffectiveFrom.After(asOf) {
continue
}
if plan.EffectiveTo != nil && !plan.EffectiveTo.After(at) {
if plan.EffectiveTo != nil && !plan.EffectiveTo.After(asOf) {
continue
}
matches = append(matches, plan)
}
if len(matches) == 0 {
return nil, storage.ErrFeePlanNotFound
}
if len(matches) > 1 {
return nil, storage.ErrConflictingFeePlans
}
return matches[0], nil
}
func (m *memoryPlansStore) FindActiveGlobalPlan(_ context.Context, at time.Time) (*model.FeePlan, error) {
func (m *memoryPlansStore) FindActiveGlobalPlan(_ context.Context, asOf time.Time) (*model.FeePlan, error) {
var matches []*model.FeePlan
for _, plan := range m.plans {
if plan == nil || ((plan.OrganizationRef != nil) && !plan.OrganizationRef.IsZero()) {
continue
}
if !plan.Active {
continue
}
if plan.EffectiveFrom.After(at) {
if plan.EffectiveFrom.After(asOf) {
continue
}
if plan.EffectiveTo != nil && !plan.EffectiveTo.After(at) {
if plan.EffectiveTo != nil && !plan.EffectiveTo.After(asOf) {
continue
}
matches = append(matches, plan)
}
if len(matches) == 0 {
return nil, storage.ErrFeePlanNotFound
}
if len(matches) > 1 {
return nil, storage.ErrConflictingFeePlans
}
return matches[0], nil
}

View File

@@ -12,6 +12,7 @@ import (
func requestLogFields(meta *feesv1.RequestMeta, intent *feesv1.Intent) []zap.Field {
fields := logFieldsFromRequestMeta(meta)
fields = append(fields, logFieldsFromIntent(intent)...)
return fields
}
@@ -19,11 +20,14 @@ func logFieldsFromRequestMeta(meta *feesv1.RequestMeta) []zap.Field {
if meta == nil {
return nil
}
fields := make([]zap.Field, 0, 4)
if org := strings.TrimSpace(meta.GetOrganizationRef()); org != "" {
fields = append(fields, zap.String("organization_ref", org))
}
fields = append(fields, logFieldsFromTrace(meta.GetTrace())...)
return fields
}
@@ -31,24 +35,30 @@ func logFieldsFromIntent(intent *feesv1.Intent) []zap.Field {
if intent == nil {
return nil
}
fields := make([]zap.Field, 0, 5)
if trigger := intent.GetTrigger(); trigger != feesv1.Trigger_TRIGGER_UNSPECIFIED {
fields = append(fields, zap.String("trigger", trigger.String()))
}
if base := intent.GetBaseAmount(); base != nil {
if amount := strings.TrimSpace(base.GetAmount()); amount != "" {
fields = append(fields, zap.String("base_amount", amount))
}
if currency := strings.TrimSpace(base.GetCurrency()); currency != "" {
fields = append(fields, zap.String("base_currency", currency))
}
}
if booked := intent.GetBookedAt(); booked != nil && booked.IsValid() {
fields = append(fields, zap.Time("booked_at", booked.AsTime()))
}
if attrs := intent.GetAttributes(); len(attrs) > 0 {
fields = append(fields, zap.Int("attributes_count", len(attrs)))
}
return fields
}
@@ -56,16 +66,20 @@ func logFieldsFromTrace(trace *tracev1.TraceContext) []zap.Field {
if trace == nil {
return nil
}
fields := make([]zap.Field, 0, 3)
if reqRef := strings.TrimSpace(trace.GetRequestRef()); reqRef != "" {
fields = append(fields, zap.String("request_ref", reqRef))
}
if idem := strings.TrimSpace(trace.GetIdempotencyKey()); idem != "" {
fields = append(fields, zap.String("idempotency_key", idem))
}
if traceRef := strings.TrimSpace(trace.GetTraceRef()); traceRef != "" {
fields = append(fields, zap.String("trace_ref", traceRef))
}
return fields
}
@@ -73,16 +87,20 @@ func logFieldsFromTokenPayload(payload *feeQuoteTokenPayload) []zap.Field {
if payload == nil {
return nil
}
fields := make([]zap.Field, 0, 6)
if org := strings.TrimSpace(payload.OrganizationRef); org != "" {
fields = append(fields, zap.String("organization_ref", org))
}
if payload.ExpiresAtUnixMs > 0 {
fields = append(fields,
zap.Int64("expires_at_unix_ms", payload.ExpiresAtUnixMs),
zap.Time("expires_at", time.UnixMilli(payload.ExpiresAtUnixMs)))
}
fields = append(fields, logFieldsFromIntent(payload.Intent)...)
fields = append(fields, logFieldsFromTrace(payload.Trace)...)
return fields
}

View File

@@ -50,6 +50,7 @@ func observeMetrics(call string, trigger feesv1.Trigger, statusLabel string, fxU
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())
@@ -59,13 +60,16 @@ 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

@@ -5,7 +5,6 @@ import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
@@ -33,6 +32,8 @@ import (
)
type Service struct {
feesv1.UnimplementedFeeEngineServer
logger mlogger.Logger
storage storage.Repository
producer msg.Producer
@@ -42,7 +43,6 @@ type Service struct {
resolver FeeResolver
announcer *discovery.Announcer
invokeURI string
feesv1.UnimplementedFeeEngineServer
}
func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Producer, opts ...Option) *Service {
@@ -52,6 +52,7 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro
producer: producer,
clock: clockpkg.NewSystem(),
}
initMetrics()
for _, opt := range opts {
@@ -61,9 +62,11 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro
if svc.clock == nil {
svc.clock = clockpkg.NewSystem()
}
if svc.calculator == nil {
svc.calculator = internalcalculator.New(svc.logger, svc.oracle)
}
if svc.resolver == nil {
svc.resolver = resolver.New(repo.Plans(), svc.logger)
}
@@ -83,25 +86,12 @@ func (s *Service) Shutdown() {
if s == nil {
return
}
if s.announcer != nil {
s.announcer.Stop()
}
}
func (s *Service) startDiscoveryAnnouncer() {
if s == nil || s.producer == nil {
return
}
announce := discovery.Announcement{
Service: "BILLING_FEES",
Operations: []string{"fee.calc"},
InvokeURI: s.invokeURI,
Version: appversion.Create().Short(),
}
s.announcer = discovery.NewAnnouncer(s.logger, s.producer, string(mservice.FeePlans), announce)
s.announcer.Start()
}
func (s *Service) QuoteFees(ctx context.Context, req *feesv1.QuoteFeesRequest) (resp *feesv1.QuoteFeesResponse, err error) {
var (
meta *feesv1.RequestMeta
@@ -111,23 +101,29 @@ func (s *Service) QuoteFees(ctx context.Context, req *feesv1.QuoteFeesRequest) (
meta = req.GetMeta()
intent = req.GetIntent()
}
logger := s.logger.With(requestLogFields(meta, intent)...)
start := s.clock.Now()
trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED
if intent != nil {
trigger = intent.GetTrigger()
}
var fxUsed bool
defer func() {
statusLabel := statusFromError(err)
linesCount := 0
appliedCount := 0
if err == nil && resp != nil {
fxUsed = resp.GetFxUsed() != nil
linesCount = len(resp.GetLines())
appliedCount = len(resp.GetApplied())
}
observeMetrics("quote", trigger, statusLabel, fxUsed, time.Since(start))
logFields := []zap.Field{
@@ -140,8 +136,10 @@ func (s *Service) QuoteFees(ctx context.Context, req *feesv1.QuoteFeesRequest) (
}
if err != nil {
logger.Warn("QuoteFees finished", append(logFields, zap.Error(err))...)
return
}
logger.Info("QuoteFees finished", logFields...)
}()
@@ -155,12 +153,14 @@ func (s *Service) QuoteFees(ctx context.Context, req *feesv1.QuoteFeesRequest) (
if parseErr != nil {
logger.Warn("QuoteFees invalid organization_ref", zap.Error(parseErr))
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())
lines, applied, fxResult, computeErr := s.computeQuote(ctx, orgRef, req.GetIntent(), req.GetPolicy(), req.GetMeta().GetTrace())
if computeErr != nil {
err = computeErr
return nil, err
}
@@ -168,8 +168,9 @@ func (s *Service) QuoteFees(ctx context.Context, req *feesv1.QuoteFeesRequest) (
Meta: &feesv1.ResponseMeta{Trace: req.GetMeta().GetTrace()},
Lines: lines,
Applied: applied,
FxUsed: fx,
FxUsed: fxResult,
}
return resp, nil
}
@@ -182,48 +183,17 @@ func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFees
meta = req.GetMeta()
intent = req.GetIntent()
}
logger := s.logger.With(requestLogFields(meta, intent)...)
start := s.clock.Now()
trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED
if intent != nil {
trigger = intent.GetTrigger()
}
var (
fxUsed bool
expiresAt time.Time
)
defer func() {
statusLabel := statusFromError(err)
linesCount := 0
appliedCount := 0
if err == nil && resp != nil {
fxUsed = resp.GetFxUsed() != nil
linesCount = len(resp.GetLines())
appliedCount = len(resp.GetApplied())
if ts := resp.GetExpiresAt(); ts != nil {
expiresAt = ts.AsTime()
}
}
observeMetrics("precompute", trigger, statusLabel, fxUsed, time.Since(start))
logFields := []zap.Field{
zap.String("status", statusLabel),
zap.Duration("duration", time.Since(start)),
zap.Bool("fx_used", fxUsed),
zap.String("trigger", trigger.String()),
zap.Int("lines", linesCount),
zap.Int("applied_rules", appliedCount),
}
if !expiresAt.IsZero() {
logFields = append(logFields, zap.Time("expires_at", expiresAt))
}
if err != nil {
logger.Warn("PrecomputeFees finished", append(logFields, zap.Error(err))...)
return
}
logger.Info("PrecomputeFees finished", logFields...)
}()
defer func() { s.observePrecomputeFees(logger, err, resp, trigger, start) }()
logger.Debug("PrecomputeFees request received")
@@ -237,12 +207,14 @@ func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFees
if parseErr != nil {
logger.Warn("PrecomputeFees invalid organization_ref", zap.Error(parseErr))
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)
lines, applied, fxResult, computeErr := s.computeQuoteWithTime(ctx, orgRef, req.GetIntent(), nil, req.GetMeta().GetTrace(), now)
if computeErr != nil {
err = computeErr
return nil, err
}
@@ -250,7 +222,8 @@ func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFees
if ttl <= 0 {
ttl = 60000
}
expiresAt = now.Add(time.Duration(ttl) * time.Millisecond)
expiresAt := now.Add(time.Duration(ttl) * time.Millisecond)
payload := feeQuoteTokenPayload{
OrganizationRef: req.GetMeta().GetOrganizationRef(),
@@ -261,8 +234,9 @@ func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFees
var token string
if token, err = encodeTokenPayload(payload); err != nil {
logger.Warn("failed to encode fee quote token", zap.Error(err))
logger.Warn("Failed to encode fee quote token", zap.Error(err))
err = status.Error(codes.Internal, "failed to encode fee quote token")
return nil, err
}
@@ -272,8 +246,9 @@ func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFees
ExpiresAt: timestamppb.New(expiresAt),
Lines: lines,
Applied: applied,
FxUsed: fx,
FxUsed: fxResult,
}
return resp, nil
}
@@ -282,49 +257,23 @@ func (s *Service) ValidateFeeToken(ctx context.Context, req *feesv1.ValidateFeeT
if req != nil {
tokenLen = len(strings.TrimSpace(req.GetFeeQuoteToken()))
}
logger := s.logger.With(zap.Int("token_length", tokenLen))
start := s.clock.Now()
trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED
var (
fxUsed bool
resultReason string
)
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))
logFields := []zap.Field{
zap.String("status", statusLabel),
zap.Duration("duration", time.Since(start)),
zap.Bool("fx_used", fxUsed),
zap.String("trigger", trigger.String()),
zap.Bool("valid", resp != nil && resp.GetValid()),
}
if resultReason != "" {
logFields = append(logFields, zap.String("reason", resultReason))
}
if err != nil {
logger.Warn("ValidateFeeToken finished", append(logFields, zap.Error(err))...)
return
}
logger.Info("ValidateFeeToken finished", logFields...)
}()
trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED
var resultReason string
defer func() { s.observeValidateFeeToken(logger, err, resp, trigger, resultReason, start) }()
logger.Debug("ValidateFeeToken request received")
if req == nil || strings.TrimSpace(req.GetFeeQuoteToken()) == "" {
resultReason = "missing_token"
err = status.Error(codes.InvalidArgument, "fee_quote_token is required")
return nil, err
}
@@ -333,8 +282,11 @@ func (s *Service) ValidateFeeToken(ctx context.Context, req *feesv1.ValidateFeeT
payload, decodeErr := decodeTokenPayload(req.GetFeeQuoteToken())
if decodeErr != nil {
resultReason = "invalid_token"
logger.Warn("failed to decode fee quote token", zap.Error(decodeErr))
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
}
@@ -346,22 +298,29 @@ func (s *Service) ValidateFeeToken(ctx context.Context, req *feesv1.ValidateFeeT
if now.UnixMilli() > payload.ExpiresAtUnixMs {
resultReason = "expired"
logger.Info("fee quote token expired")
logger.Info("Fee quote token expired")
resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "expired"}
return resp, nil
}
orgRef, parseErr := bson.ObjectIDFromHex(payload.OrganizationRef)
if parseErr != nil {
resultReason = "invalid_token"
logger.Warn("token contained invalid organization reference", zap.Error(parseErr))
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)
lines, applied, fxResult, computeErr := s.computeQuoteWithTime(ctx, orgRef, payload.Intent, nil, payload.Trace, now)
if computeErr != nil {
err = computeErr
return nil, err
}
@@ -371,8 +330,9 @@ func (s *Service) ValidateFeeToken(ctx context.Context, req *feesv1.ValidateFeeT
Intent: payload.Intent,
Lines: lines,
Applied: applied,
FxUsed: fx,
FxUsed: fxResult,
}
return resp, nil
}
@@ -380,24 +340,31 @@ 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
}
@@ -405,6 +372,7 @@ func (s *Service) validatePrecomputeRequest(req *feesv1.PrecomputeFeesRequest) e
if req == nil {
return status.Error(codes.InvalidArgument, "request is required")
}
return s.validateQuoteRequest(&feesv1.QuoteFeesRequest{Meta: req.GetMeta(), Intent: req.GetIntent()})
}
@@ -413,17 +381,13 @@ func (s *Service) computeQuote(ctx context.Context, orgRef bson.ObjectID, intent
}
func (s *Service) computeQuoteWithTime(ctx context.Context, orgRef bson.ObjectID, intent *feesv1.Intent, _ *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()
}
bookedAt := resolvedBookedAt(intent, now)
logFields := []zap.Field{
zap.Time("booked_at_used", bookedAt),
}
logFields := []zap.Field{zap.Time("booked_at_used", bookedAt)}
if !orgRef.IsZero() {
logFields = append(logFields, zap.String("organization_ref", orgRef.Hex()))
}
logFields = append(logFields, logFieldsFromIntent(intent)...)
logFields = append(logFields, logFieldsFromTrace(trace)...)
logger := s.logger.With(logFields...)
@@ -436,22 +400,13 @@ func (s *Service) computeQuoteWithTime(ctx context.Context, orgRef bson.ObjectID
plan, rule, err := s.resolver.ResolveFeeRule(ctx, orgPtr, convertTrigger(intent.GetTrigger()), bookedAt, intent.GetAttributes())
if err != nil {
s.logger.Warn("Failed to resolve fee rule", zap.Error(err))
switch {
case errors.Is(err, merrors.ErrNoData):
return nil, nil, nil, status.Error(codes.NotFound, fmt.Sprintf("fee rule not found: %s", err.Error()))
case errors.Is(err, merrors.ErrDataConflict):
return nil, nil, nil, status.Error(codes.FailedPrecondition, fmt.Sprintf("conflicting fee rules: %s", err.Error()))
case errors.Is(err, storage.ErrConflictingFeePlans):
return nil, nil, nil, status.Error(codes.FailedPrecondition, fmt.Sprintf("conflicting fee plans: %s", err.Error()))
case errors.Is(err, storage.ErrFeePlanNotFound):
return nil, nil, nil, status.Error(codes.NotFound, fmt.Sprintf("fee plan not found: %s", err.Error()))
default:
return nil, nil, nil, status.Error(codes.Internal, fmt.Sprintf("failed to resolve fee rule: %s", err.Error()))
}
return nil, nil, nil, mapResolveError(err)
}
originalRules := plan.Rules
plan.Rules = []model.FeeRule{*rule}
defer func() {
plan.Rules = originalRules
}()
@@ -461,13 +416,116 @@ func (s *Service) computeQuoteWithTime(ctx context.Context, orgRef bson.ObjectID
if errors.Is(calcErr, merrors.ErrInvalidArg) {
return nil, nil, nil, status.Error(codes.InvalidArgument, calcErr.Error())
}
logger.Warn("failed to compute fee quote", zap.Error(calcErr))
logger.Warn("Failed to compute fee quote", zap.Error(calcErr))
return nil, nil, nil, status.Error(codes.Internal, "failed to compute fee quote")
}
return result.Lines, result.Applied, result.FxUsed, nil
}
func resolvedBookedAt(intent *feesv1.Intent, now time.Time) time.Time {
if intent.GetBookedAt() != nil && intent.GetBookedAt().IsValid() {
return intent.GetBookedAt().AsTime()
}
return now
}
func mapResolveError(err error) error {
switch {
case errors.Is(err, merrors.ErrNoData):
return status.Error(codes.NotFound, "fee rule not found: "+err.Error())
case errors.Is(err, merrors.ErrDataConflict):
return status.Error(codes.FailedPrecondition, "conflicting fee rules: "+err.Error())
case errors.Is(err, storage.ErrConflictingFeePlans):
return status.Error(codes.FailedPrecondition, "conflicting fee plans: "+err.Error())
case errors.Is(err, storage.ErrFeePlanNotFound):
return status.Error(codes.NotFound, "fee plan not found: "+err.Error())
default:
return status.Error(codes.Internal, "failed to resolve fee rule: "+err.Error())
}
}
func (s *Service) observePrecomputeFees(logger mlogger.Logger, err error, resp *feesv1.PrecomputeFeesResponse, trigger feesv1.Trigger, start time.Time) {
statusLabel := statusFromError(err)
fxUsed := false
linesCount := 0
appliedCount := 0
var expiresAt time.Time
if err == nil && resp != nil {
fxUsed = resp.GetFxUsed() != nil
linesCount = len(resp.GetLines())
appliedCount = len(resp.GetApplied())
if ts := resp.GetExpiresAt(); ts != nil {
expiresAt = ts.AsTime()
}
}
observeMetrics("precompute", trigger, statusLabel, fxUsed, time.Since(start))
logFields := []zap.Field{
zap.String("status", statusLabel),
zap.Duration("duration", time.Since(start)),
zap.Bool("fx_used", fxUsed),
zap.String("trigger", trigger.String()),
zap.Int("lines", linesCount),
zap.Int("applied_rules", appliedCount),
}
if !expiresAt.IsZero() {
logFields = append(logFields, zap.Time("expires_at", expiresAt))
}
if err != nil {
logger.Warn("PrecomputeFees finished", append(logFields, zap.Error(err))...)
return
}
logger.Info("PrecomputeFees finished", logFields...)
}
func (s *Service) observeValidateFeeToken(logger mlogger.Logger, err error, resp *feesv1.ValidateFeeTokenResponse, trigger feesv1.Trigger, resultReason string, start time.Time) {
statusLabel := statusFromError(err)
fxUsed := false
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))
logFields := []zap.Field{
zap.String("status", statusLabel),
zap.Duration("duration", time.Since(start)),
zap.Bool("fx_used", fxUsed),
zap.String("trigger", trigger.String()),
zap.Bool("valid", resp != nil && resp.GetValid()),
}
if resultReason != "" {
logFields = append(logFields, zap.String("reason", resultReason))
}
if err != nil {
logger.Warn("ValidateFeeToken finished", append(logFields, zap.Error(err))...)
return
}
logger.Info("ValidateFeeToken finished", logFields...)
}
type feeQuoteTokenPayload struct {
OrganizationRef string `json:"organization_ref"`
Intent *feesv1.Intent `json:"intent"`
@@ -480,17 +538,36 @@ func encodeTokenPayload(payload feeQuoteTokenPayload) (string, error) {
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
}
func (s *Service) startDiscoveryAnnouncer() {
if s == nil || s.producer == nil {
return
}
announce := discovery.Announcement{
Service: "BILLING_FEES",
Operations: []string{discovery.OperationFeeCalc},
InvokeURI: s.invokeURI,
Version: appversion.Create().Short(),
}
s.announcer = discovery.NewAnnouncer(s.logger, s.producer, mservice.FeePlans, announce)
s.announcer.Start()
}

View File

@@ -20,7 +20,7 @@ import (
)
func TestQuoteFees_ComputesDerivedLines(t *testing.T) {
t.Helper()
t.Parallel()
now := time.Date(2024, 1, 10, 16, 0, 0, 0, time.UTC)
orgRef := bson.NewObjectID()
@@ -93,9 +93,11 @@ func TestQuoteFees_ComputesDerivedLines(t *testing.T) {
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())
}
@@ -111,16 +113,18 @@ func TestQuoteFees_ComputesDerivedLines(t *testing.T) {
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()
t.Parallel()
now := time.Date(2024, 5, 20, 9, 30, 0, 0, time.UTC)
orgRef := bson.NewObjectID()
@@ -189,20 +193,23 @@ func TestQuoteFees_FiltersByAttributesAndDates(t *testing.T) {
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()
t.Parallel()
now := time.Date(2024, 3, 15, 12, 0, 0, 0, time.UTC)
orgRef := bson.NewObjectID()
@@ -249,16 +256,18 @@ func TestQuoteFees_RoundingDown(t *testing.T) {
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()
t.Parallel()
now := time.Date(2024, 6, 1, 8, 0, 0, 0, time.UTC)
orgRef := bson.NewObjectID()
@@ -316,22 +325,26 @@ func TestQuoteFees_UsesInjectedCalculator(t *testing.T) {
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()
t.Parallel()
now := time.Date(2024, 7, 1, 9, 30, 0, 0, time.UTC)
orgRef := bson.NewObjectID()
@@ -356,7 +369,7 @@ func TestQuoteFees_PopulatesFxUsed(t *testing.T) {
plan.OrganizationRef = &orgRef
fakeOracle := &oracleclient.Fake{
LatestRateFn: func(ctx context.Context, req oracleclient.LatestRateParams) (*oracleclient.RateSnapshot, error) {
LatestRateFn: func(_ context.Context, req oracleclient.LatestRateParams) (*oracleclient.RateSnapshot, error) {
return &oracleclient.RateSnapshot{
Pair: req.Pair,
Mid: "1.2300",
@@ -399,10 +412,12 @@ func TestQuoteFees_PopulatesFxUsed(t *testing.T) {
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())
}
@@ -437,49 +452,59 @@ func (s *stubPlansStore) Get(context.Context, bson.ObjectID) (*model.FeePlan, er
return nil, storage.ErrFeePlanNotFound
}
func (s *stubPlansStore) GetActivePlan(_ context.Context, orgRef bson.ObjectID, at time.Time) (*model.FeePlan, error) {
func (s *stubPlansStore) GetActivePlan(ctx context.Context, orgRef bson.ObjectID, asOf time.Time) (*model.FeePlan, error) {
if !orgRef.IsZero() {
if plan, err := s.FindActiveOrgPlan(context.Background(), orgRef, at); err == nil {
if plan, err := s.FindActiveOrgPlan(ctx, orgRef, asOf); err == nil {
return plan, nil
} else if !errors.Is(err, storage.ErrFeePlanNotFound) {
return nil, err
}
}
return s.FindActiveGlobalPlan(context.Background(), at)
return s.FindActiveGlobalPlan(ctx, asOf)
}
func (s *stubPlansStore) FindActiveOrgPlan(_ context.Context, orgRef bson.ObjectID, at time.Time) (*model.FeePlan, error) {
func (s *stubPlansStore) FindActiveOrgPlan(_ context.Context, orgRef bson.ObjectID, asOf time.Time) (*model.FeePlan, error) {
if s.plan == nil {
return nil, storage.ErrFeePlanNotFound
}
if (s.plan.OrganizationRef != nil) && (*s.plan.OrganizationRef != orgRef) {
return nil, storage.ErrFeePlanNotFound
}
if !s.plan.Active {
return nil, storage.ErrFeePlanNotFound
}
if s.plan.EffectiveFrom.After(at) {
if s.plan.EffectiveFrom.After(asOf) {
return nil, storage.ErrFeePlanNotFound
}
if s.plan.EffectiveTo != nil && !s.plan.EffectiveTo.After(at) {
if s.plan.EffectiveTo != nil && !s.plan.EffectiveTo.After(asOf) {
return nil, storage.ErrFeePlanNotFound
}
return s.plan, nil
}
func (s *stubPlansStore) FindActiveGlobalPlan(_ context.Context, at time.Time) (*model.FeePlan, error) {
func (s *stubPlansStore) FindActiveGlobalPlan(_ context.Context, asOf time.Time) (*model.FeePlan, error) {
if s.globalPlan == nil {
return nil, storage.ErrFeePlanNotFound
}
if !s.globalPlan.Active {
return nil, storage.ErrFeePlanNotFound
}
if s.globalPlan.EffectiveFrom.After(at) {
if s.globalPlan.EffectiveFrom.After(asOf) {
return nil, storage.ErrFeePlanNotFound
}
if s.globalPlan.EffectiveTo != nil && !s.globalPlan.EffectiveTo.After(at) {
if s.globalPlan.EffectiveTo != nil && !s.globalPlan.EffectiveTo.After(asOf) {
return nil, storage.ErrFeePlanNotFound
}
return s.globalPlan, nil
}
@@ -509,8 +534,10 @@ func (s *stubCalculator) Compute(_ context.Context, plan *model.FeePlan, _ *fees
s.called = true
s.gotPlan = plan
s.bookedAt = bookedAt
if s.err != nil {
return nil, s.err
}
return s.result, nil
}

View File

@@ -7,6 +7,8 @@ import (
func convertTrigger(trigger feesv1.Trigger) model.Trigger {
switch trigger {
case feesv1.Trigger_TRIGGER_UNSPECIFIED:
return model.TriggerUnspecified
case feesv1.Trigger_TRIGGER_CAPTURE:
return model.TriggerCapture
case feesv1.Trigger_TRIGGER_REFUND:

View File

@@ -28,12 +28,13 @@ const (
type FeePlan struct {
storable.Base `bson:",inline" json:",inline"`
model.Describable `bson:",inline" json:",inline"`
OrganizationRef *bson.ObjectID `bson:"organizationRef,omitempty" json:"organizationRef,omitempty"`
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"`
OrganizationRef *bson.ObjectID `bson:"organizationRef,omitempty" json:"organizationRef,omitempty"`
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.
@@ -43,21 +44,21 @@ func (*FeePlan) Collection() string {
// 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"`
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"`
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

@@ -43,18 +43,22 @@ func New(logger mlogger.Logger, conn *db.MongoConnection) (*Store, error) {
defer cancel()
if err := result.Ping(ctx); err != nil {
result.logger.Error("mongo ping failed during store init", zap.Error(err))
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))
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
}

View File

@@ -28,6 +28,8 @@ type plansStore struct {
repo repository.Repository
}
const maxActivePlanResults = 2
// NewPlans constructs a Mongo-backed PlansStore.
func NewPlans(logger mlogger.Logger, db *mongo.Database) (storage.PlansStore, error) {
repo := repository.CreateMongoRepository(db, mservice.FeePlans)
@@ -40,7 +42,8 @@ func NewPlans(logger mlogger.Logger, db *mongo.Database) (storage.PlansStore, er
},
}
if err := repo.CreateIndex(orgIndex); err != nil {
logger.Error("failed to ensure fee plan organization index", zap.Error(err))
logger.Error("Failed to ensure fee plan organization index", zap.Error(err))
return nil, err
}
@@ -53,7 +56,8 @@ func NewPlans(logger mlogger.Logger, db *mongo.Database) (storage.PlansStore, er
Unique: true,
}
if err := repo.CreateIndex(uniqueIndex); err != nil {
logger.Error("failed to ensure fee plan uniqueness index", zap.Error(err))
logger.Error("Failed to ensure fee plan uniqueness index", zap.Error(err))
return nil, err
}
@@ -67,7 +71,7 @@ func NewPlans(logger mlogger.Logger, db *mongo.Database) (storage.PlansStore, er
},
}
if err := repo.CreateIndex(activeIndex); err != nil {
logger.Warn("failed to ensure fee plan active index", zap.Error(err))
logger.Warn("Failed to ensure fee plan active index", zap.Error(err))
}
return &plansStore{
@@ -80,6 +84,7 @@ func (p *plansStore) Create(ctx context.Context, plan *model.FeePlan) error {
if err := validatePlan(plan); err != nil {
return err
}
if err := p.ensureNoOverlap(ctx, plan); err != nil {
return err
}
@@ -88,9 +93,12 @@ func (p *plansStore) Create(ctx context.Context, plan *model.FeePlan) error {
if errors.Is(err, merrors.ErrDataConflict) {
return storage.ErrDuplicateFeePlan
}
p.logger.Warn("failed to create fee plan", zap.Error(err))
p.logger.Warn("Failed to create fee plan", zap.Error(err))
return err
}
return nil
}
@@ -98,17 +106,21 @@ 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 := validatePlan(plan); err != nil {
return err
}
if err := p.ensureNoOverlap(ctx, plan); err != nil {
return err
}
if err := p.repo.Update(ctx, plan); err != nil {
p.logger.Warn("failed to update fee plan", zap.Error(err))
p.logger.Warn("Failed to update fee plan", zap.Error(err))
return err
}
return nil
}
@@ -116,72 +128,83 @@ func (p *plansStore) Get(ctx context.Context, planRef bson.ObjectID) (*model.Fee
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 bson.ObjectID, at time.Time) (*model.FeePlan, error) {
func (p *plansStore) GetActivePlan(ctx context.Context, orgRef bson.ObjectID, asOf time.Time) (*model.FeePlan, error) {
// Compatibility shim: prefer org plan, fall back to global; allow zero org to mean global.
if orgRef.IsZero() {
return p.FindActiveGlobalPlan(ctx, at)
return p.FindActiveGlobalPlan(ctx, asOf)
}
plan, err := p.FindActiveOrgPlan(ctx, orgRef, at)
plan, err := p.FindActiveOrgPlan(ctx, orgRef, asOf)
if err == nil {
return plan, nil
}
if errors.Is(err, storage.ErrFeePlanNotFound) {
return p.FindActiveGlobalPlan(ctx, at)
return p.FindActiveGlobalPlan(ctx, asOf)
}
return nil, err
}
func (p *plansStore) FindActiveOrgPlan(ctx context.Context, orgRef bson.ObjectID, at time.Time) (*model.FeePlan, error) {
func (p *plansStore) FindActiveOrgPlan(ctx context.Context, orgRef bson.ObjectID, asOf time.Time) (*model.FeePlan, error) {
if orgRef.IsZero() {
return nil, merrors.InvalidArgument("plansStore: zero organization reference")
}
query := repository.Query().Filter(repository.OrgField(), orgRef)
return p.findActivePlan(ctx, query, at)
return p.findActivePlan(ctx, query, asOf)
}
func (p *plansStore) FindActiveGlobalPlan(ctx context.Context, at time.Time) (*model.FeePlan, error) {
func (p *plansStore) FindActiveGlobalPlan(ctx context.Context, asOf time.Time) (*model.FeePlan, error) {
globalQuery := repository.Query().Or(
repository.Exists(repository.OrgField(), false),
repository.Query().Filter(repository.OrgField(), nil),
)
return p.findActivePlan(ctx, globalQuery, at)
return p.findActivePlan(ctx, globalQuery, asOf)
}
var _ storage.PlansStore = (*plansStore)(nil)
func (p *plansStore) findActivePlan(ctx context.Context, orgQuery builder.Query, at time.Time) (*model.FeePlan, error) {
limit := int64(2)
func (p *plansStore) findActivePlan(ctx context.Context, orgQuery builder.Query, asOf time.Time) (*model.FeePlan, error) {
limit := int64(maxActivePlanResults)
query := orgQuery.
Filter(repository.Field("active"), true).
Comparison(repository.Field("effectiveFrom"), builder.Lte, at).
Comparison(repository.Field("effectiveFrom"), builder.Lte, asOf).
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),
repository.Query().Comparison(repository.Field("effectiveTo"), builder.Gte, asOf),
),
)
var plans []*model.FeePlan
decoder := func(cursor *mongo.Cursor) error {
target := &model.FeePlan{}
if err := cursor.Decode(target); err != nil {
return err
}
plans = append(plans, target)
return nil
}
@@ -189,15 +212,18 @@ func (p *plansStore) findActivePlan(ctx context.Context, orgQuery builder.Query,
if errors.Is(err, merrors.ErrNoData) {
return nil, storage.ErrFeePlanNotFound
}
return nil, err
}
if len(plans) == 0 {
return nil, storage.ErrFeePlanNotFound
}
if len(plans) > 1 {
return nil, storage.ErrConflictingFeePlans
}
return plans[0], nil
}
@@ -205,44 +231,61 @@ func validatePlan(plan *model.FeePlan) error {
if plan == nil {
return merrors.InvalidArgument("plansStore: nil fee plan")
}
if len(plan.Rules) == 0 {
return merrors.InvalidArgument("plansStore: fee plan must contain at least one rule")
}
if plan.Active && plan.EffectiveTo != nil && plan.EffectiveTo.Before(plan.EffectiveFrom) {
return merrors.InvalidArgument("plansStore: effectiveTo cannot be before effectiveFrom")
}
// Ensure unique priority per (trigger, appliesTo) combination.
seen := make(map[string]struct{})
for _, rule := range plan.Rules {
if strings.TrimSpace(rule.Percentage) != "" {
if _, err := dmath.RatFromString(rule.Percentage); err != nil {
return merrors.InvalidArgument("plansStore: invalid rule percentage")
}
}
if strings.TrimSpace(rule.FixedAmount) != "" {
if _, err := dmath.RatFromString(rule.FixedAmount); err != nil {
return merrors.InvalidArgument("plansStore: invalid rule fixed amount")
}
}
if strings.TrimSpace(rule.MinimumAmount) != "" {
if _, err := dmath.RatFromString(rule.MinimumAmount); err != nil {
return merrors.InvalidArgument("plansStore: invalid rule minimum amount")
}
}
if strings.TrimSpace(rule.MaximumAmount) != "" {
if _, err := dmath.RatFromString(rule.MaximumAmount); err != nil {
return merrors.InvalidArgument("plansStore: invalid rule maximum amount")
}
if err := validateRule(rule); err != nil {
return err
}
appliesKey := normalizeAppliesTo(rule.AppliesTo)
priorityKey := fmt.Sprintf("%s|%d|%s", rule.Trigger, rule.Priority, appliesKey)
if _, ok := seen[priorityKey]; ok {
return merrors.InvalidArgument("plansStore: duplicate priority for trigger/appliesTo")
}
seen[priorityKey] = struct{}{}
}
return nil
}
func validateRule(rule model.FeeRule) error {
if strings.TrimSpace(rule.Percentage) != "" {
if _, err := dmath.RatFromString(rule.Percentage); err != nil {
return merrors.InvalidArgument("plansStore: invalid rule percentage")
}
}
if strings.TrimSpace(rule.FixedAmount) != "" {
if _, err := dmath.RatFromString(rule.FixedAmount); err != nil {
return merrors.InvalidArgument("plansStore: invalid rule fixed amount")
}
}
if strings.TrimSpace(rule.MinimumAmount) != "" {
if _, err := dmath.RatFromString(rule.MinimumAmount); err != nil {
return merrors.InvalidArgument("plansStore: invalid rule minimum amount")
}
}
if strings.TrimSpace(rule.MaximumAmount) != "" {
if _, err := dmath.RatFromString(rule.MaximumAmount); err != nil {
return merrors.InvalidArgument("plansStore: invalid rule maximum amount")
}
}
return nil
}
@@ -250,15 +293,19 @@ func normalizeAppliesTo(applies map[string]string) string {
if len(applies) == 0 {
return ""
}
keys := make([]string, 0, len(applies))
for k := range applies {
keys = append(keys, k)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, k := range keys {
parts = append(parts, k+"="+normalizeAppliesToValue(applies[k]))
}
return strings.Join(parts, ",")
}
@@ -272,28 +319,37 @@ func normalizeAppliesToValue(value string) string {
seen := make(map[string]struct{}, len(values))
normalized := make([]string, 0, len(values))
hasWildcard := false
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if value == "*" {
hasWildcard = true
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
normalized = append(normalized, value)
}
if hasWildcard {
return "*"
}
if len(normalized) == 0 {
return ""
}
sort.Strings(normalized)
return strings.Join(normalized, ",")
}
@@ -302,7 +358,7 @@ func (p *plansStore) ensureNoOverlap(ctx context.Context, plan *model.FeePlan) e
return nil
}
orgQuery := repository.Query()
var orgQuery builder.Query
if plan.OrganizationRef.IsZero() {
orgQuery = repository.Query().Or(
repository.Exists(repository.OrgField(), false),
@@ -314,6 +370,7 @@ func (p *plansStore) ensureNoOverlap(ctx context.Context, plan *model.FeePlan) e
maxTime := time.Date(9999, 12, 31, 23, 59, 59, 0, time.UTC)
newFrom := plan.EffectiveFrom
newTo := maxTime
if plan.EffectiveTo != nil {
newTo = *plan.EffectiveTo
@@ -335,8 +392,10 @@ func (p *plansStore) ensureNoOverlap(ctx context.Context, plan *model.FeePlan) e
query = query.Limit(&limit)
var overlapFound bool
decoder := func(cursor *mongo.Cursor) error {
decoder := func(_ *mongo.Cursor) error {
overlapFound = true
return nil
}
@@ -344,10 +403,13 @@ func (p *plansStore) ensureNoOverlap(ctx context.Context, plan *model.FeePlan) e
if errors.Is(err, merrors.ErrNoData) {
return nil
}
return err
}
if overlapFound {
return storage.ErrConflictingFeePlans
}
return nil
}

196
api/discovery/.golangci.yml Normal file
View File

@@ -0,0 +1,196 @@
# See the dedicated "version" documentation section.
version: "2"
linters:
# Default set of linters.
# The value can be:
# - `standard`: https://golangci-lint.run/docs/linters/#enabled-by-default
# - `all`: enables all linters by default.
# - `none`: disables all linters by default.
# - `fast`: enables only linters considered as "fast" (`golangci-lint help linters --json | jq '[ .[] | select(.fast==true) ] | map(.name)'`).
# Default: standard
default: all
# Enable specific linter.
enable:
- arangolint
- asasalint
- asciicheck
- bidichk
- bodyclose
- canonicalheader
- containedctx
- contextcheck
- copyloopvar
- cyclop
- decorder
- dogsled
- dupl
- dupword
- durationcheck
- embeddedstructfieldcheck
- err113
- errcheck
- errchkjson
- errname
- errorlint
- exhaustive
- exptostd
- fatcontext
- forbidigo
- forcetypeassert
- funcorder
- funlen
- ginkgolinter
- gocheckcompilerdirectives
- gochecknoglobals
- gochecknoinits
- gochecksumtype
- gocognit
- goconst
- gocritic
- gocyclo
- godoclint
- godot
- godox
- goheader
- gomodguard
- goprintffuncname
- gosec
- gosmopolitan
- govet
- grouper
- iface
- importas
- inamedparam
- ineffassign
- interfacebloat
- intrange
- iotamixing
- ireturn
- lll
- loggercheck
- maintidx
- makezero
- mirror
- misspell
- mnd
- modernize
- musttag
- nakedret
- nestif
- nilerr
- nilnesserr
- nilnil
- nlreturn
- noctx
- noinlineerr
- nolintlint
- nonamedreturns
- nosprintfhostport
- paralleltest
- perfsprint
- prealloc
- predeclared
- promlinter
- protogetter
- reassign
- recvcheck
- revive
- rowserrcheck
- sloglint
- spancheck
- sqlclosecheck
- staticcheck
- tagalign
- tagliatelle
- testableexamples
- testifylint
- testpackage
- thelper
- tparallel
- unconvert
- unparam
- unqueryvet
- unused
- usestdlibvars
- usetesting
- varnamelen
- wastedassign
- whitespace
- wsl_v5
- zerologlint
# Disable specific linters.
disable:
- depguard
- exhaustruct
- gochecknoglobals
- gomoddirectives
- wsl
- wrapcheck
# All available settings of specific linters.
# See the dedicated "linters.settings" documentation section.
settings:
wsl_v5:
allow-first-in-block: true
allow-whole-block: false
branch-max-lines: 2
# Defines a set of rules to ignore issues.
# It does not skip the analysis, and so does not ignore "typecheck" errors.
exclusions:
# Mode of the generated files analysis.
#
# - `strict`: sources are excluded by strictly following the Go generated file convention.
# Source files that have lines matching only the following regular expression will be excluded: `^// Code generated .* DO NOT EDIT\.$`
# This line must appear before the first non-comment, non-blank text in the file.
# https://go.dev/s/generatedcode
# - `lax`: sources are excluded if they contain lines like `autogenerated file`, `code generated`, `do not edit`, etc.
# - `disable`: disable the generated files exclusion.
#
# Default: strict
generated: lax
# Log a warning if an exclusion rule is unused.
# Default: false
warn-unused: true
# Predefined exclusion rules.
# Default: []
presets:
- comments
- std-error-handling
- common-false-positives
- legacy
# Excluding configuration per-path, per-linter, per-text and per-source.
rules:
# Exclude some linters from running on tests files.
- path: _test\.go
linters:
- funlen
- gocyclo
- errcheck
- dupl
- gosec
# Run some linter only for test files by excluding its issues for everything else.
- path-except: _test\.go
linters:
- forbidigo
# Exclude known linters from partially hard-vendored code,
# which is impossible to exclude via `nolint` comments.
# `/` will be replaced by the current OS file path separator to properly work on Windows.
- path: internal/hmac/
text: "weak cryptographic primitive"
linters:
- gosec
# Exclude some `staticcheck` messages.
- linters:
- staticcheck
text: "SA9003:"
# Exclude `lll` issues for long lines with `go:generate`.
- linters:
- lll
source: "^//go:generate "
# Which file paths to exclude: they will be analyzed, but issues from them won't be reported.
# "/" will be replaced by the current OS file path separator to properly work on Windows.
# Default: []
paths: []
# Which file paths to not exclude.
# Default: []
paths-except: []

View File

@@ -1,11 +1,11 @@
module github.com/tech/sendico/discovery
go 1.25.6
go 1.25.7
replace github.com/tech/sendico/pkg => ../pkg
require (
github.com/go-chi/chi/v5 v5.2.4
github.com/go-chi/chi/v5 v5.2.5
github.com/prometheus/client_golang v1.23.2
github.com/tech/sendico/pkg v0.1.0
go.uber.org/zap v1.27.1
@@ -20,17 +20,17 @@ require (
github.com/casbin/mongodb-adapter/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/compress v1.18.3 // indirect
github.com/klauspost/compress v1.18.4 // 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/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nats-io/nats.go v1.48.0 // indirect
github.com/nats-io/nats.go v1.49.0 // indirect
github.com/nats-io/nkeys v0.4.15 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/procfs v0.19.2 // indirect
github.com/prometheus/procfs v0.20.0 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.2.0 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
@@ -38,12 +38,12 @@ require (
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/crypto v0.47.0 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b // indirect
google.golang.org/grpc v1.78.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
google.golang.org/grpc v1.79.1 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)

View File

@@ -38,8 +38,8 @@ 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.4 h1:WtFKPHwlywe8Srng8j2BhOD9312j9cGUxG1SP4V2cR4=
github.com/go-chi/chi/v5 v5.2.4/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
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=
@@ -57,8 +57,8 @@ 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.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.4/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=
@@ -91,8 +91,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
github.com/nats-io/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE=
github.com/nats-io/nats.go v1.49.0/go.mod h1:fDCn3mN5cY8HooHwE2ukiLb4p4G4ImmzvXyJt+tGwdw=
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
@@ -113,8 +113,8 @@ github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNw
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
github.com/prometheus/procfs v0.20.0 h1:AA7aCvjxwAquZAlonN7888f2u4IN8WVeFgBi4k82M4Q=
github.com/prometheus/procfs v0.20.0/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
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=
@@ -152,16 +152,16 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 h1:UP6IpuHFkUgOQL9FFQFrZ+5LiwhhYRbi7VZSIx6Nj5s=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM=
go.opentelemetry.io/otel v1.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.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
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=
@@ -172,15 +172,15 @@ 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.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
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.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
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.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
@@ -191,16 +191,16 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
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.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.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.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
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=
@@ -208,10 +208,10 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b h1:GZxXGdFaHX27ZSMHudWc4FokdD+xl8BC2UJm1OVIEzs=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -1,3 +1,4 @@
// Package appversion exposes build-time version information for the discovery service.
package appversion
import (
@@ -14,8 +15,9 @@ var (
BuildDate string
)
func Create() version.Printer {
vi := version.Info{
// Create returns a version printer populated with the compile-time build metadata.
func Create() version.Printer { //nolint:ireturn // factory returns interface by design
info := version.Info{
Program: "Sendico Discovery Service",
Revision: Revision,
Branch: Branch,
@@ -23,5 +25,6 @@ func Create() version.Printer {
BuildDate: BuildDate,
Version: Version,
}
return vf.Create(&vi)
return vf.Create(&info)
}

View File

@@ -1,3 +1,4 @@
// Package serverimp contains the concrete discovery server implementation.
package serverimp
import (
@@ -10,7 +11,10 @@ import (
"gopkg.in/yaml.v3"
)
const defaultMetricsAddress = ":9405"
const (
defaultMetricsAddress = ":9405"
defaultShutdownTimeoutSeconds = 15
)
type config struct {
Runtime *grpcapp.RuntimeConfig `yaml:"runtime"`
@@ -24,24 +28,28 @@ type metricsConfig struct {
}
type registryConfig struct {
KVTTLSeconds *int `yaml:"kv_ttl_seconds"`
KVTTLSeconds *int `yaml:"kv_ttl_seconds"` //nolint:tagliatelle // matches config file format
}
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
return nil, err //nolint:wrapcheck
}
cfg := &config{}
if err := yaml.Unmarshal(data, cfg); err != nil {
err = yaml.Unmarshal(data, cfg)
if err != nil {
i.logger.Error("Failed to parse configuration", zap.Error(err))
return nil, err
return nil, err //nolint:wrapcheck
}
if cfg.Runtime == nil {
cfg.Runtime = &grpcapp.RuntimeConfig{ShutdownTimeoutSeconds: 15}
cfg.Runtime = &grpcapp.RuntimeConfig{ShutdownTimeoutSeconds: defaultShutdownTimeoutSeconds}
}
if cfg.Metrics != nil && strings.TrimSpace(cfg.Metrics.Address) == "" {

View File

@@ -14,43 +14,51 @@ import (
func (i *Imp) startDiscovery(cfg *config) error {
if cfg == nil || cfg.Messaging == nil || cfg.Messaging.Driver == "" {
//nolint:wrapcheck
return merrors.InvalidArgument("discovery service: messaging configuration is required", "messaging")
}
broker, err := msg.CreateMessagingBroker(i.logger.Named("discovery_bus"), cfg.Messaging)
if err != nil {
return err
return err //nolint:wrapcheck
}
i.logger.Info("Discovery messaging broker ready", zap.String("messaging_driver", string(cfg.Messaging.Driver)))
producer := msgproducer.NewProducer(i.logger.Named("discovery_producer"), broker)
registry := discovery.NewRegistry()
var registryOpts []discovery.RegistryOption
if cfg.Registry != nil && cfg.Registry.KVTTLSeconds != nil {
ttlSeconds := *cfg.Registry.KVTTLSeconds
if ttlSeconds < 0 {
i.logger.Warn("Discovery registry TTL is negative, disabling TTL", zap.Int("ttl_seconds", ttlSeconds))
ttlSeconds = 0
}
registryOpts = append(registryOpts, discovery.WithRegistryKVTTL(time.Duration(ttlSeconds)*time.Second))
}
svc, err := discovery.NewRegistryService(i.logger, broker, producer, registry, string(mservice.Discovery), registryOpts...)
svc, err := discovery.NewRegistryService(i.logger, broker, producer, registry, mservice.Discovery, registryOpts...)
if err != nil {
return err
return err //nolint:wrapcheck
}
svc.Start()
i.registrySvc = svc
announce := discovery.Announcement{
Service: "DISCOVERY",
InstanceID: discovery.InstanceID(),
Operations: []string{"discovery.lookup"},
Operations: []string{discovery.OperationDiscoveryLookup},
Version: appversion.Create().Short(),
}
i.announcer = discovery.NewAnnouncer(i.logger, producer, string(mservice.Discovery), announce)
i.announcer = discovery.NewAnnouncer(i.logger, producer, mservice.Discovery, announce)
i.announcer.Start()
i.logger.Info("Discovery registry service started", zap.String("messaging_driver", string(cfg.Messaging.Driver)))
return nil
}
@@ -58,10 +66,12 @@ func (i *Imp) stopDiscovery() {
if i == nil {
return
}
if i.announcer != nil {
i.announcer.Stop()
i.announcer = nil
}
if i.registrySvc != nil {
i.registrySvc.Stop()
i.registrySvc = nil

View File

@@ -15,22 +15,30 @@ import (
"go.uber.org/zap"
)
const readHeaderTimeout = 5 * time.Second
func (i *Imp) startMetrics(cfg *metricsConfig) {
if i == nil {
return
}
address := ""
if cfg != nil {
address = strings.TrimSpace(cfg.Address)
}
if address == "" {
i.logger.Info("Metrics endpoint disabled")
return
}
listener, err := net.Listen("tcp", address)
lc := net.ListenConfig{}
listener, err := lc.Listen(context.Background(), "tcp", address)
if err != nil {
i.logger.Error("Failed to bind metrics listener", zap.String("address", address), zap.Error(err))
return
}
@@ -38,7 +46,9 @@ func (i *Imp) startMetrics(cfg *metricsConfig) {
router.Handle("/metrics", promhttp.Handler())
var healthRouter routers.Health
if hr, err := routers.NewHealthRouter(i.logger.Named("metrics"), router, ""); err != nil {
hr, err := routers.NewHealthRouter(i.logger.Named("metrics"), router, "")
if err != nil {
i.logger.Warn("Failed to initialise health router", zap.Error(err))
} else {
hr.SetStatus(health.SSStarting)
@@ -49,13 +59,16 @@ func (i *Imp) startMetrics(cfg *metricsConfig) {
i.metricsSrv = &http.Server{
Addr: address,
Handler: router,
ReadHeaderTimeout: 5 * time.Second,
ReadHeaderTimeout: readHeaderTimeout,
}
go func() {
i.logger.Info("Prometheus endpoint listening", zap.String("address", address))
if err := i.metricsSrv.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) {
i.logger.Error("Prometheus endpoint stopped unexpectedly", zap.Error(err))
serveErr := i.metricsSrv.Serve(listener)
if serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) {
i.logger.Error("Prometheus endpoint stopped unexpectedly", zap.Error(serveErr))
if healthRouter != nil {
healthRouter.SetStatus(health.SSTerminating)
}
@@ -69,14 +82,18 @@ func (i *Imp) shutdownMetrics(ctx context.Context) {
i.metricsHealth.Finish()
i.metricsHealth = nil
}
if i.metricsSrv == nil {
return
}
if err := i.metricsSrv.Shutdown(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) {
err := i.metricsSrv.Shutdown(ctx)
if err != nil && !errors.Is(err, http.ErrServerClosed) {
i.logger.Warn("Failed to stop metrics server", zap.Error(err))
} else {
i.logger.Info("Metrics server stopped")
}
i.metricsSrv = nil
}
@@ -84,5 +101,6 @@ func (i *Imp) setMetricsStatus(status health.ServiceStatus) {
if i == nil || i.metricsHealth == nil {
return
}
i.metricsHealth.SetStatus(status)
}

View File

@@ -10,6 +10,9 @@ import (
"go.uber.org/zap"
)
const defaultShutdownTimeout = 15 * time.Second
// Create returns a new server implementation configured with the given logger, config file, and debug flag.
func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) {
return &Imp{
logger: logger.Named("server"),
@@ -18,6 +21,7 @@ func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) {
}, nil
}
// Start loads configuration, starts metrics and the discovery registry, then blocks until stopped.
func (i *Imp) Start() error {
i.initStopChannels()
defer i.closeDone()
@@ -28,29 +32,37 @@ func (i *Imp) Start() error {
if err != nil {
return err
}
i.config = cfg
messagingDriver := "none"
if cfg.Messaging != nil {
messagingDriver = string(cfg.Messaging.Driver)
}
metricsAddress := ""
if cfg.Metrics != nil {
metricsAddress = strings.TrimSpace(cfg.Metrics.Address)
}
if metricsAddress == "" {
metricsAddress = "disabled"
}
i.logger.Info("Discovery config loaded", zap.String("messaging_driver", messagingDriver), zap.String("metrics_address", metricsAddress))
i.logger.Info("Discovery config loaded",
zap.String("messaging_driver", messagingDriver),
zap.String("metrics_address", metricsAddress))
i.startMetrics(cfg.Metrics)
if err := i.startDiscovery(cfg); err != nil {
err = i.startDiscovery(cfg)
if err != nil {
i.stopDiscovery()
i.setMetricsStatus(health.SSTerminating)
ctx, cancel := context.WithTimeout(context.Background(), i.shutdownTimeout())
i.shutdownMetrics(ctx)
cancel()
return err
}
@@ -59,9 +71,11 @@ func (i *Imp) Start() error {
<-i.stopCh
i.logger.Info("Discovery service stop signal received")
return nil
}
// Shutdown gracefully stops the discovery service and its metrics server.
func (i *Imp) Shutdown() {
timeout := i.shutdownTimeout()
i.logger.Info("Stopping discovery service", zap.Duration("timeout", timeout))
@@ -72,6 +86,7 @@ func (i *Imp) Shutdown() {
if i.doneCh != nil {
<-i.doneCh
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
i.shutdownMetrics(ctx)
cancel()
@@ -83,6 +98,7 @@ func (i *Imp) initStopChannels() {
if i.stopCh == nil {
i.stopCh = make(chan struct{})
}
if i.doneCh == nil {
i.doneCh = make(chan struct{})
}
@@ -108,5 +124,6 @@ func (i *Imp) shutdownTimeout() time.Duration {
if i.config != nil && i.config.Runtime != nil {
return i.config.Runtime.ShutdownTimeout()
}
return 15 * time.Second
return defaultShutdownTimeout
}

View File

@@ -9,6 +9,7 @@ import (
"github.com/tech/sendico/pkg/mlogger"
)
// Imp is the concrete implementation of the discovery server application.
type Imp struct {
logger mlogger.Logger
file string

View File

@@ -1,3 +1,4 @@
// Package server provides the discovery service application factory.
package server
import (
@@ -6,6 +7,9 @@ import (
"github.com/tech/sendico/pkg/server"
)
// Create initialises and returns a new discovery server application.
//
//nolint:ireturn // factory returns interface by design
func Create(logger mlogger.Logger, file string, debug bool) (server.Application, error) {
return serverimp.Create(logger, file, debug)
return serverimp.Create(logger, file, debug) //nolint:wrapcheck
}

View File

@@ -1,3 +1,4 @@
// Package main is the entry point for the discovery service.
package main
import (
@@ -8,8 +9,9 @@ import (
smain "github.com/tech/sendico/pkg/server/main"
)
//nolint:ireturn // factory returns interface by design
func factory(logger mlogger.Logger, file string, debug bool) (server.Application, error) {
return si.Create(logger, file, debug)
return si.Create(logger, file, debug) //nolint:wrapcheck
}
func main() {

View File

@@ -0,0 +1,196 @@
# See the dedicated "version" documentation section.
version: "2"
linters:
# Default set of linters.
# The value can be:
# - `standard`: https://golangci-lint.run/docs/linters/#enabled-by-default
# - `all`: enables all linters by default.
# - `none`: disables all linters by default.
# - `fast`: enables only linters considered as "fast" (`golangci-lint help linters --json | jq '[ .[] | select(.fast==true) ] | map(.name)'`).
# Default: standard
default: all
# Enable specific linter.
enable:
- arangolint
- asasalint
- asciicheck
- bidichk
- bodyclose
- canonicalheader
- containedctx
- contextcheck
- copyloopvar
- cyclop
- decorder
- dogsled
- dupl
- dupword
- durationcheck
- embeddedstructfieldcheck
- err113
- errcheck
- errchkjson
- errname
- errorlint
- exhaustive
- exptostd
- fatcontext
- forbidigo
- forcetypeassert
- funcorder
- funlen
- ginkgolinter
- gocheckcompilerdirectives
- gochecknoglobals
- gochecknoinits
- gochecksumtype
- gocognit
- goconst
- gocritic
- gocyclo
- godoclint
- godot
- godox
- goheader
- gomodguard
- goprintffuncname
- gosec
- gosmopolitan
- govet
- grouper
- iface
- importas
- inamedparam
- ineffassign
- interfacebloat
- intrange
- iotamixing
- ireturn
- lll
- loggercheck
- maintidx
- makezero
- mirror
- misspell
- mnd
- modernize
- musttag
- nakedret
- nestif
- nilerr
- nilnesserr
- nilnil
- nlreturn
- noctx
- noinlineerr
- nolintlint
- nonamedreturns
- nosprintfhostport
- paralleltest
- perfsprint
- prealloc
- predeclared
- promlinter
- protogetter
- reassign
- recvcheck
- revive
- rowserrcheck
- sloglint
- spancheck
- sqlclosecheck
- staticcheck
- tagalign
- tagliatelle
- testableexamples
- testifylint
- testpackage
- thelper
- tparallel
- unconvert
- unparam
- unqueryvet
- unused
- usestdlibvars
- usetesting
- varnamelen
- wastedassign
- whitespace
- wsl_v5
- zerologlint
# Disable specific linters.
disable:
- depguard
- exhaustruct
- gochecknoglobals
- gomoddirectives
- wrapcheck
- wsl
# All available settings of specific linters.
# See the dedicated "linters.settings" documentation section.
settings:
wsl_v5:
allow-first-in-block: true
allow-whole-block: false
branch-max-lines: 2
# Defines a set of rules to ignore issues.
# It does not skip the analysis, and so does not ignore "typecheck" errors.
exclusions:
# Mode of the generated files analysis.
#
# - `strict`: sources are excluded by strictly following the Go generated file convention.
# Source files that have lines matching only the following regular expression will be excluded: `^// Code generated .* DO NOT EDIT\.$`
# This line must appear before the first non-comment, non-blank text in the file.
# https://go.dev/s/generatedcode
# - `lax`: sources are excluded if they contain lines like `autogenerated file`, `code generated`, `do not edit`, etc.
# - `disable`: disable the generated files exclusion.
#
# Default: strict
generated: lax
# Log a warning if an exclusion rule is unused.
# Default: false
warn-unused: true
# Predefined exclusion rules.
# Default: []
presets:
- comments
- std-error-handling
- common-false-positives
- legacy
# Excluding configuration per-path, per-linter, per-text and per-source.
rules:
# Exclude some linters from running on tests files.
- path: _test\.go
linters:
- funlen
- gocyclo
- errcheck
- dupl
- gosec
# Run some linter only for test files by excluding its issues for everything else.
- path-except: _test\.go
linters:
- forbidigo
# Exclude known linters from partially hard-vendored code,
# which is impossible to exclude via `nolint` comments.
# `/` will be replaced by the current OS file path separator to properly work on Windows.
- path: internal/hmac/
text: "weak cryptographic primitive"
linters:
- gosec
# Exclude some `staticcheck` messages.
- linters:
- staticcheck
text: "SA9003:"
# Exclude `lll` issues for long lines with `go:generate`.
- linters:
- lll
source: "^//go:generate "
# Which file paths to exclude: they will be analyzed, but issues from them won't be reported.
# "/" will be replaced by the current OS file path separator to properly work on Windows.
# Default: []
paths: []
# Which file paths to not exclude.
# Default: []
paths-except: []

View File

@@ -19,10 +19,10 @@ market:
quote: "EUR"
symbol: "EURUSDT"
invert: true
- base: "USD"
quote: "USDT"
- base: "USDT"
quote: "USD"
symbol: "USDTUSD"
invert: true
invert: false
- base: "UAH"
quote: "USDT"
symbol: "USDTUAH"

View File

@@ -19,10 +19,10 @@ market:
quote: "EUR"
symbol: "EURUSDT"
invert: true
- base: "USD"
quote: "USDT"
- base: "USDT"
quote: "USD"
symbol: "USDTUSD"
invert: true
invert: false
- base: "UAH"
quote: "USDT"
symbol: "USDTUAH"

View File

@@ -1,19 +1,19 @@
module github.com/tech/sendico/fx/ingestor
go 1.25.6
go 1.25.7
replace github.com/tech/sendico/pkg => ../../pkg
replace github.com/tech/sendico/fx/storage => ../storage
require (
github.com/go-chi/chi/v5 v5.2.4
github.com/go-chi/chi/v5 v5.2.5
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
golang.org/x/net v0.49.0
golang.org/x/net v0.51.0
gopkg.in/yaml.v3 v3.0.1
)
@@ -25,17 +25,17 @@ require (
github.com/casbin/mongodb-adapter/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/compress v1.18.3 // indirect
github.com/klauspost/compress v1.18.4 // 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/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nats-io/nats.go v1.48.0 // indirect
github.com/nats-io/nats.go v1.49.0 // indirect
github.com/nats-io/nkeys v0.4.15 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/procfs v0.19.2 // indirect
github.com/prometheus/procfs v0.20.0 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.2.0 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
@@ -43,11 +43,11 @@ require (
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/crypto v0.47.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b // indirect
google.golang.org/grpc v1.78.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
google.golang.org/grpc v1.79.1 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)

View File

@@ -38,8 +38,8 @@ 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.4 h1:WtFKPHwlywe8Srng8j2BhOD9312j9cGUxG1SP4V2cR4=
github.com/go-chi/chi/v5 v5.2.4/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
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=
@@ -57,8 +57,8 @@ 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.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.4/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=
@@ -91,8 +91,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
github.com/nats-io/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE=
github.com/nats-io/nats.go v1.49.0/go.mod h1:fDCn3mN5cY8HooHwE2ukiLb4p4G4ImmzvXyJt+tGwdw=
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
@@ -113,8 +113,8 @@ github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNw
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
github.com/prometheus/procfs v0.20.0 h1:AA7aCvjxwAquZAlonN7888f2u4IN8WVeFgBi4k82M4Q=
github.com/prometheus/procfs v0.20.0/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
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=
@@ -152,16 +152,16 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 h1:UP6IpuHFkUgOQL9FFQFrZ+5LiwhhYRbi7VZSIx6Nj5s=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM=
go.opentelemetry.io/otel v1.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.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
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=
@@ -172,15 +172,15 @@ 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.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
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.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
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.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
@@ -191,16 +191,16 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
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.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.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.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
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=
@@ -208,10 +208,10 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b h1:GZxXGdFaHX27ZSMHudWc4FokdD+xl8BC2UJm1OVIEzs=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -39,6 +39,7 @@ func New(logger mlogger.Logger, cfgPath string) (*App, error) {
if err != nil {
return nil, err
}
return &App{
logger: logger,
cfg: cfg,
@@ -50,20 +51,24 @@ func (a *App) Run(ctx context.Context) error {
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)
a.logger.Debug("Metrics server initialised")
defer metricsSrv.Close(context.Background()) //nolint:contextcheck
conn, err := db.ConnectMongo(a.logger, a.cfg.Database) //nolint:contextcheck
if err != nil {
return err
}
defer conn.Disconnect(context.Background())
defer conn.Disconnect(context.Background()) //nolint:errcheck,contextcheck
a.logger.Debug("MongoDB connection established")
repo, err := mongostorage.New(a.logger, conn)
repo, err := mongostorage.New(a.logger, conn) //nolint:contextcheck
if err != nil {
return err
}
a.logger.Debug("Storage repository initialised")
service, err := ingestor.New(a.logger, a.cfg, repo)
@@ -72,6 +77,7 @@ func (a *App) Run(ctx context.Context) error {
}
var announcer *discovery.Announcer
if cfg := a.cfg.Messaging; cfg != nil && cfg.Driver != "" {
broker, err := msg.CreateMessagingBroker(a.logger.Named("discovery_bus"), cfg)
if err != nil {
@@ -80,10 +86,11 @@ func (a *App) Run(ctx context.Context) error {
producer := msgproducer.NewProducer(a.logger.Named("discovery_producer"), broker)
announce := discovery.Announcement{
Service: "FX_INGESTOR",
Operations: []string{"fx.ingest"},
Operations: []string{discovery.OperationFXIngest},
Version: appversion.Create().Short(),
}
announcer = discovery.NewAnnouncer(a.logger, producer, "fx_ingestor", announce)
announcer.Start()
defer announcer.Stop()
}
@@ -92,12 +99,15 @@ func (a *App) Run(ctx context.Context) error {
a.logger.Info("Starting FX ingestor service", zap.String("version", appversion.Create().Info()))
metricsSrv.SetStatus(health.SSRunning)
if err := service.Run(ctx); err != nil {
err = service.Run(ctx)
if 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

@@ -14,8 +14,9 @@ var (
BuildDate string
)
//nolint:ireturn
func Create() version.Printer {
vi := version.Info{
info := version.Info{
Program: "Sendico FX Ingestor Service",
Revision: Revision,
Branch: Branch,
@@ -23,5 +24,6 @@ func Create() version.Printer {
BuildDate: BuildDate,
Version: Version,
}
return vf.Create(&vi)
return vf.Create(&info)
}

View File

@@ -15,7 +15,7 @@ import (
const defaultPollInterval = 30 * time.Second
type Config struct {
PollIntervalSeconds int `yaml:"poll_interval_seconds"`
PollIntervalSeconds int `yaml:"poll_interval_seconds"` //nolint:tagliatelle // matches config file format
Market MarketConfig `yaml:"market"`
Database *db.Config `yaml:"database"`
Metrics *MetricsConfig `yaml:"metrics"`
@@ -25,6 +25,7 @@ type Config struct {
pairsBySource map[mmodel.Driver][]PairConfig
}
//nolint:cyclop,funlen
func Load(path string) (*Config, error) {
if path == "" {
return nil, merrors.InvalidArgument("config: path is empty")
@@ -36,19 +37,23 @@ func Load(path string) (*Config, error) {
}
cfg := &Config{}
if err := yaml.Unmarshal(data, cfg); err != nil {
err = yaml.Unmarshal(data, cfg)
if err != nil {
return nil, merrors.InternalWrap(err, "config: failed to parse yaml")
}
if len(cfg.Market.Sources) == 0 {
return nil, merrors.InvalidArgument("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, merrors.InvalidArgument("config: market source driver is empty")
}
sourceSet[src.Driver] = struct{}{}
}
@@ -58,6 +63,7 @@ func Load(path string) (*Config, error) {
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 {
@@ -65,8 +71,10 @@ func Load(path string) (*Config, error) {
if driver.IsEmpty() {
return nil, merrors.InvalidArgument("config: pair source is empty")
}
if _, ok := sourceSet[driver]; !ok {
return nil, merrors.InvalidArgument("config: pair references unknown source: "+driver.String(), "pairs."+driver.String())
return nil, merrors.InvalidArgument( //nolint:lll
"config: pair references unknown source: "+driver.String(), "pairs."+driver.String())
}
processed := make([]PairConfig, len(pairList))
@@ -74,19 +82,25 @@ func Load(path string) (*Config, error) {
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, merrors.InvalidArgument("config: pair entries must define base, quote, and symbol", "pairs."+driver.String())
return nil, merrors.InvalidArgument( //nolint:lll
"config: pair entries must define base, quote, and symbol", "pairs."+driver.String())
}
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
}
@@ -94,6 +108,7 @@ func Load(path string) (*Config, error) {
cfg.Market.Pairs = normalizedPairs
cfg.pairsBySource = pairsBySource
cfg.pairs = flattened
if cfg.Database == nil {
return nil, merrors.InvalidArgument("config: database configuration is required")
}
@@ -112,9 +127,11 @@ func (c *Config) PollInterval() time.Duration {
if c == nil {
return defaultPollInterval
}
if c.PollIntervalSeconds <= 0 {
return defaultPollInterval
}
return time.Duration(c.PollIntervalSeconds) * time.Second
}
@@ -122,8 +139,10 @@ func (c *Config) Pairs() []Pair {
if c == nil {
return nil
}
out := make([]Pair, len(c.pairs))
copy(out, c.pairs)
return out
}
@@ -131,12 +150,14 @@ 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
}
@@ -144,6 +165,8 @@ func (c *Config) MetricsConfig() *MetricsConfig {
if c == nil || c.Metrics == nil {
return nil
}
cp := *c.Metrics
return &cp
}

View File

@@ -15,7 +15,8 @@ type PairConfig struct {
type Pair struct {
PairConfig `yaml:",inline"`
Source mmodel.Driver `yaml:"-"`
Source mmodel.Driver `yaml:"-"`
}
type MarketConfig struct {

View File

@@ -28,9 +28,11 @@ func New(logger mlogger.Logger, cfg *config.Config, repo storage.Repository) (*S
if logger == nil {
return nil, merrors.InvalidArgument("ingestor: nil logger")
}
if cfg == nil {
return nil, merrors.InvalidArgument("ingestor: nil config")
}
if repo == nil {
return nil, merrors.InvalidArgument("ingestor: nil repository")
}
@@ -52,6 +54,7 @@ func New(logger mlogger.Logger, cfg *config.Config, repo storage.Repository) (*S
func (s *Service) Run(ctx context.Context) error {
interval := s.cfg.PollInterval()
ticker := time.NewTicker(interval)
defer ticker.Stop()
@@ -65,6 +68,7 @@ func (s *Service) Run(ctx context.Context) error {
select {
case <-ctx.Done():
s.logger.Info("Context cancelled, stopping ingestor")
return ctx.Err()
case <-ticker.C:
if err := s.executePoll(ctx); err != nil {
@@ -77,27 +81,34 @@ func (s *Service) Run(ctx context.Context) error {
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
failures := 0
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
}
failures++
s.logger.Warn("Failed to ingest pair",
zap.String("symbol", pair.Symbol),
zap.String("source", pair.Source.String()),
@@ -110,14 +121,17 @@ func (s *Service) pollOnce(ctx context.Context) error {
)
}
}
if failures > 0 {
s.logger.Warn("Ingestion poll completed with failures", zap.Int("failures", failures), zap.Int("total", len(s.pairs)))
} else {
s.logger.Debug("Ingestion poll completed", zap.Int("total", len(s.pairs)))
}
return firstErr
}
//nolint:funlen
func (s *Service) upsertPair(ctx context.Context, pair config.Pair) error {
connector, ok := s.connectors[pair.Source]
if !ok {
@@ -133,6 +147,7 @@ func (s *Service) upsertPair(ctx context.Context, pair config.Pair) error {
if err != nil {
return merrors.InvalidArgumentWrap(err, "parse bid price", "bid")
}
ask, err := parseDecimal(ticker.AskPrice)
if err != nil {
return merrors.InvalidArgumentWrap(err, "parse ask price", "ask")
@@ -148,16 +163,18 @@ func (s *Service) upsertPair(ctx context.Context, pair config.Pair) error {
}
mid := new(big.Rat).Add(bid, ask)
mid.Quo(mid, big.NewRat(2, 1))
mid.Quo(mid, big.NewRat(2, 1)) //nolint:mnd
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
spread.Mul(spread, big.NewRat(10000, 1)) //nolint:mnd // basis points
}
now := time.Now().UTC()
@@ -201,6 +218,7 @@ func parseDecimal(value string) (*big.Rat, error) {
if _, ok := r.SetString(value); !ok {
return nil, merrors.InvalidArgument("invalid decimal \""+value+"\"", "value")
}
return r, nil
}
@@ -208,9 +226,11 @@ 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
}
@@ -218,6 +238,7 @@ 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

@@ -27,30 +27,33 @@ type binanceConnector struct {
}
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
defaultDialTimeout = 5 * time.Second
defaultDialKeepAlive = 30 * time.Second
defaultTLSHandshakeTimeout = 5 * time.Second
defaultResponseHeaderTimeout = 10 * time.Second
defaultRequestTimeout = 10 * time.Second
)
func NewConnector(logger mlogger.Logger, settings model.SettingsT) (mmodel.Connector, error) {
func NewConnector(logger mlogger.Logger, settings model.SettingsT) (mmodel.Connector, error) { //nolint:ireturn
baseURL := defaultBinanceBaseURL
provider := strings.ToLower(mmodel.DriverBinance.String())
dialTimeout := defaultDialTimeoutSeconds
dialKeepAlive := defaultDialKeepAliveSeconds
tlsHandshakeTimeout := defaultTLSHandshakeTimeoutSeconds
responseHeaderTimeout := defaultResponseHeaderTimeoutSeconds
requestTimeout := defaultRequestTimeoutSeconds
dialTimeout := defaultDialTimeout
dialKeepAlive := defaultDialKeepAlive
tlsHandshakeTimeout := defaultTLSHandshakeTimeout
responseHeaderTimeout := defaultResponseHeaderTimeout
requestTimeout := defaultRequestTimeout
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)
@@ -96,6 +99,7 @@ func (c *binanceConnector) FetchTicker(ctx context.Context, symbol string) (*mmo
if err != nil {
return nil, merrors.InternalWrap(err, "binance: parse base url")
}
endpoint.Path = "/api/v3/ticker/bookTicker"
query := endpoint.Query()
query.Set("symbol", strings.ToUpper(strings.TrimSpace(symbol)))
@@ -109,12 +113,14 @@ func (c *binanceConnector) FetchTicker(ctx context.Context, symbol string) (*mmo
resp, err := c.client.Do(req)
if err != nil {
c.logger.Warn("Binance request failed", zap.String("symbol", symbol), zap.Error(err))
return nil, merrors.InternalWrap(err, "binance: request failed")
}
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, merrors.Internal("binance: unexpected status " + strconv.Itoa(resp.StatusCode))
}
@@ -124,9 +130,11 @@ func (c *binanceConnector) FetchTicker(ctx context.Context, symbol string) (*mmo
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, merrors.InternalWrap(err, "binance: decode response")
decodeErr := json.NewDecoder(resp.Body).Decode(&payload)
if decodeErr != nil {
c.logger.Warn("Binance decode failed", zap.String("symbol", symbol), zap.Error(decodeErr))
return nil, merrors.InternalWrap(decodeErr, "binance: decode response")
}
return &mmodel.Ticker{

View File

@@ -42,21 +42,21 @@ const (
)
const (
defaultDialTimeoutSeconds = 5 * time.Second
defaultDialKeepAliveSeconds = 30 * time.Second
defaultTLSHandshakeTimeoutSeconds = 5 * time.Second
defaultResponseHeaderTimeoutSeconds = 10 * time.Second
defaultRequestTimeoutSeconds = 10 * time.Second
defaultDialTimeout = 5 * time.Second
defaultDialKeepAlive = 30 * time.Second
defaultTLSHandshakeTimeout = 5 * time.Second
defaultResponseHeaderTimeout = 10 * time.Second
defaultRequestTimeout = 10 * time.Second
)
func NewConnector(logger mlogger.Logger, settings model.SettingsT) (mmodel.Connector, error) {
func NewConnector(logger mlogger.Logger, settings model.SettingsT) (mmodel.Connector, error) { //nolint:cyclop,funlen,nestif,ireturn
baseURL := defaultCBRBaseURL
provider := strings.ToLower(mmodel.DriverCBR.String())
dialTimeout := defaultDialTimeoutSeconds
dialKeepAlive := defaultDialKeepAliveSeconds
tlsHandshakeTimeout := defaultTLSHandshakeTimeoutSeconds
responseHeaderTimeout := defaultResponseHeaderTimeoutSeconds
requestTimeout := defaultRequestTimeoutSeconds
dialTimeout := defaultDialTimeout
dialKeepAlive := defaultDialKeepAlive
tlsHandshakeTimeout := defaultTLSHandshakeTimeout
responseHeaderTimeout := defaultResponseHeaderTimeout
requestTimeout := defaultRequestTimeout
directoryPath := defaultDirectoryPath
dailyPath := defaultDailyPath
dynamicPath := defaultDynamicPath
@@ -79,9 +79,11 @@ func NewConnector(logger mlogger.Logger, settings model.SettingsT) (mmodel.Conne
if value, ok := settings["dynamic_path"].(string); ok && strings.TrimSpace(value) != "" {
dynamicPath = strings.TrimSpace(value)
}
if value, ok := settings["user_agent"].(string); ok && strings.TrimSpace(value) != "" {
userAgent = strings.TrimSpace(value)
}
if value, ok := settings["accept_header"].(string); ok && strings.TrimSpace(value) != "" {
acceptHeader = strings.TrimSpace(value)
}
@@ -132,7 +134,8 @@ func NewConnector(logger mlogger.Logger, settings model.SettingsT) (mmodel.Conne
logger: logger.Named("cbr"),
}
if err := connector.refreshDirectory(); err != nil {
err = connector.refreshDirectory()
if err != nil {
return nil, err
}
@@ -165,6 +168,7 @@ func (c *cbrConnector) FetchTicker(ctx context.Context, symbol string) (*mmodel.
}
now := time.Now().UnixMilli()
return &mmodel.Ticker{
Symbol: formatSymbol(isoCode, asOfDate),
BidPrice: price,
@@ -213,7 +217,9 @@ func (c *cbrConnector) refreshDirectory() error {
decoder.CharsetReader = charset.NewReaderLabel
var directory valuteDirectory
if err := decoder.Decode(&directory); err != nil {
err = decoder.Decode(&directory)
if err != nil {
c.logger.Warn("CBR directory decode failed", zap.Error(err), zap.String("endpoint", endpoint))
return merrors.InternalWrap(err, "cbr: decode directory")
}
@@ -226,6 +232,7 @@ func (c *cbrConnector) refreshDirectory() error {
c.byISO = mapping.byISO
c.byID = mapping.byID
return nil
}
@@ -265,7 +272,9 @@ func (c *cbrConnector) fetchDailyRate(ctx context.Context, valute valuteInfo) (s
decoder.CharsetReader = charset.NewReaderLabel
var payload dailyRates
if err := decoder.Decode(&payload); err != nil {
err = decoder.Decode(&payload)
if err != nil {
c.logger.Warn("CBR daily decode failed", zap.Error(err),
zap.String("currency", valute.ISOCharCode), zap.String("endpoint", endpoint),
)
@@ -284,13 +293,16 @@ func (c *cbrConnector) fetchDailyRate(ctx context.Context, valute valuteInfo) (s
return computePrice(entry.Value, entry.Nominal)
}
func (c *cbrConnector) fetchHistoricalRate(ctx context.Context, valute valuteInfo, date time.Time) (string, error) {
func (c *cbrConnector) fetchHistoricalRate( //nolint:funlen
ctx context.Context, valute valuteInfo, date time.Time,
) (string, error) {
query := map[string]string{
"date_req1": date.Format("02/01/2006"),
"date_req2": date.Format("02/01/2006"),
"VAL_NM_RQ": valute.ID,
}
dateStr := date.Format("2006-01-02")
endpoint, err := c.buildURL(c.dynamicPath, query)
if err != nil {
return "", err
@@ -365,7 +377,9 @@ func (c *cbrConnector) buildURL(path string, query map[string]string) (string, e
if err != nil {
return "", merrors.InternalWrap(err, "cbr: parse base url")
}
base.Path = strings.TrimRight(base.Path, "/") + path
q := base.Query()
for key, value := range query {
q.Set(key, value)
@@ -401,13 +415,13 @@ type valuteMapping struct {
byID map[string]valuteInfo
}
func buildValuteMapping(logger *zap.Logger, items []valuteItem) (*valuteMapping, error) {
func buildValuteMapping(logger mlogger.Logger, items []valuteItem) (*valuteMapping, error) { //nolint:cyclop,gocognit,nestif
byISO := make(map[string]valuteInfo, len(items))
byID := make(map[string]valuteInfo, len(items))
byNum := make(map[string]string, len(items))
for _, item := range items {
id := strings.TrimSpace(item.ID)
valuteID := strings.TrimSpace(item.ID)
isoChar := strings.ToUpper(strings.TrimSpace(item.ISOChar))
isoNum := strings.TrimSpace(item.ISONum)
name := strings.TrimSpace(item.Name)
@@ -418,17 +432,18 @@ func buildValuteMapping(logger *zap.Logger, items []valuteItem) (*valuteMapping,
return nil, merrors.InvalidDataType("cbr: parse directory nominal: " + err.Error())
}
if id == "" || isoChar == "" {
if valuteID == "" || isoChar == "" {
logger.Info("Skipping invalid currency entry",
zap.String("id", id),
zap.String("id", valuteID),
zap.String("iso_char", isoChar),
zap.String("name", name),
)
continue
}
info := valuteInfo{
ID: id,
ID: valuteID,
ISOCharCode: isoChar,
ISONumCode: isoNum,
Name: name,
@@ -439,11 +454,12 @@ func buildValuteMapping(logger *zap.Logger, items []valuteItem) (*valuteMapping,
// Handle duplicate ISO char codes (e.g. DEM with different IDs / nominals).
if existing, ok := byISO[isoChar]; ok {
// Same ISO + same ID: duplicate entry, just ignore.
if existing.ID == id {
if existing.ID == valuteID {
logger.Debug("Duplicate directory entry for same ISO and ID, ignoring",
zap.String("iso_code", isoChar),
zap.String("id", id),
zap.String("id", valuteID),
)
continue
}
@@ -453,11 +469,12 @@ func buildValuteMapping(logger *zap.Logger, items []valuteItem) (*valuteMapping,
// 2) Otherwise prefer smaller nominal
keepExisting := true
if existing.Nominal != 1 && info.Nominal == 1 {
switch {
case existing.Nominal != 1 && info.Nominal == 1:
keepExisting = false
} else if existing.Nominal == 1 && info.Nominal != 1 {
case existing.Nominal == 1 && info.Nominal != 1:
keepExisting = true
} else if info.Nominal < existing.Nominal {
case info.Nominal < existing.Nominal:
keepExisting = false
}
@@ -484,17 +501,18 @@ func buildValuteMapping(logger *zap.Logger, items []valuteItem) (*valuteMapping,
// Update byID: drop old ID, add new one
delete(byID, existing.ID)
byID[id] = info
byID[valuteID] = info
// Update ISO mapping
byISO[isoChar] = info
// Update numeric-code index if present
if isoNum != "" {
if existingID, ok := byNum[isoNum]; ok && existingID != id {
if existingID, ok := byNum[isoNum]; ok && existingID != valuteID {
return nil, merrors.InvalidDataType("cbr: duplicate ISO numeric code " + isoNum)
}
byNum[isoNum] = id
byNum[isoNum] = valuteID
}
continue
@@ -502,21 +520,24 @@ func buildValuteMapping(logger *zap.Logger, items []valuteItem) (*valuteMapping,
// No existing ISO entry, do normal uniqueness checks.
if existing, ok := byID[id]; ok && existing.ISOCharCode != isoChar {
return nil, merrors.InvalidDataType("cbr: duplicate valute id " + id)
if existing, ok := byID[valuteID]; ok && existing.ISOCharCode != isoChar {
return nil, merrors.InvalidDataType("cbr: duplicate valute id " + valuteID)
}
if isoNum != "" {
if existingID, ok := byNum[isoNum]; ok && existingID != id {
if existingID, ok := byNum[isoNum]; ok && existingID != valuteID {
return nil, merrors.InvalidDataType("cbr: duplicate ISO numeric code " + isoNum)
}
byNum[isoNum] = id
byNum[isoNum] = valuteID
}
logger.Info("Installing currency code", zap.String("iso_code", isoChar), zap.String("id", id), zap.Int64("nominal", nominal))
logger.Info("Installing currency code",
zap.String("iso_code", isoChar), zap.String("id", valuteID), zap.Int64("nominal", nominal),
)
byISO[isoChar] = info
byID[id] = info
byID[valuteID] = info
}
if len(byISO) == 0 {
@@ -546,6 +567,7 @@ func (d *dailyRates) find(id string) *dailyValute {
if d == nil {
return nil
}
for idx := range d.Valutes {
if strings.EqualFold(strings.TrimSpace(d.Valutes[idx].ID), id) {
return &d.Valutes[idx]
@@ -569,7 +591,9 @@ func (d *dynamicRates) find(id string, date time.Time) *dynamicRecord {
if d == nil {
return nil
}
target := date.Format("02.01.2006")
for idx := range d.Records {
rec := &d.Records[idx]
if !strings.EqualFold(strings.TrimSpace(rec.ID), id) {
@@ -663,7 +687,7 @@ func computePrice(value string, nominalStr string) (string, error) {
den := big.NewRat(nominal, 1)
price := new(big.Rat).Quo(r, den)
return price.FloatString(8), nil
return price.FloatString(8), nil //nolint:mnd
}
func formatSymbol(iso string, asOf *time.Time) string {

View File

@@ -1,4 +1,4 @@
package cbr
package cbr //nolint:testpackage
import (
"context"
@@ -14,6 +14,8 @@ import (
)
func TestFetchTickerDaily(t *testing.T) {
t.Parallel()
transport := &stubRoundTripper{
responses: map[string]stubResponse{
"/scripts/XML_valFull.asp": {body: valuteDirectoryXML},
@@ -47,10 +49,16 @@ func TestFetchTickerDaily(t *testing.T) {
}
func TestFetchTickerValidatesDailyEntry(t *testing.T) {
t.Parallel()
transport := &stubRoundTripper{
responses: map[string]stubResponse{
"/scripts/XML_valFull.asp": {body: valuteDirectoryXML},
"/scripts/XML_daily.asp": {body: strings.ReplaceAll(dailyRatesXML, "<CharCode>USD</CharCode>", "<CharCode>XXX</CharCode>")},
"/scripts/XML_daily.asp": {
body: strings.ReplaceAll(
dailyRatesXML, "<CharCode>USD</CharCode>", "<CharCode>XXX</CharCode>",
),
},
},
}
@@ -68,6 +76,8 @@ func TestFetchTickerValidatesDailyEntry(t *testing.T) {
}
func TestFetchTickerHistorical(t *testing.T) {
t.Parallel()
transport := &stubRoundTripper{
responses: map[string]stubResponse{
"/scripts/XML_valFull.asp": {body: valuteDirectoryXML},
@@ -75,13 +85,13 @@ func TestFetchTickerHistorical(t *testing.T) {
body: dynamicRatesXML,
check: func(r *http.Request) error {
if got := r.URL.Query().Get("VAL_NM_RQ"); got != "R01235" {
return fmt.Errorf("unexpected valute id: %s", got)
return fmt.Errorf("unexpected valute id: %s", got) //nolint:err113
}
if got := r.URL.Query().Get("date_req1"); got != "05/01/2023" {
return fmt.Errorf("unexpected date_req1: %s", got)
return fmt.Errorf("unexpected date_req1: %s", got) //nolint:err113
}
if got := r.URL.Query().Get("date_req2"); got != "05/01/2023" {
return fmt.Errorf("unexpected date_req2: %s", got)
return fmt.Errorf("unexpected date_req2: %s", got) //nolint:err113
}
return nil
},
@@ -111,6 +121,8 @@ func TestFetchTickerHistorical(t *testing.T) {
}
func TestFetchTickerUnknownCurrency(t *testing.T) {
t.Parallel()
transport := &stubRoundTripper{
responses: map[string]stubResponse{
"/scripts/XML_valFull.asp": {body: valuteDirectoryXML},
@@ -136,6 +148,8 @@ func TestFetchTickerUnknownCurrency(t *testing.T) {
}
func TestFetchTickerRespectsCustomPaths(t *testing.T) {
t.Parallel()
transport := &stubRoundTripper{
responses: map[string]stubResponse{
"/dir.xml": {body: valuteDirectoryXML},
@@ -200,11 +214,11 @@ type stubRoundTripper struct {
func (s *stubRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
if s.responses == nil {
return nil, fmt.Errorf("no responses configured")
return nil, fmt.Errorf("no responses configured") //nolint:err113
}
res, ok := s.responses[req.URL.Path]
if !ok {
return nil, fmt.Errorf("unexpected request path: %s", req.URL.Path)
return nil, fmt.Errorf("unexpected request path: %s", req.URL.Path) //nolint:err113
}
if res.check != nil {
if err := res.check(req); err != nil {

View File

@@ -5,6 +5,7 @@ import (
"net/http"
"strings"
"github.com/tech/sendico/pkg/mlogger"
"go.uber.org/zap"
)
@@ -17,7 +18,7 @@ const (
type httpClient struct {
client *http.Client
headers http.Header
logger *zap.Logger
logger mlogger.Logger
}
type httpClientOptions struct {
@@ -26,7 +27,7 @@ type httpClientOptions struct {
referer string
}
func newHTTPClient(logger *zap.Logger, client *http.Client, opts httpClientOptions) *httpClient {
func newHTTPClient(logger mlogger.Logger, client *http.Client, opts httpClientOptions) *httpClient {
userAgent := opts.userAgent
if strings.TrimSpace(userAgent) == "" {
userAgent = defaultUserAgent
@@ -41,20 +42,20 @@ func newHTTPClient(logger *zap.Logger, client *http.Client, opts httpClientOptio
if strings.TrimSpace(referer) == "" {
referer = defaultCBRBaseURL
}
l := logger.Named("http_client")
httpLogger := logger.Named("http_client")
headers := make(http.Header, 3)
headers.Set("User-Agent", userAgent)
headers.Set("Accept", accept)
headers.Set("Referer", referer)
l.Info("HTTP client initialized", zap.String("user_agent", userAgent),
httpLogger.Info("HTTP client initialized", zap.String("user_agent", userAgent),
zap.String("accept", accept), zap.String("referrer", referer))
return &httpClient{
client: client,
headers: headers,
logger: l,
logger: httpLogger,
}
}
@@ -68,6 +69,7 @@ func (h *httpClient) Do(req *http.Request) (*http.Response, error) {
if enriched.Header.Get(key) != "" {
continue
}
for _, value := range values {
enriched.Header.Add(key, value)
}

View File

@@ -29,29 +29,36 @@ type coingeckoConnector struct {
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
defaultDialTimeout = 5 * time.Second
defaultDialKeepAlive = 30 * time.Second
defaultTLSHandshakeTimeout = 5 * time.Second
defaultResponseHeaderTimeout = 10 * time.Second
defaultRequestTimeout = 10 * time.Second
)
func NewConnector(logger mlogger.Logger, settings model.SettingsT) (mmodel.Connector, error) {
const (
expectedSymbolParts = 2
tsToMillis = 1000
)
func NewConnector(logger mlogger.Logger, settings model.SettingsT) (mmodel.Connector, error) { //nolint:ireturn
baseURL := defaultCoinGeckoBaseURL
provider := strings.ToLower(mmodel.DriverCoinGecko.String())
dialTimeout := defaultDialTimeoutSeconds
dialKeepAlive := defaultDialKeepAliveSeconds
tlsHandshakeTimeout := defaultTLSHandshakeTimeoutSeconds
responseHeaderTimeout := defaultResponseHeaderTimeoutSeconds
requestTimeout := defaultRequestTimeoutSeconds
dialTimeout := defaultDialTimeout
dialKeepAlive := defaultDialKeepAlive
tlsHandshakeTimeout := defaultTLSHandshakeTimeout
responseHeaderTimeout := defaultResponseHeaderTimeout
requestTimeout := defaultRequestTimeout
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)
@@ -88,6 +95,7 @@ func (c *coingeckoConnector) ID() mmodel.Driver {
return c.id
}
//nolint:cyclop,funlen
func (c *coingeckoConnector) FetchTicker(ctx context.Context, symbol string) (*mmodel.Ticker, error) {
coinID, vsCurrency, err := parseSymbol(symbol)
if err != nil {
@@ -98,6 +106,7 @@ func (c *coingeckoConnector) FetchTicker(ctx context.Context, symbol string) (*m
if err != nil {
return nil, merrors.InternalWrap(err, "coingecko: parse base url")
}
endpoint.Path = strings.TrimRight(endpoint.Path, "/") + "/simple/price"
query := endpoint.Query()
query.Set("ids", coinID)
@@ -113,44 +122,51 @@ func (c *coingeckoConnector) FetchTicker(ctx context.Context, symbol string) (*m
resp, err := c.client.Do(req)
if err != nil {
c.logger.Warn("CoinGecko request failed", zap.String("symbol", symbol), zap.Error(err))
return nil, merrors.InternalWrap(err, "coingecko: request failed")
}
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, merrors.Internal("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, merrors.InternalWrap(err, "coingecko: decode response")
var payload map[string]map[string]any
decodeErr := decoder.Decode(&payload)
if decodeErr != nil {
c.logger.Warn("CoinGecko decode failed", zap.String("symbol", symbol), zap.Error(decodeErr))
return nil, merrors.InternalWrap(decodeErr, "coingecko: decode response")
}
coinData, ok := payload[coinID]
if !ok {
coinData, coinFound := payload[coinID]
if !coinFound {
return nil, merrors.Internal("coingecko: coin id not found in response")
}
priceValue, ok := coinData[vsCurrency]
if !ok {
priceValue, priceFound := coinData[vsCurrency]
if !priceFound {
return nil, merrors.Internal("coingecko: vs currency not found in response")
}
price, ok := toFloat(priceValue)
if !ok || price <= 0 {
price, priceOk := toFloat(priceValue)
if !priceOk || price <= 0 {
return nil, merrors.Internal("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 tsValue, tsFound := coinData["last_updated_at"]; tsFound {
if tsFloat, tsOk := toFloat(tsValue); tsOk && tsFloat > 0 {
tsMillis := int64(tsFloat * tsToMillis)
if tsMillis > 0 {
timestamp = tsMillis
}
@@ -179,14 +195,16 @@ func parseSymbol(symbol string) (string, string, error) {
case ':', '/', '-', '_':
return true
}
return false
})
if len(parts) != 2 {
if len(parts) != expectedSymbolParts {
return "", "", merrors.InvalidArgument("coingecko: symbol must be <coin_id>/<vs_currency>", "symbol")
}
coinID := strings.TrimSpace(parts[0])
vsCurrency := strings.TrimSpace(parts[1])
if coinID == "" || vsCurrency == "" {
return "", "", merrors.InvalidArgument("coingecko: symbol contains empty segments", "symbol")
@@ -195,28 +213,31 @@ func parseSymbol(symbol string) (string, string, error) {
return coinID, vsCurrency, nil
}
func toFloat(value interface{}) (float64, bool) {
switch v := value.(type) {
func toFloat(value any) (float64, bool) {
switch val := value.(type) {
case json.Number:
f, err := v.Float64()
f, err := val.Float64()
if err != nil {
return 0, false
}
return f, true
case float64:
return v, true
return val, true
case float32:
return float64(v), true
return float64(val), true
case int:
return float64(v), true
return float64(val), true
case int64:
return float64(v), true
return float64(val), true
case uint64:
return float64(v), true
return float64(val), true
case string:
if parsed, err := strconv.ParseFloat(v, 64); err == nil {
parsed, parseErr := strconv.ParseFloat(val, 64)
if parseErr == nil {
return parsed, true
}
}
return 0, false
}

View File

@@ -1,4 +1,4 @@
package common
package common //nolint:revive // package provides shared market connector utilities
import (
"strconv"
@@ -8,39 +8,46 @@ import (
)
// DurationSetting reads a positive duration override from settings or returns def when the value is missing or invalid.
//
//nolint:cyclop
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) {
switch val := value.(type) {
case time.Duration:
if v > 0 {
return v
if val > 0 {
return val
}
case int:
if v > 0 {
return time.Duration(v) * time.Second
if val > 0 {
return time.Duration(val) * time.Second
}
case int64:
if v > 0 {
return time.Duration(v) * time.Second
if val > 0 {
return time.Duration(val) * time.Second
}
case float64:
if v > 0 {
return time.Duration(v * float64(time.Second))
if val > 0 {
return time.Duration(val * float64(time.Second))
}
case string:
if parsed, err := time.ParseDuration(v); err == nil && parsed > 0 {
parsed, parseErr := time.ParseDuration(val)
if parseErr == nil && parsed > 0 {
return parsed
}
if seconds, err := strconv.ParseFloat(v, 64); err == nil && seconds > 0 {
seconds, floatErr := strconv.ParseFloat(val, 64)
if floatErr == nil && seconds > 0 {
return time.Duration(seconds * float64(time.Second))
}
}
return def
}

View File

@@ -24,16 +24,19 @@ const (
)
type Server interface {
SetStatus(health.ServiceStatus)
Close(context.Context)
SetStatus(status health.ServiceStatus)
Close(ctx context.Context)
}
//nolint:ireturn
func NewServer(logger mlogger.Logger, cfg *config.MetricsConfig) (Server, error) {
if logger == nil {
return nil, merrors.InvalidArgument("metrics: logger is nil")
}
if cfg == nil || !cfg.Enabled {
logger.Debug("Metrics disabled; using noop server")
return noopServer{}, nil
}
@@ -47,7 +50,9 @@ func NewServer(logger mlogger.Logger, cfg *config.MetricsConfig) (Server, error)
router.Handle("/metrics", promhttp.Handler())
var healthRouter routers.Health
if hr, err := routers.NewHealthRouter(metricsLogger, router, ""); err != nil {
hr, err := routers.NewHealthRouter(metricsLogger, router, "")
if err != nil {
metricsLogger.Warn("Failed to initialise health router", zap.Error(err))
} else {
hr.SetStatus(health.SSStarting)
@@ -60,7 +65,7 @@ func NewServer(logger mlogger.Logger, cfg *config.MetricsConfig) (Server, error)
ReadHeaderTimeout: readHeaderTimeout,
}
ms := &httpServerWrapper{
wrapper := &httpServerWrapper{
logger: metricsLogger,
server: httpServer,
health: healthRouter,
@@ -69,7 +74,9 @@ func NewServer(logger mlogger.Logger, cfg *config.MetricsConfig) (Server, error)
go func() {
metricsLogger.Info("Prometheus endpoint listening", zap.String("address", address))
if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
err := httpServer.ListenAndServe()
if err != nil && !errors.Is(err, http.ErrServerClosed) {
metricsLogger.Error("Prometheus endpoint stopped unexpectedly", zap.Error(err))
if healthRouter != nil {
healthRouter.SetStatus(health.SSTerminating)
@@ -77,7 +84,7 @@ func NewServer(logger mlogger.Logger, cfg *config.MetricsConfig) (Server, error)
}
}()
return ms, nil
return wrapper, nil
}
type httpServerWrapper struct {
@@ -91,6 +98,7 @@ 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)
}
@@ -110,10 +118,12 @@ func (s *httpServerWrapper) Close(ctx context.Context) {
return
}
//nolint:contextcheck
shutdownCtx := ctx
if shutdownCtx == nil {
shutdownCtx = context.Background()
}
if s.timeout > 0 {
var cancel context.CancelFunc
shutdownCtx, cancel = context.WithTimeout(shutdownCtx, s.timeout)
@@ -129,6 +139,6 @@ func (s *httpServerWrapper) Close(ctx context.Context) {
type noopServer struct{}
func (noopServer) SetStatus(health.ServiceStatus) {}
func (noopServer) SetStatus(_ health.ServiceStatus) {}
func (noopServer) Close(context.Context) {}
func (noopServer) Close(_ context.Context) {}

View File

@@ -26,16 +26,18 @@ func main() {
flag.Parse()
logger := lf.NewLogger(*debugFlag).Named("fx_ingestor")
logger = logger.With(zap.String("instance_id", discovery.InstanceID()))
defer logger.Sync()
av := appversion.Create()
logger = logger.With(zap.String("instance_id", discovery.InstanceID()))
defer logger.Sync() //nolint:errcheck
appVersion := appversion.Create()
if *versionFlag {
fmt.Fprintln(os.Stdout, av.Print())
fmt.Fprintln(os.Stdout, appVersion.Print())
return
}
logger.Info(fmt.Sprintf("Starting %s", av.Program()), zap.String("version", av.Info()))
logger.Info("Starting "+appVersion.Program(), zap.String("version", appVersion.Info()))
ctx, cancel := signalctx.WithSignals(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
@@ -47,8 +49,10 @@ func main() {
if err := application.Run(ctx); err != nil {
if errors.Is(err, context.Canceled) {
logger.Info("FX ingestor stopped")
return
}
logger.Error("Ingestor terminated with error", zap.Error(err))
}
}

View File

@@ -68,6 +68,7 @@ type Quote struct {
BaseAmount *moneyv1.Money
QuoteAmount *moneyv1.Money
ExpiresAt time.Time
PricedAt time.Time
Provider string
RateRef string
Firm bool
@@ -237,6 +238,10 @@ func fromProtoQuote(quote *oraclev1.Quote) *Quote {
if quote == nil {
return nil
}
pricedAt := time.Time{}
if ts := quote.GetPricedAt(); ts != nil {
pricedAt = ts.AsTime()
}
return &Quote{
QuoteRef: quote.GetQuoteRef(),
Pair: quote.Pair,
@@ -245,6 +250,7 @@ func fromProtoQuote(quote *oraclev1.Quote) *Quote {
BaseAmount: quote.BaseAmount,
QuoteAmount: quote.QuoteAmount,
ExpiresAt: time.UnixMilli(quote.GetExpiresAtUnixMs()),
PricedAt: pricedAt,
Provider: quote.GetProvider(),
RateRef: quote.GetRateRef(),
Firm: quote.GetFirm(),

View File

@@ -9,6 +9,7 @@ import (
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
"google.golang.org/grpc"
"google.golang.org/protobuf/types/known/timestamppb"
)
type stubOracle struct {
@@ -75,6 +76,7 @@ func TestLatestRate(t *testing.T) {
func TestGetQuote(t *testing.T) {
expiresAt := time.Date(2024, 2, 2, 12, 0, 0, 0, time.UTC)
pricedAt := time.Date(2024, 2, 2, 11, 59, 0, 0, time.UTC)
stub := &stubOracle{
quoteResp: &oraclev1.GetQuoteResponse{
Quote: &oraclev1.Quote{
@@ -85,6 +87,7 @@ func TestGetQuote(t *testing.T) {
BaseAmount: &moneyv1.Money{Amount: "100.00", Currency: "GBP"},
QuoteAmount: &moneyv1.Money{Amount: "125.00", Currency: "USD"},
ExpiresAtUnixMs: expiresAt.UnixMilli(),
PricedAt: timestamppb.New(pricedAt),
Provider: "Test",
RateRef: "test-ref",
Firm: true,
@@ -113,4 +116,7 @@ func TestGetQuote(t *testing.T) {
if resp.QuoteRef != "quote-123" || resp.Price != "1.2500" || !resp.ExpiresAt.Equal(expiresAt) {
t.Fatalf("unexpected quote response: %+v", resp)
}
if !resp.PricedAt.Equal(pricedAt) {
t.Fatalf("expected priced_at %s, got %s", pricedAt, resp.PricedAt)
}
}

View File

@@ -11,6 +11,8 @@ grpc:
metrics:
address: ":9400"
max_quote_ttl_ms: 600000
database:
driver: mongodb
settings:

View File

@@ -11,6 +11,8 @@ grpc:
metrics:
address: ":9400"
max_quote_ttl_ms: 600000
database:
driver: mongodb
settings:

View File

@@ -1,6 +1,6 @@
module github.com/tech/sendico/fx/oracle
go 1.25.6
go 1.25.7
replace github.com/tech/sendico/pkg => ../../pkg
@@ -13,7 +13,7 @@ require (
github.com/tech/sendico/pkg v0.1.0
go.mongodb.org/mongo-driver/v2 v2.5.0
go.uber.org/zap v1.27.1
google.golang.org/grpc v1.78.0
google.golang.org/grpc v1.79.1
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1
)
@@ -25,28 +25,28 @@ require (
github.com/casbin/govaluate v1.10.0 // indirect
github.com/casbin/mongodb-adapter/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/go-chi/chi/v5 v5.2.4 // indirect
github.com/klauspost/compress v1.18.3 // indirect
github.com/go-chi/chi/v5 v5.2.5 // indirect
github.com/klauspost/compress v1.18.4 // 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/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nats-io/nats.go v1.48.0 // indirect
github.com/nats-io/nats.go v1.49.0 // indirect
github.com/nats-io/nkeys v0.4.15 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/procfs v0.19.2 // indirect
github.com/prometheus/procfs v0.20.0 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.2.0 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
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.47.0 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
)

View File

@@ -38,8 +38,8 @@ 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.4 h1:WtFKPHwlywe8Srng8j2BhOD9312j9cGUxG1SP4V2cR4=
github.com/go-chi/chi/v5 v5.2.4/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
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=
@@ -57,8 +57,8 @@ 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.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.4/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=
@@ -91,8 +91,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
github.com/nats-io/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE=
github.com/nats-io/nats.go v1.49.0/go.mod h1:fDCn3mN5cY8HooHwE2ukiLb4p4G4ImmzvXyJt+tGwdw=
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
@@ -113,8 +113,8 @@ github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNw
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
github.com/prometheus/procfs v0.20.0 h1:AA7aCvjxwAquZAlonN7888f2u4IN8WVeFgBi4k82M4Q=
github.com/prometheus/procfs v0.20.0/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
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=
@@ -152,16 +152,16 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 h1:UP6IpuHFkUgOQL9FFQFrZ+5LiwhhYRbi7VZSIx6Nj5s=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM=
go.opentelemetry.io/otel v1.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.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
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=
@@ -172,15 +172,15 @@ 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.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
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.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
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.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
@@ -191,16 +191,16 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
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.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.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.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
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=
@@ -208,10 +208,10 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b h1:GZxXGdFaHX27ZSMHudWc4FokdD+xl8BC2UJm1OVIEzs=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -22,11 +22,28 @@ type Imp struct {
file string
debug bool
config *grpcapp.Config
config *config
app *grpcapp.App[storage.Repository]
service *oracle.Service
}
type config struct {
*grpcapp.Config `yaml:",inline"`
MaxQuoteTTLMs int64 `yaml:"max_quote_ttl_ms"`
}
const (
defaultMaxQuoteTTL = 10 * time.Minute
defaultMaxQuoteTTLMillis = int64(defaultMaxQuoteTTL / time.Millisecond)
)
func (c *config) maxQuoteTTLMillis() int64 {
if c == nil || c.MaxQuoteTTLMs <= 0 {
return defaultMaxQuoteTTLMillis
}
return c.MaxQuoteTTLMs
}
func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) {
return &Imp{
logger: logger.Named("server"),
@@ -63,12 +80,18 @@ func (i *Imp) Start() error {
}
serviceFactory := func(logger mlogger.Logger, repo storage.Repository, producer msg.Producer) (grpcapp.Service, error) {
svc := oracle.NewService(logger, repo, producer, cfg.GRPC.DiscoveryInvokeURI())
svc := oracle.NewService(
logger,
repo,
producer,
cfg.GRPC.DiscoveryInvokeURI(),
oracle.WithMaxQuoteTTLMillis(cfg.maxQuoteTTLMillis()),
)
i.service = svc
return svc, nil
}
app, err := grpcapp.NewApp(i.logger, "fx", cfg, i.debug, repoFactory, serviceFactory)
app, err := grpcapp.NewApp(i.logger, "fx", cfg.Config, i.debug, repoFactory, serviceFactory)
if err != nil {
return err
}
@@ -77,14 +100,14 @@ func (i *Imp) Start() error {
return i.app.Start()
}
func (i *Imp) loadConfig() (*grpcapp.Config, error) {
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 := &grpcapp.Config{}
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

View File

@@ -130,6 +130,10 @@ func (qc *quoteComputation) buildModelQuote(firm bool, expiryMillis int64, req *
if qc.baseRounded == nil || qc.quoteRounded == nil || qc.priceRounded == nil {
return nil, merrors.Internal("oracle: computation not executed")
}
pricedAtUnixMs := qc.rate.AsOfUnixMs
if pricedAtUnixMs <= 0 {
pricedAtUnixMs = time.Now().UnixMilli()
}
quote := &model.Quote{
QuoteRef: uuid.NewString(),
@@ -147,6 +151,7 @@ func (qc *quoteComputation) buildModelQuote(firm bool, expiryMillis int64, req *
Amount: formatRat(qc.quoteRounded, qc.quoteScale),
},
AmountType: qc.amountType,
PricedAtUnixMs: pricedAtUnixMs,
RateRef: qc.rate.RateRef,
Provider: qc.provider,
PreferredProvider: req.GetPreferredProvider(),

View File

@@ -28,6 +28,14 @@ func (e serviceError) Error() string {
return string(e)
}
const (
defaultMaxQuoteTTL = 10 * time.Minute
defaultMaxQuoteTTLMillis = int64(defaultMaxQuoteTTL / time.Millisecond)
)
// Option configures oracle service behavior.
type Option func(*Service)
var (
errSideRequired = serviceError("oracle: side is required")
errAmountsMutuallyExclusive = serviceError("oracle: exactly one amount must be provided")
@@ -38,21 +46,40 @@ var (
)
type Service struct {
logger mlogger.Logger
storage storage.Repository
producer pmessaging.Producer
announcer *discovery.Announcer
invokeURI string
logger mlogger.Logger
storage storage.Repository
producer pmessaging.Producer
announcer *discovery.Announcer
invokeURI string
maxQuoteTTLMillis int64
oraclev1.UnimplementedOracleServer
}
func NewService(logger mlogger.Logger, repo storage.Repository, prod pmessaging.Producer, invokeURI string) *Service {
// WithMaxQuoteTTLMillis caps firm quote TTL requests to the supplied number of milliseconds.
func WithMaxQuoteTTLMillis(value int64) Option {
return func(s *Service) {
if value > 0 {
s.maxQuoteTTLMillis = value
}
}
}
func NewService(logger mlogger.Logger, repo storage.Repository, prod pmessaging.Producer, invokeURI string, opts ...Option) *Service {
initMetrics()
svc := &Service{
logger: logger.Named("oracle"),
storage: repo,
producer: prod,
invokeURI: strings.TrimSpace(invokeURI),
logger: logger.Named("oracle"),
storage: repo,
producer: prod,
invokeURI: strings.TrimSpace(invokeURI),
maxQuoteTTLMillis: defaultMaxQuoteTTLMillis,
}
for _, opt := range opts {
if opt != nil {
opt(svc)
}
}
if svc.maxQuoteTTLMillis <= 0 {
svc.maxQuoteTTLMillis = defaultMaxQuoteTTLMillis
}
svc.startDiscoveryAnnouncer()
return svc
@@ -79,7 +106,7 @@ func (s *Service) startDiscoveryAnnouncer() {
}
announce := discovery.Announcement{
Service: "FX_ORACLE",
Operations: []string{"fx.quote"},
Operations: []string{discovery.OperationFXQuote},
InvokeURI: s.invokeURI,
Version: appversion.Create().Short(),
}
@@ -222,7 +249,16 @@ func (s *Service) getQuoteResponder(ctx context.Context, req *oraclev1.GetQuoteR
expiresAt := int64(0)
if req.GetFirm() {
expiry, err := computeExpiry(now, req.GetTtlMs())
ttlMs := req.GetTtlMs()
if ttlMs > s.maxQuoteTTLMillis {
logger.Info(
"Clamping requested firm quote ttl to configured maximum",
zap.Int64("requested_ttl_ms", ttlMs),
zap.Int64("max_ttl_ms", s.maxQuoteTTLMillis),
)
ttlMs = s.maxQuoteTTLMillis
}
expiry, err := computeExpiry(now, ttlMs)
if err != nil {
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
}

View File

@@ -111,6 +111,7 @@ func (currencyStoreStub) List(ctx context.Context, codes ...string) ([]*model.Cu
func (currencyStoreStub) Upsert(ctx context.Context, currency *model.Currency) error { return nil }
func TestServiceGetQuoteFirm(t *testing.T) {
pricedAt := time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC)
repo := &repositoryStub{}
repo.pairs = &pairStoreStub{
getFn: func(ctx context.Context, pair model.CurrencyPair) (*model.Pair, error) {
@@ -129,7 +130,7 @@ func TestServiceGetQuoteFirm(t *testing.T) {
Ask: "1.10",
Bid: "1.08",
RateRef: "rate#1",
AsOfUnixMs: time.Now().UnixMilli(),
AsOfUnixMs: pricedAt.UnixMilli(),
}, nil
},
}
@@ -169,9 +170,71 @@ func TestServiceGetQuoteFirm(t *testing.T) {
if resp.GetQuote().GetQuoteAmount().GetAmount() != "110.00" {
t.Fatalf("unexpected quote amount: %s", resp.GetQuote().GetQuoteAmount().GetAmount())
}
if got := resp.GetQuote().GetPricedAt(); got == nil || !got.AsTime().Equal(pricedAt) {
t.Fatalf("expected priced_at %s, got %v", pricedAt, got)
}
if savedQuote.QuoteRef == "" {
t.Fatalf("expected quote persisted")
}
if savedQuote.PricedAtUnixMs != pricedAt.UnixMilli() {
t.Fatalf("expected stored pricedAtUnixMs %d, got %d", pricedAt.UnixMilli(), savedQuote.PricedAtUnixMs)
}
}
func TestServiceGetQuoteFirm_ClampsTTLToConfiguredMax(t *testing.T) {
const (
configuredMaxTTL = 1 * time.Second
requestedTTL = 1 * time.Minute
)
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
},
}
repo.quotes = &quotesStoreStub{}
repo.currencies = currencyStoreStub{}
svc := NewService(zap.NewNop(), repo, nil, "", WithMaxQuoteTTLMillis(int64(configuredMaxTTL/time.Millisecond)))
start := time.Now()
resp, 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: "100",
}},
Firm: true,
TtlMs: int64(requestedTTL / time.Millisecond),
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
expiry := time.UnixMilli(resp.GetQuote().GetExpiresAtUnixMs())
if expiry.Before(start) {
t.Fatalf("expected expiry after request start, got %s", expiry)
}
if expiry.After(start.Add(5 * time.Second)) {
t.Fatalf("expected clamped expiry close to 1s max ttl, got %s", expiry)
}
}
func TestServiceGetQuoteRateNotFound(t *testing.T) {

View File

@@ -2,12 +2,14 @@ package oracle
import (
"strings"
"time"
"github.com/tech/sendico/fx/storage/model"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
"google.golang.org/protobuf/types/known/timestamppb"
)
func buildResponseMeta(meta *oraclev1.RequestMeta) *oraclev1.ResponseMeta {
@@ -36,6 +38,7 @@ func quoteModelToProto(q *model.Quote) *oraclev1.Quote {
BaseAmount: moneyModelToProto(&q.BaseAmount),
QuoteAmount: moneyModelToProto(&q.QuoteAmount),
ExpiresAtUnixMs: q.ExpiresAtUnixMs,
PricedAt: timestampFromUnixMillis(q.PricedAtUnixMs, q.CreatedAt),
Provider: q.Provider,
RateRef: q.RateRef,
Firm: q.Firm,
@@ -117,3 +120,13 @@ func decimalStringToProto(value string) *moneyv1.Decimal {
}
return &moneyv1.Decimal{Value: value}
}
func timestampFromUnixMillis(ms int64, fallback time.Time) *timestamppb.Timestamp {
if ms > 0 {
return timestamppb.New(time.UnixMilli(ms).UTC())
}
if !fallback.IsZero() {
return timestamppb.New(fallback.UTC())
}
return nil
}

View File

@@ -1,6 +1,6 @@
module github.com/tech/sendico/fx/storage
go 1.25.6
go 1.25.7
replace github.com/tech/sendico/pkg => ../../pkg
@@ -16,15 +16,15 @@ require (
github.com/casbin/govaluate v1.10.0 // indirect
github.com/casbin/mongodb-adapter/v4 v4.3.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/compress v1.18.3 // indirect
github.com/klauspost/compress v1.18.4 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.2.0 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/crypto v0.47.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/text v0.33.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)

View File

@@ -16,6 +16,8 @@ github.com/casbin/mongodb-adapter/v4 v4.3.0 h1:yYXky9v1by6vj/0QK7OyHyd/xpz4vzh0l
github.com/casbin/mongodb-adapter/v4 v4.3.0/go.mod h1:bOTSYZUjX7I9E0ExEvgq46m3mcDNRII7g8iWjrM1BHE=
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=
@@ -49,8 +51,8 @@ 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.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.4/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=
@@ -120,12 +122,12 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 h1:UP6IpuHFkUgOQL9FFQFrZ+5LiwhhYRbi7VZSIx6Nj5s=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM=
go.opentelemetry.io/otel v1.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.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
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=
@@ -134,8 +136,8 @@ 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.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
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=
@@ -150,16 +152,16 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
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.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.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.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
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=

View File

@@ -21,6 +21,7 @@ type Quote struct {
QuoteAmount paymenttypes.Money `bson:"quoteAmount" json:"quoteAmount"`
AmountType QuoteAmountType `bson:"amountType" json:"amountType"`
ExpiresAtUnixMs int64 `bson:"expiresAtUnixMs" json:"expiresAtUnixMs"`
PricedAtUnixMs int64 `bson:"pricedAtUnixMs,omitempty" json:"pricedAtUnixMs,omitempty"`
ExpiresAt *time.Time `bson:"expiresAt,omitempty" json:"expiresAt,omitempty"`
RateRef string `bson:"rateRef" json:"rateRef"`
Provider string `bson:"provider" json:"provider"`

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