Compare commits
791 Commits
devKA
...
b481de9ffc
| Author | SHA1 | Date | |
|---|---|---|---|
| b481de9ffc | |||
|
|
0c29e7686d | ||
| 5b26a70a15 | |||
|
|
b832c2a7c4 | ||
| 15393765b9 | |||
|
|
440b6a2553 | ||
| bc76cfe063 | |||
|
|
ed8f7c519c | ||
|
|
71d99338f2 | ||
| b499778bce | |||
|
|
4a554833c4 | ||
| b7ea11a62b | |||
|
|
026f698d9b | ||
| 0da6078468 | |||
|
|
3b65a2dc3a | ||
| a9b00b6871 | |||
| d64ad89072 | |||
|
|
4a5e26b03a | ||
|
|
d61eee99bc | ||
| 1e376da719 | |||
| a8b0c70b65 | |||
|
|
8981d296c8 | ||
|
|
7e5a98acd7 | ||
| 8577239dd6 | |||
|
|
5e59fea7e5 | ||
| 801f349aa8 | |||
|
|
d1e47841cc | ||
| 364731a8c7 | |||
|
|
519a2b1304 | ||
| d027f2deda | |||
|
|
ba5a3312b5 | ||
| f2c9685eb1 | |||
|
|
e80cb3eed1 | ||
| 5f647904d7 | |||
|
|
b6f05f52dc | ||
| 75555520f3 | |||
|
|
d666c4ce51 | ||
| 706a57e860 | |||
|
|
f7b0915303 | ||
|
|
c59538869b | ||
|
|
aff804ec58 | ||
| 2bab8371b8 | |||
|
|
af8ab8238e | ||
|
|
92a6191014 | ||
| 80b25a8608 | |||
| 17d954c689 | |||
|
|
349e8afdc5 | ||
|
|
8a1e44c038 | ||
|
|
3fcbbfb08a | ||
|
|
75f3678b90 | ||
| 4fa641f971 | |||
|
|
eb8b7b3402 | ||
| 3a8935f5f0 | |||
|
|
d92be5eedc | ||
| 94406373e6 | |||
| eda5bf19ad | |||
|
|
5629f5fcb2 | ||
| 95f7698661 | |||
|
|
4e70873a94 | ||
|
|
de07b9a792 | ||
| 9b794a3065 | |||
|
|
56bf49aa03 | ||
|
|
8377b6b2af | ||
| f06208348b | |||
|
|
b4c09cfb3b | ||
| 00812fa2bd | |||
|
|
ce5f90939f | ||
| 1b40b173eb | |||
| cd8e8071a9 | |||
|
|
f7a1027de7 | ||
| c5b3dfbd7a | |||
|
|
41cb826d26 | ||
|
|
51c72a87ae | ||
|
|
3f578353da | ||
| 7cac494509 | |||
|
|
d8f0febc5e | ||
| 34a8a5d057 | |||
|
|
83745bcd10 | ||
|
|
f9acb47ad7 | ||
| b2cc3fe980 | |||
|
|
d28e8615a9 | ||
| 3d1157a5d3 | |||
|
|
bae4cd6e35 | ||
| bd79eb016a | |||
|
|
b10ec79fe0 | ||
| 4b57550c36 | |||
|
|
0f0529c445 | ||
| 01c4108157 | |||
|
|
3c6cffdf33 | ||
| 82bab11a8f | |||
|
|
2f77d9d972 | ||
| 7559d4d09b | |||
| a1e739ba52 | |||
|
|
2be76aa519 | ||
|
|
6bb3ab5063 | ||
| 17e08ff26f | |||
|
|
e5ba048c73 | ||
| ddd5e36275 | |||
|
|
ce23de94ce | ||
| 1005201bb7 | |||
|
|
38077c1ed8 | ||
| d0368f5a00 | |||
|
|
86eab3bb70 | ||
| 709df51512 | |||
|
|
f914575a65 | ||
|
|
a6d560eb10 | ||
| 3cbe07a1ec | |||
|
|
598510f487 | ||
|
|
12c67361dd | ||
| 363d6474f2 | |||
|
|
004355f7d5 | ||
| 1f67fba3e4 | |||
|
|
4c3132bbc1 | ||
|
|
0f28f2d088 | ||
| b7900d3beb | |||
|
|
800f8c12f8 | ||
| f50313c30b | |||
|
|
98db0e4e9e | ||
| 34182af3b8 | |||
|
|
d47159278b | ||
| 077c510690 | |||
|
|
605f0ba139 | ||
| 02a0d192b9 | |||
|
|
128e5392e1 | ||
| c462b8fa69 | |||
|
|
bec969cf8b | ||
| 92253de6f3 | |||
|
|
6fe6b7a932 | ||
| fea71779b9 | |||
|
|
d1ebd4b009 | ||
|
|
651f103abd | ||
| 57428c5c56 | |||
|
|
5113568a1b | ||
| 5f4e580b2c | |||
|
|
039a41e6d8 | ||
| 98770eda1e | |||
|
|
fc5d44364b | ||
| 55ddfff4f2 | |||
|
|
747153bdbf | ||
| 82cf91e703 | |||
|
|
9fd9cba92a | ||
| 4ea39c44ef | |||
|
|
7d6d7c3f56 | ||
| 560e2213b5 | |||
|
|
a794aff5f3 | ||
|
|
fa9e6f47cf | ||
|
|
947cd7f4c9 | ||
|
|
20ce4485e8 | ||
|
|
e8d763eb15 | ||
|
|
4949c4ffe0 | ||
|
|
7661038868 | ||
|
|
0f95f898a8 | ||
|
|
b4b5616de0 | ||
|
|
336f352858 | ||
|
|
54e5c799e8 | ||
|
|
70b1c2a9cc | ||
|
|
008427483c | ||
|
|
7235ca1897 | ||
|
|
53abb24482 | ||
| 84cd23d5a1 | |||
|
|
d05a5430f1 | ||
|
|
70359916b9 | ||
| ebde599c38 | |||
|
|
26bedc5743 | ||
|
|
38b347f02e | ||
|
|
af4b68f4c7 | ||
| 6c98dc2c0c | |||
|
|
dd61fe155f | ||
| 11c37d286c | |||
|
|
d70d9e84c9 | ||
|
|
da11be526a | ||
|
|
fa54088b25 | ||
|
|
a998b59072 | ||
|
|
4c5677202a | ||
|
|
2e08ec9b9b | ||
|
|
2fe90347a8 | ||
|
|
6444813f38 | ||
| 0646f55189 | |||
|
|
0c6fa03aba | ||
| a68aa2abff | |||
|
|
51514159f5 | ||
| 199811ba09 | |||
|
|
671ccc55a0 | ||
| bc2bc3770d | |||
|
|
20cb057618 | ||
| e23484ddff | |||
|
|
4344ae89e0 | ||
| 352a4a524b | |||
|
|
2bf05ab414 | ||
| 14eb99e221 | |||
|
|
afd8d8d01e | ||
| b6cced6947 | |||
|
|
2fd8a6ebb7 | ||
| 6d21f08e10 | |||
|
|
47f0a3d890 | ||
| 9f3ba8f610 | |||
|
|
578a2cd1d7 | ||
| e399e13b56 | |||
|
|
1c5d3d202b | ||
| a33be56247 | |||
| 3a310caeb6 | |||
|
|
770c7b9da9 | ||
|
|
e901ac3eb6 | ||
| 4dc182bfa2 | |||
|
|
69531cee73 | ||
|
|
974caf286c | ||
| 854a6af2a6 | |||
| 2707acedae | |||
|
|
9bdb667b08 | ||
|
|
e2e2257167 | ||
|
|
0eea39fb97 | ||
| 11d4b9a608 | |||
|
|
fe3f6ca79a | ||
| 2070c35c61 | |||
|
|
138ab00474 | ||
| 930e8d08cd | |||
|
|
33ea643dc3 | ||
| 4c51742ec6 | |||
|
|
7c76ca7f56 | ||
| e77baf1687 | |||
| 2c2512ead8 | |||
|
|
5dd1b5f4d7 | ||
| de48205e68 | |||
|
|
89ac299688 | ||
| 1c564daa41 | |||
|
|
71296800ef | ||
| ae873caa57 | |||
|
|
01020bb694 | ||
| e6232f9b1d | |||
|
|
52c4c046c9 | ||
| da1636014b | |||
|
|
8704a968d2 | ||
| 08dccae175 | |||
|
|
91dc2bd844 | ||
| f0b14a8bbd | |||
|
|
1b3aa0f9ea | ||
| 71a7a474c8 | |||
|
|
16c44ec7d3 | ||
| fd1f5498b4 | |||
|
|
b5db65ef78 | ||
| 44a22ce962 | |||
|
|
a862e27087 | ||
| b80dca0ce9 | |||
|
|
e605c734ad | ||
| 55624aa8b5 | |||
|
|
fcbfa323c8 | ||
| 33c83b6768 | |||
|
|
45d3c3145c | ||
| ea68d161d6 | |||
| 6a0289963b | |||
|
|
853b855049 | ||
| e767436f33 | |||
|
|
57914c0754 | ||
| b894f92e50 | |||
|
|
76548032b3 | ||
| d121f4172e | |||
|
|
e717cabd0f | ||
| 8fead967d1 | |||
|
|
a09fd550ba | ||
| 8eae9270f2 | |||
|
|
dc6a4224a0 | ||
| a99a3c851c | |||
|
|
9bca523caa | ||
|
|
e36fb88a9a | ||
| 27de7a9655 | |||
|
|
7cbcbb4b3c | ||
| fee10afbb8 | |||
|
|
97395acd8f | ||
| f4b43f7218 | |||
|
|
3c0d709a82 | ||
| b787cc4d9e | |||
|
|
7b53ca6cef | ||
| fab07fdc8e | |||
|
|
e116535926 | ||
| 9b8f59e05a | |||
|
|
6844d1e587 | ||
| 3225babeca | |||
|
|
0a1e04d0d6 | ||
| 648ace709a | |||
|
|
274035ed6d | ||
| 0aab5cceb0 | |||
|
|
9b97ebfa6c | ||
| b02df379a7 | |||
|
|
f5052f6cf8 | ||
|
|
edb43f9909 | ||
| 7524852533 | |||
|
|
cfb219e206 | ||
| 66989ea36c | |||
|
|
296cc7b86a | ||
| 6745bc0f6f | |||
|
|
c962ac7cbd | ||
| 817d4357cf | |||
|
|
2711d601b0 | ||
| 74b0976cb7 | |||
|
|
3c82afbd43 | ||
| 32688a2de7 | |||
|
|
f2938daddd | ||
| 7c26698f0d | |||
|
|
461a340b08 | ||
| dadf9e2485 | |||
|
|
f59789ab7a | ||
| 5e92d7b9ff | |||
|
|
3578ddd54f | ||
| 9463c7ce1f | |||
|
|
7c182afd23 | ||
|
|
7f540671c1 | ||
|
|
76c3bfdea9 | ||
|
|
eda6b75f74 | ||
| 5caf46ffe1 | |||
|
|
c8b8b1183b | ||
| 17bc2a2a62 | |||
|
|
706861af5f | ||
|
|
f8a3bef2e6 | ||
| 4639b2c610 | |||
|
|
b9748b8ab2 | ||
| 8034847e46 | |||
|
|
49b0b63f55 | ||
| 5984f2c2f7 | |||
|
|
bc50391fe7 | ||
| 67598449e6 | |||
|
|
761dda9377 | ||
| 42da0260b0 | |||
|
|
5df02baa80 | ||
| 7417b33de3 | |||
|
|
217542ec14 | ||
| e394770eb1 | |||
|
|
611bde214f | ||
|
|
d3e69bcd62 | ||
| fb9def8c19 | |||
|
|
6e3115e7fa | ||
| 0675978bd1 | |||
|
|
04391cbd8d | ||
| 87f320802d | |||
|
|
3a4f1c7e3f | ||
| 542d88750d | |||
|
|
b27eed31b7 | ||
|
|
69ed9f25cb | ||
|
|
e4fb270390 | ||
|
|
81ffdd4291 | ||
|
|
0ce90eef21 | ||
| 509af9bc5c | |||
|
|
bc0fd6ab2f | ||
| ca0e45ee5f | |||
| 3cf6b10ea7 | |||
|
|
1bdbbf65a4 | ||
|
|
fe9133c206 | ||
| b722d61c4f | |||
|
|
f56a3d4611 | ||
| afa842ba65 | |||
|
|
ccc9737d1b | ||
| a569757b7f | |||
|
|
43b252859e | ||
| e1c439fb85 | |||
|
|
571cea3b37 | ||
| 6198ceb9a4 | |||
|
|
f02f28d53f | ||
| f1f16a30e6 | |||
|
|
979a0ac917 | ||
| 7d69e52679 | |||
|
|
bbf781bba3 | ||
| 156152ede3 | |||
|
|
446d4d737c | ||
| b7ce4f7f4a | |||
|
|
fccc29b8d8 | ||
| 3cdbf4ae31 | |||
|
|
70cf849dfa | ||
| e78011c234 | |||
|
|
58e4ad379c | ||
| 9255c7998d | |||
|
|
5e87e2f2f9 | ||
| 05d998e0f7 | |||
|
|
8faed5cbaa | ||
| be3fd6075f | |||
|
|
c16fa8104e | ||
| 433a0af5b4 | |||
|
|
8469d93cee | ||
| fcd86816c6 | |||
|
|
a8bf3b69e2 | ||
| c0cb7e6521 | |||
|
|
5fa15d39c8 | ||
| b2e8ff3279 | |||
|
|
31adbd40cb | ||
| bea8f1bbf5 | |||
|
|
d42dfb6070 | ||
|
|
1aa7e287fb | ||
| cbb7bd8ba6 | |||
| c499fb7f9b | |||
|
|
abf1a39d1a | ||
|
|
8377b26c86 | ||
| 9aaac3fdf2 | |||
|
|
c2867a6ab1 | ||
|
|
4064907921 | ||
| 37c8500811 | |||
| d16b64a9f2 | |||
| a9673c96c0 | |||
| 0c0039888f | |||
| 4648d3ec0e | |||
| 4d58fdb1f1 | |||
| 6e66634ef0 | |||
| 9819946449 | |||
| 70d06db1dc | |||
| 25e9744827 | |||
| ffc3d46c03 | |||
| a2f5fc1854 | |||
| 73ad422d9c | |||
| ac1e974d68 | |||
| 8e08c401bc | |||
| be4ade91c4 | |||
|
|
c1596296d1 | ||
|
|
102c5d3668 | ||
|
|
e1f58b0982 | ||
|
|
809370bda8 | ||
|
|
73fadc9eb2 | ||
|
|
4a11bcb2c5 | ||
|
|
17dde423f6 | ||
|
|
8788ff67ec | ||
|
|
c7c05ad8f6 | ||
|
|
0e7f6ac9b2 | ||
|
|
bc46eccbe0 | ||
|
|
9417bfd456 | ||
|
|
c27b322b22 | ||
|
|
f980d2caea | ||
|
|
2cbc75bb8e | ||
|
|
d5016547d0 | ||
|
|
7fbd88b6ef | ||
| 51f5b0804a | |||
|
|
a3908cebba | ||
|
|
efa69b43b2 | ||
| da8da04ae9 | |||
| 8747d0be05 | |||
|
|
0a27e0116a | ||
|
|
97dec6a8a0 | ||
| d78fe5daa0 | |||
|
|
6a3a60ef19 | ||
|
|
be1d678c42 | ||
| e5cd0c9433 | |||
|
|
2bd3cef94a | ||
| 7391a67f82 | |||
|
|
81ba682d18 | ||
| dce0f2b467 | |||
|
|
0b2a993b6d | ||
| 5577bea038 | |||
| bd99a8d398 | |||
|
|
3c0993686d | ||
|
|
197a0ba610 | ||
| 97448a2f15 | |||
|
|
54f2f96e76 | ||
| b349f2867f | |||
|
|
dcf8bdfba0 | ||
| 79b5d11ef4 | |||
|
|
3765780a4d | ||
| 5161825bae | |||
|
|
debabf060d | ||
| f90bb53309 | |||
|
|
e6519f4ba6 | ||
| bdb84ac6ac | |||
|
|
d7e029d421 | ||
| 63d57d8a10 | |||
|
|
41f653775f | ||
|
|
218c4e20b3 | ||
| b677d37b99 | |||
|
|
980c9fc9c7 | ||
| c3226cb59e | |||
|
|
7ae32cac55 | ||
| 1b59823105 | |||
|
|
71753e09ba | ||
| f202acd8ab | |||
|
|
acb257334f | ||
| 1f135924f6 | |||
|
|
32e8376700 | ||
| 8456263dd8 | |||
|
|
8e46bc6061 | ||
|
|
62ff96b90e | ||
| eb4e7bc06a | |||
| 9d9d1ebdeb | |||
|
|
636afe5d25 | ||
|
|
6284625977 | ||
|
|
8cb19f897a | ||
| 1e5ff51e07 | |||
| 859aeee08b | |||
|
|
bd5dfb4f26 | ||
|
|
d374bdc66c | ||
| 87f99be01d | |||
|
|
d2e78356e6 | ||
| a15375f18e | |||
|
|
0e933ace58 | ||
| abc4ddfb5b | |||
|
|
e0d7320fad | ||
| ae6c617136 | |||
|
|
5737ebf89a | ||
| d9d2fded8a | |||
|
|
f10ad5ba1e | ||
| 1e18764a75 | |||
|
|
069ff6b26b | ||
| 0b2a13feff | |||
|
|
2adcdb0b43 | ||
|
|
f84d52ac14 | ||
| c13ea01130 | |||
|
|
64803a21e0 | ||
| 1eb5a918a0 | |||
|
|
5cd8ac0519 | ||
| 1d21991cd7 | |||
|
|
3db1681a6e | ||
| 66a3961cef | |||
|
|
4ddfdc10be | ||
| 10a2e5e528 | |||
|
|
0082b590ac | ||
|
|
426954ed20 | ||
|
|
a4333a5385 | ||
| fe0caaa611 | |||
|
|
4cd4bfe7be | ||
| 67a9b72a3f | |||
| 000d3c179b | |||
|
|
0d444e5ff4 | ||
| e398810c70 | |||
|
|
455afe99e7 | ||
| 4f8e0b5e6c | |||
|
|
0d6e16711f | ||
| b512da71de | |||
|
|
4786df60ae | ||
| c775b3fdd4 | |||
|
|
6a2d1b34e4 | ||
|
|
3242fc3744 | ||
|
|
9319108b26 | ||
| 6bc5130883 | |||
| 3a7b2a3b28 | |||
| 0245b679d2 | |||
|
|
343911ebe7 | ||
|
|
7fb413fd9b | ||
|
|
abb428e891 | ||
| 62bc2644d4 | |||
| aa70f54021 | |||
|
|
5447433b5d | ||
|
|
0cb5101ed9 | ||
| dedde76dd7 | |||
|
|
9e747e7251 | ||
| 33647a0f3d | |||
|
|
890f78a42e | ||
| c0ba167f69 | |||
|
|
3aa5d56cc3 | ||
| 326fc5a885 | |||
|
|
43edbc109d | ||
| 12700c5595 | |||
|
|
4da9e0b522 | ||
| 5d443230f4 | |||
|
|
3e83cc51d7 | ||
| 9c2ef52d07 | |||
|
|
e84854d875 | ||
|
|
2f34b5a827 | ||
| 9a5c087940 | |||
|
|
4fb2e0433c | ||
| cd89171cf0 | |||
|
|
7424ef751c | ||
| fcd831902a | |||
|
|
03f4988a99 | ||
|
|
5684a959f5 | ||
| 94406f65cb | |||
|
|
49ba144d8c | ||
| 2c5f2b8cb1 | |||
|
|
ee28c13558 | ||
| 6a57afc057 | |||
|
|
59c83e414a | ||
|
|
743f683d92 | ||
|
|
ea1c69f14a | ||
|
|
97ba7500dc | ||
| 19b7b69bd8 | |||
|
|
b157522fdb | ||
| 202582626a | |||
|
|
6a2efd3d22 | ||
| a6374d1136 | |||
|
|
7c864dc304 | ||
| 4aeb06fd31 | |||
|
|
d1786dc5d9 | ||
| f5bf8cf6d0 | |||
| 7daa4ab027 | |||
|
|
6f2309669b | ||
|
|
e4847cd137 | ||
| dbd06a4162 | |||
|
|
1ec6cd8386 | ||
| 6daf567baf | |||
| 23a57e543d | |||
|
|
8adfab94b5 | ||
|
|
db488a31e8 | ||
| 3836ff5ef3 | |||
| aef5c99a22 | |||
|
|
be7c965234 | ||
|
|
63448ab267 | ||
| 34a565d86d | |||
|
|
171d90b3f7 | ||
| 5191336a49 | |||
|
|
48f64a722d | ||
| bde453d106 | |||
|
|
3bb33b8895 | ||
| 8ee092089f | |||
|
|
eca3d0d62e | ||
| aba743406a | |||
|
|
deb29efde3 | ||
| 6995afc47d | |||
|
|
7b645a3bbe | ||
| 0ddd92b88b | |||
|
|
6151e3d3a5 | ||
| af7abbb095 | |||
|
|
71be1ef9f0 | ||
| 3df358d865 | |||
|
|
c6b2ba486b | ||
| d324e455cc | |||
|
|
8c87e5534e | ||
| bcb3e9e647 | |||
|
|
43f26143df | ||
| ed6e6bf1ba | |||
|
|
2d38b974ba | ||
|
|
610296b301 | ||
|
|
fcc68c8380 | ||
| b96babdfd4 | |||
| 69fdbf4e95 | |||
|
|
d32b2aa959 | ||
|
|
be10839e3a | ||
| d530af43a1 | |||
|
|
aa673fb26d | ||
| d978e24a9d | |||
|
|
31d93e5113 | ||
| f02f3449f3 | |||
|
|
d46822b9bb | ||
| 0505b2314e | |||
|
|
407e704352 | ||
| 4251dfb2c6 | |||
|
|
e0820c47c2 | ||
| 68b82cbca2 | |||
|
|
9e6d530385 | ||
| 5836292adb | |||
| 0c6229331f | |||
| 8cb6a64f2b | |||
|
|
4453dab366 | ||
|
|
512f25f74f | ||
|
|
43020f3eb6 | ||
| 964e90767d | |||
|
|
03cd2f4784 | ||
| 2d735aa7f5 | |||
|
|
342dd5328f | ||
| 915ed66b08 | |||
|
|
fe73b3078a | ||
| 76204822e7 | |||
|
|
77c205f9b2 | ||
| 6a29dc8907 | |||
|
|
8f1f279792 | ||
| 1f0b54d590 | |||
|
|
cefb9706f9 | ||
| 79b7899658 | |||
|
|
c941319c4e | ||
| e6626600cc | |||
|
|
e74c06e87a | ||
| c3647bfc46 | |||
|
|
3ff81038a9 | ||
| d6d9d47e67 | |||
|
|
034eb943e2 | ||
| 93bd0bf002 | |||
|
|
946bfa217c | ||
| 318255405b | |||
|
|
19d4ee1d33 | ||
| bc6a56c129 | |||
|
|
ec54579921 | ||
| 1ed76f7243 | |||
|
|
6527d183ec | ||
| 41b0dec460 | |||
|
|
d26ba84094 | ||
|
|
4073c8819c | ||
|
|
47ada0691c | ||
| 97c67670e5 | |||
|
|
dfad7fb335 | ||
| 41abf723e6 | |||
|
|
2d6586430f | ||
| d649748f6f | |||
|
|
61177a4e30 | ||
| c7b9b70d57 | |||
|
|
5030453807 | ||
| 5565081b69 | |||
| b216aa68b7 | |||
| 7ac1c519e3 | |||
| 076b0c6434 | |||
|
|
9a90e6a03b | ||
|
|
5218632c00 | ||
|
|
a2c05745ad | ||
|
|
82b2f88122 | ||
|
|
28d74d058b | ||
|
|
6ee146b95a | ||
| 67b52af150 | |||
|
|
058a3fefaf | ||
| c8a97d940c | |||
|
|
00045c1e65 | ||
| d64d7dab58 | |||
|
|
4746a00eee | ||
| 3f8399d647 | |||
|
|
028b29fe08 | ||
| cb3f59a9d5 | |||
|
|
2b8d02d95c | ||
| d90d8cda11 | |||
|
|
17333df7af | ||
| 681d53e856 | |||
|
|
dc608fd257 | ||
| cd2efdc2f3 | |||
|
|
fd47867101 | ||
| 2ca1a6956c | |||
|
|
a5ad4f4c3c | ||
| 8b202e0c60 | |||
|
|
4626d0a1a7 | ||
| da72121109 | |||
|
|
5bebadf17c | ||
| 1bab0b14ef | |||
|
|
39f323d050 | ||
| 7cd9e14618 | |||
|
|
b77d2c16ab | ||
|
|
324f150950 | ||
| dd6bcf843c | |||
|
|
874cc4971b | ||
| bfe4695b2d | |||
|
|
99161c8e7d | ||
| 6901791dd2 | |||
|
|
acb3d14b47 | ||
| aa5f7e271e | |||
|
|
0a01995f53 | ||
| 97f71d125e | |||
|
|
8db2f3926c | ||
|
|
2b68b59eca | ||
| d07e64fc4f | |||
|
|
8e40e6247b | ||
| 779cb0ead9 | |||
|
|
2e0057f839 | ||
| 25080ae168 | |||
|
|
e6b001dc61 | ||
| 97d1470515 | |||
|
|
a4481fb63d | ||
| bdf766075e | |||
|
|
47899e25d4 | ||
| 4ec934c96b | |||
|
|
19df740550 | ||
| 1079ad7d0a | |||
| 81d2db394b | |||
|
|
8d6a302cb8 | ||
| 0e48d2a318 | |||
|
|
32653e11fc | ||
| a24ead2c36 | |||
|
|
ce59cb1b26 | ||
| cecaebfc5e | |||
|
|
660f689a7a | ||
| e16f11d48a | |||
|
|
0804ad71f7 | ||
| 7a2f921de9 | |||
|
|
999f0684cb | ||
| 602b77ddc7 | |||
|
|
8115abb569 | ||
|
|
64ad8c8b38 | ||
| f478219990 | |||
|
|
bf39b1d401 | ||
| f7bf3138ac | |||
|
|
7cb747f9a9 | ||
| f2658aea44 | |||
|
|
5e49ee3244 | ||
| 1073be187f | |||
|
|
e854963fa6 | ||
| e5f283432b | |||
|
|
d62a3413b2 | ||
| f720ba9bdf | |||
|
|
98f254e34b | ||
|
|
980bb96c74 | ||
| 4bb18f0210 | |||
|
|
574b40fe9f | ||
| a3a807e625 | |||
|
|
3b047af7ca | ||
| 36cc46577c | |||
|
|
e1da16448b | ||
| fed6f39de6 | |||
|
|
85fb567ed9 | ||
|
|
fd07c10cba | ||
|
|
c44edc85fa | ||
| 57a48fe2a3 | |||
|
|
2b2a8afc2f | ||
| d431317a50 | |||
|
|
b4c696f1ef | ||
| 4d03a6ead8 | |||
|
|
2fe5151650 | ||
|
|
2754a7aa13 | ||
|
|
f71cc76f64 | ||
| 082d782a80 | |||
|
|
18f8f3c476 | ||
| 659b90b6a5 | |||
|
|
84318883d2 | ||
| 668ade2014 | |||
|
|
43c4866ad7 | ||
|
|
396a0c0c88 | ||
| f439f53524 | |||
|
|
2052602050 |
2
.dockerignore
Normal file
2
.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ci/dev/mongo.key*
|
||||||
|
|
||||||
17
.gitignore
vendored
17
.gitignore
vendored
@@ -8,4 +8,19 @@ devtools_options.yaml
|
|||||||
untranslated.txt
|
untranslated.txt
|
||||||
generate_protos.sh
|
generate_protos.sh
|
||||||
update_dep.sh
|
update_dep.sh
|
||||||
.vscode/
|
test.sh
|
||||||
|
.vscode/
|
||||||
|
.gocache/
|
||||||
|
.cache/
|
||||||
|
.claude/
|
||||||
|
|
||||||
|
# Air hot reload build artifacts
|
||||||
|
**/tmp/
|
||||||
|
build-errors.log
|
||||||
|
|
||||||
|
# Development environment (NEVER commit credentials!)
|
||||||
|
.env.dev
|
||||||
|
vault-keys.txt
|
||||||
|
.vault_token
|
||||||
|
|
||||||
|
CLAUDE.md
|
||||||
@@ -4,11 +4,23 @@ matrix:
|
|||||||
BFF_DOCKERFILE: ci/prod/compose/bff.dockerfile
|
BFF_DOCKERFILE: ci/prod/compose/bff.dockerfile
|
||||||
BFF_MONGO_SECRET_PATH: sendico/db
|
BFF_MONGO_SECRET_PATH: sendico/db
|
||||||
BFF_API_SECRET_PATH: sendico/api/endpoint
|
BFF_API_SECRET_PATH: sendico/api/endpoint
|
||||||
|
BFF_VAULT_SECRET_PATH: sendico/edge/bff/vault
|
||||||
BFF_ENV: prod
|
BFF_ENV: prod
|
||||||
|
|
||||||
when:
|
when:
|
||||||
- event: push
|
- event: push
|
||||||
branch: main
|
branch: main
|
||||||
|
path:
|
||||||
|
include:
|
||||||
|
- api/edge/bff/**
|
||||||
|
- 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:
|
steps:
|
||||||
- name: version
|
- name: version
|
||||||
@@ -35,6 +47,14 @@ steps:
|
|||||||
- export PATH="$(go env GOPATH)/bin:$PATH"
|
- export PATH="$(go env GOPATH)/bin:$PATH"
|
||||||
- bash ci/scripts/proto/generate.sh
|
- 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
|
- name: secrets
|
||||||
image: alpine:latest
|
image: alpine:latest
|
||||||
depends_on: [ version ]
|
depends_on: [ version ]
|
||||||
@@ -55,7 +75,7 @@ steps:
|
|||||||
|
|
||||||
- name: build-image
|
- name: build-image
|
||||||
image: gcr.io/kaniko-project/executor:debug
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
depends_on: [ proto, secrets ]
|
depends_on: [ backend-tests, secrets ]
|
||||||
commands:
|
commands:
|
||||||
- sh ci/scripts/bff/build-image.sh
|
- sh ci/scripts/bff/build-image.sh
|
||||||
|
|
||||||
|
|||||||
89
.woodpecker/billing_documents.yml
Normal file
89
.woodpecker/billing_documents.yml
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- DOCUMENTS_IMAGE_PATH: billing/documents
|
||||||
|
DOCUMENTS_DOCKERFILE: ci/prod/compose/billing_documents.dockerfile
|
||||||
|
DOCUMENTS_MONGO_SECRET_PATH: sendico/db
|
||||||
|
DOCUMENTS_ENV: prod
|
||||||
|
|
||||||
|
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
|
||||||
|
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 billing_documents
|
||||||
|
|
||||||
|
- 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/billing_documents/build-image.sh
|
||||||
|
|
||||||
|
- name: deploy
|
||||||
|
image: alpine:latest
|
||||||
|
depends_on: [ secrets, build-image ]
|
||||||
|
environment:
|
||||||
|
VAULT_ADDR: { from_secret: VAULT_ADDR }
|
||||||
|
VAULT_ROLE_ID: { from_secret: VAULT_APP_ROLE }
|
||||||
|
VAULT_SECRET_ID: { from_secret: VAULT_SECRET_ID }
|
||||||
|
commands:
|
||||||
|
- set -euo pipefail
|
||||||
|
- apk add --no-cache bash openssh-client rsync coreutils curl sed python3
|
||||||
|
- mkdir -p /root/.ssh
|
||||||
|
- install -m 600 secrets/SSH_KEY /root/.ssh/id_rsa
|
||||||
|
- sh ci/scripts/billing_documents/deploy.sh
|
||||||
@@ -8,6 +8,14 @@ matrix:
|
|||||||
when:
|
when:
|
||||||
- event: push
|
- event: push
|
||||||
branch: main
|
branch: main
|
||||||
|
path:
|
||||||
|
include:
|
||||||
|
- api/billing/fees/**
|
||||||
|
- api/proto/**
|
||||||
|
- api/pkg/**
|
||||||
|
- ci/prod/**
|
||||||
|
- .woodpecker/billing_fees.yml
|
||||||
|
ignore_message: '[rebuild]'
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: version
|
- name: version
|
||||||
@@ -34,6 +42,14 @@ steps:
|
|||||||
- export PATH="$(go env GOPATH)/bin:$PATH"
|
- export PATH="$(go env GOPATH)/bin:$PATH"
|
||||||
- bash ci/scripts/proto/generate.sh
|
- 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
|
- name: secrets
|
||||||
image: alpine:latest
|
image: alpine:latest
|
||||||
depends_on: [ version ]
|
depends_on: [ version ]
|
||||||
@@ -54,7 +70,7 @@ steps:
|
|||||||
|
|
||||||
- name: build-image
|
- name: build-image
|
||||||
image: gcr.io/kaniko-project/executor:debug
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
depends_on: [ proto, secrets ]
|
depends_on: [ backend-tests, secrets ]
|
||||||
commands:
|
commands:
|
||||||
- sh ci/scripts/billing_fees/build-image.sh
|
- sh ci/scripts/billing_fees/build-image.sh
|
||||||
|
|
||||||
|
|||||||
90
.woodpecker/callbacks.yml
Normal file
90
.woodpecker/callbacks.yml
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- CALLBACKS_IMAGE_PATH: edge/callbacks
|
||||||
|
CALLBACKS_DOCKERFILE: ci/prod/compose/callbacks.dockerfile
|
||||||
|
CALLBACKS_MONGO_SECRET_PATH: sendico/db
|
||||||
|
CALLBACKS_VAULT_SECRET_PATH: sendico/edge/callbacks/vault
|
||||||
|
CALLBACKS_ENV: prod
|
||||||
|
|
||||||
|
when:
|
||||||
|
- event: push
|
||||||
|
branch: main
|
||||||
|
path:
|
||||||
|
include:
|
||||||
|
- api/edge/callbacks/**
|
||||||
|
- api/proto/**
|
||||||
|
- api/pkg/**
|
||||||
|
- ci/prod/**
|
||||||
|
- .woodpecker/callbacks.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 callbacks
|
||||||
|
|
||||||
|
- 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/callbacks/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/callbacks/deploy.sh
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
when:
|
when:
|
||||||
- event: push
|
- event: push
|
||||||
branch: main
|
branch: main
|
||||||
|
path:
|
||||||
|
exclude: ['**']
|
||||||
|
ignore_message: '[infra]'
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: version
|
- name: version
|
||||||
|
|||||||
88
.woodpecker/discovery.yml
Normal file
88
.woodpecker/discovery.yml
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- DISCOVERY_IMAGE_PATH: discovery/service
|
||||||
|
DISCOVERY_DOCKERFILE: ci/prod/compose/discovery.dockerfile
|
||||||
|
DISCOVERY_ENV: prod
|
||||||
|
|
||||||
|
when:
|
||||||
|
- event: push
|
||||||
|
branch: main
|
||||||
|
path:
|
||||||
|
include:
|
||||||
|
- api/discovery/**
|
||||||
|
- api/proto/**
|
||||||
|
- api/pkg/**
|
||||||
|
- ci/prod/**
|
||||||
|
- .woodpecker/discovery.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 discovery
|
||||||
|
|
||||||
|
- 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/discovery/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/discovery/deploy.sh
|
||||||
@@ -7,6 +7,16 @@ matrix:
|
|||||||
when:
|
when:
|
||||||
- event: push
|
- event: push
|
||||||
branch: main
|
branch: main
|
||||||
|
path:
|
||||||
|
include:
|
||||||
|
- api/edge/bff/**
|
||||||
|
- api/pkg/**
|
||||||
|
- api/proto/**
|
||||||
|
- frontend/**
|
||||||
|
- interface/**
|
||||||
|
- ci/prod/**
|
||||||
|
- .woodpecker/frontend.yml
|
||||||
|
ignore_message: '[rebuild]'
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: version
|
- name: version
|
||||||
|
|||||||
@@ -5,12 +5,21 @@ matrix:
|
|||||||
FX_DOCKERFILE: ci/prod/compose/fx_ingestor.dockerfile
|
FX_DOCKERFILE: ci/prod/compose/fx_ingestor.dockerfile
|
||||||
FX_DEPLOY_TARGET: ingestor
|
FX_DEPLOY_TARGET: ingestor
|
||||||
FX_MONGO_SECRET_PATH: sendico/db
|
FX_MONGO_SECRET_PATH: sendico/db
|
||||||
FX_NEEDS_NATS: "false"
|
FX_NEEDS_NATS: "true"
|
||||||
FX_ENV: prod
|
FX_ENV: prod
|
||||||
|
|
||||||
when:
|
when:
|
||||||
- event: push
|
- event: push
|
||||||
branch: main
|
branch: main
|
||||||
|
path:
|
||||||
|
include:
|
||||||
|
- api/fx/ingestor/**
|
||||||
|
- api/fx/storage/**
|
||||||
|
- api/proto/**
|
||||||
|
- api/pkg/**
|
||||||
|
- ci/prod/**
|
||||||
|
- .woodpecker/fx_ingestor.yml
|
||||||
|
ignore_message: '[rebuild]'
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: version
|
- name: version
|
||||||
@@ -38,6 +47,14 @@ steps:
|
|||||||
- export PATH="$(go env GOPATH)/bin:$PATH"
|
- export PATH="$(go env GOPATH)/bin:$PATH"
|
||||||
- bash ci/scripts/proto/generate.sh
|
- 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
|
- name: secrets
|
||||||
image: alpine:latest
|
image: alpine:latest
|
||||||
depends_on: [ version ]
|
depends_on: [ version ]
|
||||||
@@ -58,7 +75,7 @@ steps:
|
|||||||
|
|
||||||
- name: build-image
|
- name: build-image
|
||||||
image: gcr.io/kaniko-project/executor:debug
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
depends_on: [ proto, secrets ]
|
depends_on: [ backend-tests, secrets ]
|
||||||
commands:
|
commands:
|
||||||
- sh ci/scripts/fx/build-image.sh
|
- sh ci/scripts/fx/build-image.sh
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,16 @@ matrix:
|
|||||||
when:
|
when:
|
||||||
- event: push
|
- event: push
|
||||||
branch: main
|
branch: main
|
||||||
|
path:
|
||||||
|
include:
|
||||||
|
- api/fx/oracle/**
|
||||||
|
- api/fx/storage/**
|
||||||
|
- api/proto/**
|
||||||
|
- api/pkg/**
|
||||||
|
- ci/prod/**
|
||||||
|
- .woodpecker/fx_oracle.yml
|
||||||
|
ignore_message: '[rebuild]'
|
||||||
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: version
|
- name: version
|
||||||
@@ -38,6 +48,14 @@ steps:
|
|||||||
- export PATH="$(go env GOPATH)/bin:$PATH"
|
- export PATH="$(go env GOPATH)/bin:$PATH"
|
||||||
- bash ci/scripts/proto/generate.sh
|
- 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
|
- name: secrets
|
||||||
image: alpine:latest
|
image: alpine:latest
|
||||||
depends_on: [ version ]
|
depends_on: [ version ]
|
||||||
@@ -58,7 +76,7 @@ steps:
|
|||||||
|
|
||||||
- name: build-image
|
- name: build-image
|
||||||
image: gcr.io/kaniko-project/executor:debug
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
depends_on: [ proto, secrets ]
|
depends_on: [ backend-tests, secrets ]
|
||||||
commands:
|
commands:
|
||||||
- sh ci/scripts/fx/build-image.sh
|
- sh ci/scripts/fx/build-image.sh
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,15 @@ matrix:
|
|||||||
when:
|
when:
|
||||||
- event: push
|
- event: push
|
||||||
branch: main
|
branch: main
|
||||||
|
path:
|
||||||
|
include:
|
||||||
|
- api/gateway/chain/**
|
||||||
|
- api/gateway/common/**
|
||||||
|
- api/proto/**
|
||||||
|
- api/pkg/**
|
||||||
|
- ci/prod/**
|
||||||
|
- .woodpecker/gateway_chain.yml
|
||||||
|
ignore_message: '[rebuild]'
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: version
|
- name: version
|
||||||
@@ -37,6 +46,14 @@ steps:
|
|||||||
- export PATH="$(go env GOPATH)/bin:$PATH"
|
- export PATH="$(go env GOPATH)/bin:$PATH"
|
||||||
- bash ci/scripts/proto/generate.sh
|
- 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
|
- name: secrets
|
||||||
image: alpine:latest
|
image: alpine:latest
|
||||||
depends_on: [ version ]
|
depends_on: [ version ]
|
||||||
@@ -57,7 +74,7 @@ steps:
|
|||||||
|
|
||||||
- name: build-image
|
- name: build-image
|
||||||
image: gcr.io/kaniko-project/executor:debug
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
depends_on: [ proto, secrets ]
|
depends_on: [ backend-tests, secrets ]
|
||||||
commands:
|
commands:
|
||||||
- sh ci/scripts/chain_gateway/build-image.sh
|
- sh ci/scripts/chain_gateway/build-image.sh
|
||||||
|
|
||||||
92
.woodpecker/gateway_mntx.yml
Normal file
92
.woodpecker/gateway_mntx.yml
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- MNTX_GATEWAY_IMAGE_PATH: gateway/mntx
|
||||||
|
MNTX_GATEWAY_DOCKERFILE: ci/prod/compose/mntx_gateway.dockerfile
|
||||||
|
MNTX_GATEWAY_ENV: prod
|
||||||
|
MNTX_GATEWAY_MONETIX_SECRET_PATH: sendico/gateway/monetix
|
||||||
|
MNTX_GATEWAY_NATS_SECRET_PATH: sendico/nats
|
||||||
|
MNTX_GATEWAY_MONGO_SECRET_PATH: sendico/db
|
||||||
|
|
||||||
|
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
|
||||||
|
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 gateway_mntx
|
||||||
|
|
||||||
|
- 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/mntx/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/mntx/deploy.sh
|
||||||
90
.woodpecker/gateway_tgsettle.yml
Normal file
90
.woodpecker/gateway_tgsettle.yml
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- TGSETTLE_GATEWAY_IMAGE_PATH: gateway/tgsettle
|
||||||
|
TGSETTLE_GATEWAY_DOCKERFILE: ci/prod/compose/tgsettle_gateway.dockerfile
|
||||||
|
TGSETTLE_GATEWAY_MONGO_SECRET_PATH: sendico/db
|
||||||
|
TGSETTLE_GATEWAY_ENV: prod
|
||||||
|
|
||||||
|
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
|
||||||
|
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 gateway_tgsettle
|
||||||
|
|
||||||
|
- 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/tgsettle/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/tgsettle/deploy.sh
|
||||||
93
.woodpecker/gateway_tron.yml
Normal file
93
.woodpecker/gateway_tron.yml
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- TRON_GATEWAY_IMAGE_PATH: gateway/tron
|
||||||
|
TRON_GATEWAY_DOCKERFILE: ci/prod/compose/tron_gateway.dockerfile
|
||||||
|
TRON_GATEWAY_MONGO_SECRET_PATH: sendico/db
|
||||||
|
TRON_GATEWAY_RPC_SECRET_PATH: sendico/gateway/tron
|
||||||
|
TRON_GATEWAY_WALLET_SECRET_PATH: sendico/gateway/tron/wallet
|
||||||
|
TRON_GATEWAY_VAULT_SECRET_PATH: sendico/gateway/tron/vault
|
||||||
|
TRON_GATEWAY_ENV: prod
|
||||||
|
|
||||||
|
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
|
||||||
|
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 gateway_tron
|
||||||
|
|
||||||
|
- 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/tron_gateway/build-image.sh
|
||||||
|
|
||||||
|
- name: deploy
|
||||||
|
image: alpine:latest
|
||||||
|
depends_on: [ secrets, build-image ]
|
||||||
|
environment:
|
||||||
|
VAULT_ADDR: { from_secret: VAULT_ADDR }
|
||||||
|
VAULT_ROLE_ID: { from_secret: VAULT_APP_ROLE }
|
||||||
|
VAULT_SECRET_ID: { from_secret: VAULT_SECRET_ID }
|
||||||
|
commands:
|
||||||
|
- set -euo pipefail
|
||||||
|
- apk add --no-cache bash openssh-client rsync coreutils curl sed python3
|
||||||
|
- mkdir -p /root/.ssh
|
||||||
|
- install -m 600 secrets/SSH_KEY /root/.ssh/id_rsa
|
||||||
|
- sh ci/scripts/tron_gateway/deploy.sh
|
||||||
@@ -8,6 +8,14 @@ matrix:
|
|||||||
when:
|
when:
|
||||||
- event: push
|
- event: push
|
||||||
branch: main
|
branch: main
|
||||||
|
path:
|
||||||
|
include:
|
||||||
|
- api/ledger/**
|
||||||
|
- api/proto/**
|
||||||
|
- api/pkg/**
|
||||||
|
- ci/prod/**
|
||||||
|
- .woodpecker/ledger.yml
|
||||||
|
ignore_message: '[rebuild]'
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: version
|
- name: version
|
||||||
@@ -34,6 +42,14 @@ steps:
|
|||||||
- export PATH="$(go env GOPATH)/bin:$PATH"
|
- export PATH="$(go env GOPATH)/bin:$PATH"
|
||||||
- bash ci/scripts/proto/generate.sh
|
- 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
|
- name: secrets
|
||||||
image: alpine:latest
|
image: alpine:latest
|
||||||
depends_on: [ version ]
|
depends_on: [ version ]
|
||||||
@@ -54,7 +70,7 @@ steps:
|
|||||||
|
|
||||||
- name: build-image
|
- name: build-image
|
||||||
image: gcr.io/kaniko-project/executor:debug
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
depends_on: [ proto, secrets ]
|
depends_on: [ backend-tests, secrets ]
|
||||||
commands:
|
commands:
|
||||||
- sh ci/scripts/ledger/build-image.sh
|
- sh ci/scripts/ledger/build-image.sh
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
when:
|
when:
|
||||||
- event: push
|
- event: push
|
||||||
branch: main
|
branch: main
|
||||||
|
path:
|
||||||
|
exclude: ['**']
|
||||||
|
ignore_message: '[infra]'
|
||||||
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: version
|
- name: version
|
||||||
|
|||||||
@@ -11,6 +11,14 @@ matrix:
|
|||||||
when:
|
when:
|
||||||
- event: push
|
- event: push
|
||||||
branch: main
|
branch: main
|
||||||
|
path:
|
||||||
|
include:
|
||||||
|
- api/notification/**
|
||||||
|
- api/proto/**
|
||||||
|
- api/pkg/**
|
||||||
|
- ci/prod/**
|
||||||
|
- .woodpecker/notification.yml
|
||||||
|
ignore_message: '[rebuild]'
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: version
|
- name: version
|
||||||
@@ -37,6 +45,14 @@ steps:
|
|||||||
- export PATH="$(go env GOPATH)/bin:$PATH"
|
- export PATH="$(go env GOPATH)/bin:$PATH"
|
||||||
- bash ci/scripts/proto/generate.sh
|
- 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
|
- name: secrets
|
||||||
image: alpine:latest
|
image: alpine:latest
|
||||||
depends_on: [ version ]
|
depends_on: [ version ]
|
||||||
@@ -57,7 +73,7 @@ steps:
|
|||||||
|
|
||||||
- name: build-image
|
- name: build-image
|
||||||
image: gcr.io/kaniko-project/executor:debug
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
depends_on: [ proto, secrets ]
|
depends_on: [ backend-tests, secrets ]
|
||||||
commands:
|
commands:
|
||||||
- sh ci/scripts/notification/build-image.sh
|
- sh ci/scripts/notification/build-image.sh
|
||||||
|
|
||||||
|
|||||||
90
.woodpecker/payments_methods.yml
Normal file
90
.woodpecker/payments_methods.yml
Normal 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
|
||||||
@@ -8,6 +8,15 @@ matrix:
|
|||||||
when:
|
when:
|
||||||
- event: push
|
- event: push
|
||||||
branch: main
|
branch: main
|
||||||
|
path:
|
||||||
|
include:
|
||||||
|
- api/payments/orchestrator/**
|
||||||
|
- api/payments/storage/**
|
||||||
|
- api/proto/**
|
||||||
|
- api/pkg/**
|
||||||
|
- ci/prod/**
|
||||||
|
- .woodpecker/payments_orchestrator.yml
|
||||||
|
ignore_message: '[rebuild]'
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: version
|
- name: version
|
||||||
@@ -34,6 +43,14 @@ steps:
|
|||||||
- export PATH="$(go env GOPATH)/bin:$PATH"
|
- export PATH="$(go env GOPATH)/bin:$PATH"
|
||||||
- bash ci/scripts/proto/generate.sh
|
- 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
|
- name: secrets
|
||||||
image: alpine:latest
|
image: alpine:latest
|
||||||
depends_on: [ version ]
|
depends_on: [ version ]
|
||||||
@@ -54,7 +71,7 @@ steps:
|
|||||||
|
|
||||||
- name: build-image
|
- name: build-image
|
||||||
image: gcr.io/kaniko-project/executor:debug
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
depends_on: [ proto, secrets ]
|
depends_on: [ backend-tests, secrets ]
|
||||||
commands:
|
commands:
|
||||||
- sh ci/scripts/payments_orchestrator/build-image.sh
|
- sh ci/scripts/payments_orchestrator/build-image.sh
|
||||||
|
|
||||||
|
|||||||
90
.woodpecker/payments_quotation.yml
Normal file
90
.woodpecker/payments_quotation.yml
Normal 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
|
||||||
376
Makefile
Normal file
376
Makefile
Normal file
@@ -0,0 +1,376 @@
|
|||||||
|
# Sendico Development Environment - Makefile
|
||||||
|
# Docker Compose + Makefile build system
|
||||||
|
|
||||||
|
.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 backend-up backend-down backend-rebuild
|
||||||
|
|
||||||
|
COMPOSE := docker compose -f docker-compose.dev.yml --env-file .env.dev
|
||||||
|
SERVICE ?=
|
||||||
|
BACKEND_SERVICES := \
|
||||||
|
dev-discovery \
|
||||||
|
dev-fx-oracle \
|
||||||
|
dev-fx-ingestor \
|
||||||
|
dev-billing-fees \
|
||||||
|
dev-billing-documents \
|
||||||
|
dev-ledger \
|
||||||
|
dev-payments-orchestrator \
|
||||||
|
dev-payments-quotation \
|
||||||
|
dev-payments-methods \
|
||||||
|
dev-chain-gateway-vault-agent \
|
||||||
|
dev-chain-gateway \
|
||||||
|
dev-tron-gateway-vault-agent \
|
||||||
|
dev-tron-gateway \
|
||||||
|
dev-aurora-gateway \
|
||||||
|
dev-tgsettle-gateway \
|
||||||
|
dev-notification \
|
||||||
|
dev-callbacks-vault-agent \
|
||||||
|
dev-callbacks \
|
||||||
|
dev-bff-vault-agent \
|
||||||
|
dev-bff
|
||||||
|
|
||||||
|
# Colors
|
||||||
|
GREEN := \033[0;32m
|
||||||
|
YELLOW := \033[1;33m
|
||||||
|
NC := \033[0m
|
||||||
|
|
||||||
|
help:
|
||||||
|
@echo "$(GREEN)Sendico Development Environment$(NC)"
|
||||||
|
@echo ""
|
||||||
|
@echo "$(YELLOW)Quick Start:$(NC)"
|
||||||
|
@echo " make init Initialize dev environment (first time setup)"
|
||||||
|
@echo " make up Start all services"
|
||||||
|
@echo " make vault-init Initialize Vault (if services need it)"
|
||||||
|
@echo ""
|
||||||
|
@echo "$(YELLOW)Main Commands:$(NC)"
|
||||||
|
@echo " make build Build all service images"
|
||||||
|
@echo " make down Stop all services"
|
||||||
|
@echo " make restart Restart all services"
|
||||||
|
@echo " make status Show service status"
|
||||||
|
@echo " make logs [SERVICE=x] View logs (all or specific service)"
|
||||||
|
@echo " make rebuild SERVICE=x Rebuild specific service"
|
||||||
|
@echo " make clean Remove all containers and volumes"
|
||||||
|
@echo ""
|
||||||
|
@echo "$(YELLOW)Selective Operations:$(NC)"
|
||||||
|
@echo " make infra-up Start infrastructure only (mongo, nats, vault)"
|
||||||
|
@echo " make services-up Start application services only"
|
||||||
|
@echo " make backend-up Start backend services only (no infrastructure/frontend)"
|
||||||
|
@echo " make backend-down Stop backend services only"
|
||||||
|
@echo " make backend-rebuild Rebuild and restart backend services only"
|
||||||
|
@echo " make list-services List all available services"
|
||||||
|
@echo ""
|
||||||
|
@echo "$(YELLOW)Build Groups:$(NC)"
|
||||||
|
@echo " make build-core Build core services (discovery, ledger, fees, documents)"
|
||||||
|
@echo " make build-fx Build FX services (oracle, ingestor)"
|
||||||
|
@echo " make build-payments Build payment orchestrator"
|
||||||
|
@echo " make build-gateways Build gateway services (chain, tron, aurora, tgsettle)"
|
||||||
|
@echo " make build-api Build API services (notification, callbacks, bff)"
|
||||||
|
@echo " make build-frontend Build Flutter web frontend"
|
||||||
|
@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:"
|
||||||
|
@echo " make logs SERVICE=dev-ledger"
|
||||||
|
@echo " make rebuild SERVICE=dev-ledger"
|
||||||
|
|
||||||
|
# First-time initialization
|
||||||
|
init:
|
||||||
|
@echo "$(GREEN)Initializing development environment...$(NC)"
|
||||||
|
@if [ ! -f ci/dev/mongo-key/mongo.key ]; then \
|
||||||
|
echo "$(YELLOW)Generating MongoDB keyfile...$(NC)"; \
|
||||||
|
openssl rand -base64 756 | tr -d '\n' > ci/dev/mongo-key/mongo.key; \
|
||||||
|
chmod 400 ci/dev/mongo-key/mongo.key; \
|
||||||
|
fi
|
||||||
|
@if [ ! -f .env.dev ]; then \
|
||||||
|
echo "$(YELLOW)Creating .env.dev with default values...$(NC)"; \
|
||||||
|
echo "# Sendico Development Environment Configuration" > .env.dev; \
|
||||||
|
echo "# Simple plaintext credentials for infrastructure" >> .env.dev; \
|
||||||
|
echo "" >> .env.dev; \
|
||||||
|
echo "# MongoDB Configuration" >> .env.dev; \
|
||||||
|
echo "MONGO_USER=dev_root" >> .env.dev; \
|
||||||
|
echo "MONGO_PASSWORD=dev_password_123" >> .env.dev; \
|
||||||
|
echo "" >> .env.dev; \
|
||||||
|
echo "# NATS Configuration" >> .env.dev; \
|
||||||
|
echo "NATS_USER=dev_nats" >> .env.dev; \
|
||||||
|
echo "NATS_PASSWORD=nats_password_123" >> .env.dev; \
|
||||||
|
echo "" >> .env.dev; \
|
||||||
|
echo "# Vault Configuration (for application data only)" >> .env.dev; \
|
||||||
|
echo "VAULT_ADDR=http://dev-vault:8200" >> .env.dev; \
|
||||||
|
fi
|
||||||
|
@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)"
|
||||||
|
@./ci/scripts/proto/generate.sh
|
||||||
|
@echo "$(GREEN)Building Docker images...$(NC)"
|
||||||
|
@$(COMPOSE) build
|
||||||
|
@echo "$(GREEN)✅ Initialization complete!$(NC)"
|
||||||
|
@echo ""
|
||||||
|
@echo "Next steps:"
|
||||||
|
@echo " 1. make up # Start services"
|
||||||
|
@echo " 2. make vault-init # Initialize Vault (if needed)"
|
||||||
|
|
||||||
|
# Build all images
|
||||||
|
build:
|
||||||
|
@echo "$(GREEN)Building all service images...$(NC)"
|
||||||
|
@./ci/scripts/proto/generate.sh
|
||||||
|
@$(COMPOSE) build
|
||||||
|
|
||||||
|
# Start all services
|
||||||
|
up:
|
||||||
|
@echo "$(GREEN)Starting development environment...$(NC)"
|
||||||
|
@$(COMPOSE) up -d
|
||||||
|
@echo ""
|
||||||
|
@echo "$(GREEN)✅ Development environment started!$(NC)"
|
||||||
|
@echo ""
|
||||||
|
@echo "Services:"
|
||||||
|
@echo " MongoDB: mongodb://dev_root:dev_password_123@localhost:27017/admin?replicaSet=dev-rs"
|
||||||
|
@echo " NATS: nats://dev_nats:nats_password_123@localhost:4222"
|
||||||
|
@echo " NATS UI: http://localhost:8222"
|
||||||
|
@echo " Vault: http://localhost:8200 (run 'make vault-init' first)"
|
||||||
|
@echo ""
|
||||||
|
@echo "View logs: make logs [SERVICE=name]"
|
||||||
|
@echo "Stop: make down"
|
||||||
|
|
||||||
|
# Stop all services
|
||||||
|
down:
|
||||||
|
@echo "$(YELLOW)Stopping development environment...$(NC)"
|
||||||
|
@$(COMPOSE) down
|
||||||
|
|
||||||
|
# Restart all services
|
||||||
|
restart:
|
||||||
|
@echo "$(YELLOW)Restarting services...$(NC)"
|
||||||
|
@$(COMPOSE) restart
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
logs:
|
||||||
|
ifdef SERVICE
|
||||||
|
@$(COMPOSE) logs -f $(SERVICE)
|
||||||
|
else
|
||||||
|
@$(COMPOSE) logs -f
|
||||||
|
endif
|
||||||
|
|
||||||
|
# Rebuild specific service
|
||||||
|
rebuild:
|
||||||
|
ifndef SERVICE
|
||||||
|
$(error SERVICE is required: make rebuild SERVICE=ledger)
|
||||||
|
endif
|
||||||
|
@echo "$(GREEN)Rebuilding $(SERVICE)...$(NC)"
|
||||||
|
@$(COMPOSE) build $(SERVICE)
|
||||||
|
@$(COMPOSE) up -d --force-recreate $(SERVICE)
|
||||||
|
@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
|
||||||
|
generate-api:
|
||||||
|
@echo "$(GREEN)Generating protobuf code...$(NC)"
|
||||||
|
@./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)"
|
||||||
|
@read -p "Are you sure? [y/N] " -n 1 -r; \
|
||||||
|
echo; \
|
||||||
|
if [[ $$REPLY =~ ^[Yy]$$ ]]; then \
|
||||||
|
$(COMPOSE) down -v --remove-orphans; \
|
||||||
|
docker system prune -f; \
|
||||||
|
echo "$(GREEN)✅ Cleanup complete$(NC)"; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Initialize Vault (first time only)
|
||||||
|
vault-init:
|
||||||
|
@echo "$(GREEN)Initializing Vault...$(NC)"
|
||||||
|
@echo "$(YELLOW)Make sure Vault is running: make up$(NC)"
|
||||||
|
@sleep 2
|
||||||
|
@echo "Checking Vault status..."
|
||||||
|
@docker exec dev-vault vault status || echo "Vault not initialized yet"
|
||||||
|
@echo ""
|
||||||
|
@echo "$(GREEN)Initializing Vault with 1 key share...$(NC)"
|
||||||
|
@docker exec dev-vault vault operator init -key-shares=1 -key-threshold=1 > vault-keys.txt || echo "Vault already initialized"
|
||||||
|
@if [ -f vault-keys.txt ]; then \
|
||||||
|
echo "$(GREEN)✅ Vault initialized!$(NC)"; \
|
||||||
|
echo ""; \
|
||||||
|
echo "$(YELLOW)IMPORTANT: Save vault-keys.txt securely!$(NC)"; \
|
||||||
|
echo "It contains the unseal key and root token."; \
|
||||||
|
echo ""; \
|
||||||
|
UNSEAL_KEY=$$(grep 'Unseal Key 1:' vault-keys.txt | awk '{print $$NF}'); \
|
||||||
|
echo "Unsealing Vault..."; \
|
||||||
|
docker exec dev-vault vault operator unseal $$UNSEAL_KEY; \
|
||||||
|
echo ""; \
|
||||||
|
echo "$(GREEN)✅ Vault is unsealed and ready!$(NC)"; \
|
||||||
|
echo ""; \
|
||||||
|
echo "Root token: $$(grep 'Initial Root Token:' vault-keys.txt | awk '{print $$NF}')"; \
|
||||||
|
echo "UI: http://localhost:8200"; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Infrastructure only (mongo + nats + vault)
|
||||||
|
infra-up:
|
||||||
|
@echo "$(GREEN)Starting infrastructure only...$(NC)"
|
||||||
|
@$(COMPOSE) up -d dev-mongo-1 dev-mongo-2 dev-mongo-3 dev-nats dev-vault
|
||||||
|
@$(COMPOSE) up dev-mongo-init
|
||||||
|
|
||||||
|
# Services only (assumes infra is running)
|
||||||
|
services-up:
|
||||||
|
@echo "$(GREEN)Starting application services...$(NC)"
|
||||||
|
@$(COMPOSE) up -d \
|
||||||
|
dev-discovery \
|
||||||
|
dev-fx-oracle \
|
||||||
|
dev-fx-ingestor \
|
||||||
|
dev-billing-fees \
|
||||||
|
dev-billing-documents \
|
||||||
|
dev-ledger \
|
||||||
|
dev-payments-orchestrator \
|
||||||
|
dev-payments-quotation \
|
||||||
|
dev-payments-methods \
|
||||||
|
dev-chain-gateway \
|
||||||
|
dev-tron-gateway \
|
||||||
|
dev-aurora-gateway \
|
||||||
|
dev-tgsettle-gateway \
|
||||||
|
dev-notification \
|
||||||
|
dev-callbacks \
|
||||||
|
dev-bff \
|
||||||
|
dev-frontend
|
||||||
|
|
||||||
|
# Backend services only (no infrastructure, no frontend)
|
||||||
|
backend-up:
|
||||||
|
@echo "$(GREEN)Starting backend services only (no infra changes)...$(NC)"
|
||||||
|
@$(COMPOSE) up -d --no-deps $(BACKEND_SERVICES)
|
||||||
|
|
||||||
|
backend-down:
|
||||||
|
@echo "$(YELLOW)Stopping backend services only...$(NC)"
|
||||||
|
@$(COMPOSE) stop $(BACKEND_SERVICES)
|
||||||
|
|
||||||
|
backend-rebuild:
|
||||||
|
@echo "$(GREEN)Rebuilding backend services only (no infra changes)...$(NC)"
|
||||||
|
@$(COMPOSE) build $(BACKEND_SERVICES)
|
||||||
|
@$(COMPOSE) up -d --no-deps --force-recreate $(BACKEND_SERVICES)
|
||||||
|
@echo "$(GREEN)✅ Backend services rebuilt$(NC)"
|
||||||
|
|
||||||
|
# Status check
|
||||||
|
status:
|
||||||
|
@$(COMPOSE) ps
|
||||||
|
|
||||||
|
# List all services
|
||||||
|
list-services:
|
||||||
|
@echo "$(GREEN)Infrastructure Services:$(NC)"
|
||||||
|
@echo " - dev-mongo-1, dev-mongo-2, dev-mongo-3 (MongoDB Replica Set)"
|
||||||
|
@echo " - dev-nats (NATS with JetStream)"
|
||||||
|
@echo " - dev-vault (Vault for application secrets)"
|
||||||
|
@echo ""
|
||||||
|
@echo "$(GREEN)Application Services:$(NC)"
|
||||||
|
@echo " - dev-discovery :9407 (Service Registry)"
|
||||||
|
@echo " - dev-fx-oracle :50051, :9400 (FX Quotes)"
|
||||||
|
@echo " - dev-fx-ingestor :9102 (FX Rate Ingestion)"
|
||||||
|
@echo " - dev-billing-fees :50060, :9402 (Fee Calculation)"
|
||||||
|
@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-aurora-gateway :50075, :9405, :8084 (Card Payouts Simulator)"
|
||||||
|
@echo " - dev-tgsettle-gateway :50080, :9406 (Telegram Settlements)"
|
||||||
|
@echo " - dev-notification :8081 (Notifications)"
|
||||||
|
@echo " - dev-callbacks :9420 (Webhook Callbacks)"
|
||||||
|
@echo " - dev-bff :8080 (Backend for Frontend)"
|
||||||
|
@echo " - dev-frontend :3000 (Flutter Web UI)"
|
||||||
|
|
||||||
|
# Health check all services
|
||||||
|
health:
|
||||||
|
@echo "$(GREEN)Checking service health...$(NC)"
|
||||||
|
@$(COMPOSE) ps --format json | grep -v "State" || echo "Run 'make up' first"
|
||||||
|
|
||||||
|
# Build specific service group
|
||||||
|
build-infra:
|
||||||
|
@echo "$(GREEN)Building infrastructure images...$(NC)"
|
||||||
|
# Infrastructure uses official images, nothing to build
|
||||||
|
|
||||||
|
build-core:
|
||||||
|
@echo "$(GREEN)Building core services...$(NC)"
|
||||||
|
@$(COMPOSE) build dev-discovery dev-ledger dev-billing-fees dev-billing-documents
|
||||||
|
|
||||||
|
build-fx:
|
||||||
|
@echo "$(GREEN)Building FX services...$(NC)"
|
||||||
|
@$(COMPOSE) build dev-fx-oracle dev-fx-ingestor
|
||||||
|
|
||||||
|
build-payments:
|
||||||
|
@echo "$(GREEN)Building payment services...$(NC)"
|
||||||
|
@$(COMPOSE) build dev-payments-orchestrator dev-payments-quotation dev-payments-methods
|
||||||
|
|
||||||
|
build-gateways:
|
||||||
|
@echo "$(GREEN)Building gateway services...$(NC)"
|
||||||
|
@$(COMPOSE) build dev-chain-gateway dev-tron-gateway dev-aurora-gateway dev-tgsettle-gateway
|
||||||
|
|
||||||
|
build-api:
|
||||||
|
@echo "$(GREEN)Building API services...$(NC)"
|
||||||
|
@$(COMPOSE) build dev-notification dev-callbacks dev-bff
|
||||||
|
|
||||||
|
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)"
|
||||||
179
README.md
Normal file
179
README.md
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
# Sendico [](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 Aurora | `api/gateway/aurora/` | Card payouts simulator |
|
||||||
|
| Gateway MNTX | `api/gateway/mntx/` | Card payouts |
|
||||||
|
| Gateway TGSettle | `api/gateway/tgsettle/` | Telegram settlements with MNTX |
|
||||||
|
| Notification | `api/notification/` | Notifications |
|
||||||
|
| BFF | `api/edge/bff/` | Backend for frontend |
|
||||||
|
| Callbacks | `api/edge/callbacks/` | Webhook callbacks delivery |
|
||||||
|
| Frontend | `frontend/pweb/` | Flutter web UI |
|
||||||
|
|
||||||
|
Gateway note: current dev compose workflows (`make services-up`, `make build-gateways`) use Aurora for card-payout flows (`chain`, `tron`, `aurora`, `tgsettle`). The MNTX gateway codebase is retained separately for Monetix-specific integration.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Docker with Docker Compose plugin
|
||||||
|
- GNU Make
|
||||||
|
- Go toolchain
|
||||||
|
- Dart SDK
|
||||||
|
- Flutter SDK
|
||||||
|
|
||||||
|
## 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 list-services # List all services and ports
|
||||||
|
make health # Check service health
|
||||||
|
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)
|
||||||
|
make backend-up # Start backend services only (no infrastructure/frontend changes)
|
||||||
|
make backend-down # Stop backend services only
|
||||||
|
make backend-rebuild # Rebuild and restart backend services only
|
||||||
|
make list-services # Show service names, ports, and descriptions
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build Groups
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make build-core # discovery, ledger, fees, documents
|
||||||
|
make build-fx # oracle, ingestor
|
||||||
|
make build-payments # orchestrator, quotation, methods
|
||||||
|
make build-gateways # chain, tron, aurora, tgsettle
|
||||||
|
make build-api # notification, callbacks, 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
|
||||||
|
```
|
||||||
|
|
||||||
|
### Callbacks Secret References
|
||||||
|
|
||||||
|
Callbacks (`api/edge/callbacks`) supports three secret reference formats:
|
||||||
|
|
||||||
|
- `env:MY_SECRET_ENV` to read from environment variables.
|
||||||
|
- `vault:some/path#field` to read a field from Vault KV v2.
|
||||||
|
- `some/path#field` to read from Vault KV v2 when `secrets.vault` is configured.
|
||||||
|
|
||||||
|
If `#field` is omitted, callbacks uses `secrets.vault.default_field` (default: `value`).
|
||||||
|
|
||||||
|
### Callbacks Vault Auth (Dev + Prod)
|
||||||
|
|
||||||
|
Callbacks now authenticates to Vault through a sidecar Vault Agent (AppRole), same pattern as chain/tron gateways.
|
||||||
|
|
||||||
|
- Dev compose:
|
||||||
|
- service: `dev-callbacks-vault-agent`
|
||||||
|
- shared token file: `/run/vault/token`
|
||||||
|
- app reads token via `VAULT_TOKEN_FILE=/run/vault/token` and `token_env: VAULT_TOKEN`
|
||||||
|
- Prod compose:
|
||||||
|
- service: `sendico_callbacks_vault_agent`
|
||||||
|
- same token sink and env flow
|
||||||
|
- AppRole creds are injected at deploy from `CALLBACKS_VAULT_SECRET_PATH` (default `sendico/edge/callbacks/vault`)
|
||||||
|
|
||||||
|
Required Vault policy (minimal read-only for KV v2 mount `kv`):
|
||||||
|
|
||||||
|
```hcl
|
||||||
|
path "kv/data/sendico/callbacks/*" {
|
||||||
|
capabilities = ["read"]
|
||||||
|
}
|
||||||
|
|
||||||
|
path "kv/metadata/sendico/callbacks/*" {
|
||||||
|
capabilities = ["read", "list"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Create policy + role (example):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vault policy write callbacks callbacks-policy.hcl
|
||||||
|
vault write auth/approle/role/callbacks \
|
||||||
|
token_policies="callbacks" \
|
||||||
|
token_ttl="1h" \
|
||||||
|
token_max_ttl="24h"
|
||||||
|
vault read -field=role_id auth/approle/role/callbacks/role-id
|
||||||
|
vault write -f -field=secret_id auth/approle/role/callbacks/secret-id
|
||||||
|
```
|
||||||
|
|
||||||
|
Store AppRole creds for prod deploy pipeline:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vault kv put kv/sendico/edge/callbacks/vault \
|
||||||
|
role_id="<callbacks-role-id>" \
|
||||||
|
secret_id="<callbacks-secret-id>"
|
||||||
|
```
|
||||||
|
|
||||||
|
Store webhook signing secrets (example path consumed by `secret_ref`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vault kv put kv/sendico/callbacks/client-a/webhook secret="super-secret"
|
||||||
|
```
|
||||||
399
SETUP.md
Normal file
399
SETUP.md
Normal file
@@ -0,0 +1,399 @@
|
|||||||
|
# Sendico Development Environment - Setup Guide
|
||||||
|
|
||||||
|
Complete Docker Compose + Makefile build system for local development.
|
||||||
|
|
||||||
|
## 🎯 What's Included
|
||||||
|
|
||||||
|
**13 Services:**
|
||||||
|
- ✅ Discovery (Service Registry)
|
||||||
|
- ✅ FX Oracle & FX Ingestor
|
||||||
|
- ✅ Billing Fees
|
||||||
|
- ✅ Ledger (Double-Entry Accounting)
|
||||||
|
- ✅ Payments Orchestrator
|
||||||
|
- ✅ Chain Gateway (Blockchain)
|
||||||
|
- ✅ MNTX Gateway (Card Payouts)
|
||||||
|
- ✅ TGSettle Gateway (Telegram Settlements)
|
||||||
|
- ✅ Notification
|
||||||
|
- ✅ BFF (Backend for Frontend / Server)
|
||||||
|
- ✅ Frontend (Flutter Web)
|
||||||
|
|
||||||
|
**Infrastructure:**
|
||||||
|
- MongoDB Replica Set (3 nodes)
|
||||||
|
- NATS with JetStream
|
||||||
|
- Vault (single node, for application data only)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. First time setup
|
||||||
|
make init
|
||||||
|
|
||||||
|
# 2. Start everything
|
||||||
|
make up
|
||||||
|
|
||||||
|
# 3. Initialize Vault (if services need blockchain keys, external API keys)
|
||||||
|
make vault-init
|
||||||
|
|
||||||
|
# 4. Check status
|
||||||
|
make status
|
||||||
|
|
||||||
|
# 5. View frontend
|
||||||
|
open http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Prerequisites
|
||||||
|
|
||||||
|
- Docker & Docker Compose
|
||||||
|
- Make
|
||||||
|
- Go 1.24+ (for local proto generation)
|
||||||
|
- protoc, protoc-gen-go, protoc-gen-go-grpc (for proto generation)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Configuration
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
Edit `.env.dev` (created after `make init`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# MongoDB
|
||||||
|
MONGO_USER=dev_root
|
||||||
|
MONGO_PASSWORD=dev_password_123
|
||||||
|
|
||||||
|
# NATS
|
||||||
|
NATS_USER=dev_nats
|
||||||
|
NATS_PASSWORD=nats_password_123
|
||||||
|
|
||||||
|
# Vault (for application data only)
|
||||||
|
VAULT_ADDR=http://dev-vault:8200
|
||||||
|
```
|
||||||
|
|
||||||
|
**IMPORTANT:** These are plaintext credentials for dev infrastructure only!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Service Ports
|
||||||
|
|
||||||
|
**Infrastructure:**
|
||||||
|
- MongoDB: `localhost:27017`
|
||||||
|
- NATS: `localhost:4222`
|
||||||
|
- NATS Monitoring: `localhost:8222`
|
||||||
|
- Vault: `localhost:8200`
|
||||||
|
|
||||||
|
**Services:**
|
||||||
|
- Discovery: `localhost:9407`
|
||||||
|
- FX Oracle: `localhost:50051` (gRPC), `localhost:9400` (metrics)
|
||||||
|
- FX Ingestor: `localhost:9102`
|
||||||
|
- Billing Fees: `localhost:50060` (gRPC), `localhost:9402` (metrics)
|
||||||
|
- Ledger: `localhost:50052` (gRPC), `localhost:9401` (metrics)
|
||||||
|
- Payments Orchestrator: `localhost:50062` (gRPC), `localhost:9403` (metrics)
|
||||||
|
- Chain Gateway: `localhost:50070` (gRPC), `localhost:9404` (metrics)
|
||||||
|
- MNTX Gateway: `localhost:50075` (gRPC), `localhost:9405` (metrics), `localhost:8084` (HTTP)
|
||||||
|
- TGSettle Gateway: `localhost:50080` (gRPC), `localhost:9406` (metrics)
|
||||||
|
- Notification: `localhost:8081`
|
||||||
|
- BFF: `localhost:8080`
|
||||||
|
- Frontend: `localhost:3000`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Common Commands
|
||||||
|
|
||||||
|
### Lifecycle
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make up # Start all services
|
||||||
|
make down # Stop all services
|
||||||
|
make restart # Restart all services
|
||||||
|
make status # Check status
|
||||||
|
make clean # Remove everything (containers + volumes)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# View logs
|
||||||
|
make logs # All services
|
||||||
|
make logs SERVICE=dev-ledger # Specific service
|
||||||
|
|
||||||
|
# Rebuild after code changes
|
||||||
|
make rebuild SERVICE=dev-ledger
|
||||||
|
|
||||||
|
# Regenerate protobuf
|
||||||
|
make proto
|
||||||
|
```
|
||||||
|
|
||||||
|
### Selective Startup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start infrastructure only
|
||||||
|
make infra-up
|
||||||
|
|
||||||
|
# Then start services
|
||||||
|
make services-up
|
||||||
|
|
||||||
|
# Or start specific service groups
|
||||||
|
make build-core # discovery, ledger, billing-fees
|
||||||
|
make build-fx # fx-oracle, fx-ingestor
|
||||||
|
make build-payments # payments-orchestrator
|
||||||
|
make build-gateways # chain, mntx, tgsettle
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔐 Vault Setup
|
||||||
|
|
||||||
|
Vault is **only for application data** (blockchain keys, external API keys).
|
||||||
|
Infrastructure credentials (MongoDB, NATS) are in `.env.dev`.
|
||||||
|
|
||||||
|
### Initialize Vault
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make vault-init
|
||||||
|
```
|
||||||
|
|
||||||
|
This will:
|
||||||
|
1. Initialize Vault with 1 key share
|
||||||
|
2. Unseal Vault automatically
|
||||||
|
3. Save keys to `vault-keys.txt` (NEVER commit this!)
|
||||||
|
4. Display root token
|
||||||
|
|
||||||
|
### Using Vault in Services
|
||||||
|
|
||||||
|
Services that need Vault (e.g., Chain Gateway for blockchain keys):
|
||||||
|
1. Set `VAULT_ADDR` in service environment
|
||||||
|
2. Use Vault client to fetch secrets at runtime
|
||||||
|
3. Store secrets in Vault: `kv/data/chain/ethereum/private_key`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗️ Development Workflow
|
||||||
|
|
||||||
|
### Typical Day
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Morning: Start environment
|
||||||
|
make up
|
||||||
|
|
||||||
|
# Work on ledger service
|
||||||
|
vim api/ledger/internal/service/ledger/posting.go
|
||||||
|
|
||||||
|
# Rebuild ledger
|
||||||
|
make rebuild SERVICE=dev-ledger
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
make logs SERVICE=dev-ledger
|
||||||
|
|
||||||
|
# Test
|
||||||
|
curl http://localhost:9401/health
|
||||||
|
|
||||||
|
# End of day
|
||||||
|
make down
|
||||||
|
```
|
||||||
|
|
||||||
|
### After Changing Protos
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Regenerate protos
|
||||||
|
make proto
|
||||||
|
|
||||||
|
# Rebuild affected services
|
||||||
|
make rebuild SERVICE=dev-ledger
|
||||||
|
make rebuild SERVICE=dev-payments-orchestrator
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend Development
|
||||||
|
|
||||||
|
For faster iteration, run frontend locally instead of Docker:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start backend services only
|
||||||
|
make infra-up
|
||||||
|
make services-up
|
||||||
|
|
||||||
|
# Run frontend locally
|
||||||
|
cd frontend/pweb
|
||||||
|
flutter run -d chrome
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 Debugging
|
||||||
|
|
||||||
|
### MongoDB Replica Set Issues
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check replica set status
|
||||||
|
docker exec -it dev-mongo-1 mongosh -u dev_root -p dev_password_123 --eval "rs.status()"
|
||||||
|
|
||||||
|
# Reinitialize
|
||||||
|
make down
|
||||||
|
make up
|
||||||
|
docker logs dev-mongo-init
|
||||||
|
```
|
||||||
|
|
||||||
|
### Service Can't Connect to MongoDB
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check if replica set is initialized
|
||||||
|
docker logs dev-mongo-init
|
||||||
|
|
||||||
|
# Check MongoDB is healthy
|
||||||
|
docker ps | grep mongo
|
||||||
|
```
|
||||||
|
|
||||||
|
### Vault Sealed
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Get unseal key from vault-keys.txt
|
||||||
|
UNSEAL_KEY=$(grep 'Unseal Key 1:' vault-keys.txt | awk '{print $NF}')
|
||||||
|
|
||||||
|
# Unseal
|
||||||
|
docker exec dev-vault vault operator unseal $UNSEAL_KEY
|
||||||
|
```
|
||||||
|
|
||||||
|
### Service Crash Loop
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check logs
|
||||||
|
make logs SERVICE=dev-ledger
|
||||||
|
|
||||||
|
# Check environment variables
|
||||||
|
docker inspect dev-ledger | grep -A 20 Env
|
||||||
|
|
||||||
|
# Rebuild from scratch
|
||||||
|
make rebuild SERVICE=dev-ledger
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 Docker Images
|
||||||
|
|
||||||
|
All images tagged as `sendico-dev/<service>:latest`:
|
||||||
|
- `sendico-dev/discovery:latest`
|
||||||
|
- `sendico-dev/ledger:latest`
|
||||||
|
- `sendico-dev/billing-fees:latest`
|
||||||
|
- `sendico-dev/fx-oracle:latest`
|
||||||
|
- `sendico-dev/fx-ingestor:latest`
|
||||||
|
- `sendico-dev/payments-orchestrator:latest`
|
||||||
|
- `sendico-dev/chain-gateway:latest`
|
||||||
|
- `sendico-dev/mntx-gateway:latest`
|
||||||
|
- `sendico-dev/tgsettle-gateway:latest`
|
||||||
|
- `sendico-dev/notification:latest`
|
||||||
|
- `sendico-dev/bff:latest`
|
||||||
|
- `sendico-dev/frontend:latest`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 Testing Full Flow
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Start everything
|
||||||
|
make up
|
||||||
|
|
||||||
|
# 2. Check all services are healthy
|
||||||
|
make status
|
||||||
|
|
||||||
|
# 3. Test discovery
|
||||||
|
curl http://localhost:9407/health
|
||||||
|
|
||||||
|
# 4. Test ledger
|
||||||
|
curl http://localhost:9401/health
|
||||||
|
|
||||||
|
# 5. Test BFF
|
||||||
|
curl http://localhost:8080/api/v1/health
|
||||||
|
|
||||||
|
# 6. Test frontend
|
||||||
|
open http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🗑️ Cleanup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Stop everything
|
||||||
|
make down
|
||||||
|
|
||||||
|
# Remove volumes (fresh start)
|
||||||
|
make clean
|
||||||
|
|
||||||
|
# Remove images
|
||||||
|
docker rmi $(docker images 'sendico-dev/*' -q)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📂 File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
sendico/
|
||||||
|
├── docker-compose.dev.yml # All service definitions
|
||||||
|
├── .env.dev # Plaintext credentials
|
||||||
|
├── Makefile # Build commands
|
||||||
|
├── SETUP.md # This file
|
||||||
|
├── vault-keys.txt # Vault keys (gitignored)
|
||||||
|
└── docker/
|
||||||
|
└── dev/
|
||||||
|
├── README.md # Detailed docs
|
||||||
|
├── mongo.key # MongoDB keyfile
|
||||||
|
├── vault/
|
||||||
|
│ └── config.hcl # Vault config
|
||||||
|
└── *.dockerfile # Service builds
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🆘 Getting Help
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Show all available commands
|
||||||
|
make help
|
||||||
|
|
||||||
|
# List all services
|
||||||
|
make list-services
|
||||||
|
|
||||||
|
# Check service health
|
||||||
|
make health
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎓 Next Steps
|
||||||
|
|
||||||
|
1. **Initialize Vault with secrets** for Chain Gateway
|
||||||
|
2. **Seed test data** into MongoDB
|
||||||
|
3. **Run integration tests**
|
||||||
|
4. **Add monitoring** (Prometheus/Grafana - optional)
|
||||||
|
5. **Document API endpoints** in each service
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ Important Notes
|
||||||
|
|
||||||
|
- **Never commit `.env.dev`** - Contains credentials
|
||||||
|
- **Never commit `vault-keys.txt`** - Contains Vault keys
|
||||||
|
- **Vault is for app data only** - Not for infrastructure secrets
|
||||||
|
- **Use `dev-` prefix** for all service names to avoid conflicts
|
||||||
|
- **MongoDB keyfile** must have `400` permissions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Troubleshooting Checklist
|
||||||
|
|
||||||
|
- [ ] Docker is running
|
||||||
|
- [ ] Docker Compose v2 is installed (`docker compose version`)
|
||||||
|
- [ ] `.env.dev` exists and has correct values
|
||||||
|
- [ ] Vault is initialized and unsealed (`make vault-init`)
|
||||||
|
- [ ] MongoDB replica set is initialized (`docker logs dev-mongo-init`)
|
||||||
|
- [ ] All services are healthy (`make status`)
|
||||||
|
- [ ] No port conflicts on host machine
|
||||||
|
- [ ] Proto generation succeeded (`make proto`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Ready to build? Run `make init && make up`** 🚀
|
||||||
46
api/billing/documents/.air.toml
Normal file
46
api/billing/documents/.air.toml
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
root = "."
|
||||||
|
testdata_dir = "testdata"
|
||||||
|
tmp_dir = "tmp"
|
||||||
|
|
||||||
|
[build]
|
||||||
|
args_bin = []
|
||||||
|
entrypoint = "./tmp/main"
|
||||||
|
cmd = "go build -o ./tmp/main ."
|
||||||
|
delay = 1000
|
||||||
|
exclude_dir = ["assets", "tmp", "vendor", "testdata"]
|
||||||
|
exclude_file = []
|
||||||
|
exclude_regex = ["_test.go", "_templ.go"]
|
||||||
|
exclude_unchanged = false
|
||||||
|
follow_symlink = false
|
||||||
|
full_bin = ""
|
||||||
|
include_dir = []
|
||||||
|
include_ext = ["go", "tpl", "tmpl", "html"]
|
||||||
|
include_file = []
|
||||||
|
kill_delay = "0s"
|
||||||
|
log = "build-errors.log"
|
||||||
|
poll = false
|
||||||
|
poll_interval = 0
|
||||||
|
post_cmd = []
|
||||||
|
pre_cmd = []
|
||||||
|
rerun = false
|
||||||
|
rerun_delay = 500
|
||||||
|
send_interrupt = false
|
||||||
|
stop_on_error = false
|
||||||
|
|
||||||
|
[color]
|
||||||
|
app = ""
|
||||||
|
build = "yellow"
|
||||||
|
main = "magenta"
|
||||||
|
runner = "green"
|
||||||
|
watcher = "cyan"
|
||||||
|
|
||||||
|
[log]
|
||||||
|
main_only = false
|
||||||
|
time = false
|
||||||
|
|
||||||
|
[misc]
|
||||||
|
clean_on_exit = false
|
||||||
|
|
||||||
|
[screen]
|
||||||
|
clear_on_rebuild = false
|
||||||
|
keep_scroll = true
|
||||||
4
api/billing/documents/.gitignore
vendored
Normal file
4
api/billing/documents/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
internal/generated
|
||||||
|
.gocache
|
||||||
|
/app
|
||||||
|
tmp
|
||||||
196
api/billing/documents/.golangci.yml
Normal file
196
api/billing/documents/.golangci.yml
Normal 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: []
|
||||||
BIN
api/billing/documents/assets/logo.png
Normal file
BIN
api/billing/documents/assets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
50
api/billing/documents/config.dev.yml
Normal file
50
api/billing/documents/config.dev.yml
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
runtime:
|
||||||
|
shutdown_timeout_seconds: 15
|
||||||
|
|
||||||
|
grpc:
|
||||||
|
network: tcp
|
||||||
|
address: ":50061"
|
||||||
|
advertise_host: "dev-billing-documents"
|
||||||
|
enable_reflection: true
|
||||||
|
enable_health: true
|
||||||
|
|
||||||
|
metrics:
|
||||||
|
address: ":9409"
|
||||||
|
|
||||||
|
database:
|
||||||
|
driver: mongodb
|
||||||
|
settings:
|
||||||
|
host_env: DOCUMENTS_MONGO_HOST
|
||||||
|
port_env: DOCUMENTS_MONGO_PORT
|
||||||
|
database_env: DOCUMENTS_MONGO_DATABASE
|
||||||
|
user_env: DOCUMENTS_MONGO_USER
|
||||||
|
password_env: DOCUMENTS_MONGO_PASSWORD
|
||||||
|
auth_source_env: DOCUMENTS_MONGO_AUTH_SOURCE
|
||||||
|
replica_set_env: DOCUMENTS_MONGO_REPLICA_SET
|
||||||
|
|
||||||
|
documents:
|
||||||
|
issuer:
|
||||||
|
legal_name: "Sendico Ltd"
|
||||||
|
legal_address: "12 Market Street, London, UK"
|
||||||
|
logo_path: "assets/logo.png"
|
||||||
|
templates:
|
||||||
|
acceptance_path: "templates/acceptance.tpl"
|
||||||
|
protection:
|
||||||
|
owner_password: "sendico-documents"
|
||||||
|
storage:
|
||||||
|
driver: local_fs
|
||||||
|
local:
|
||||||
|
root_path: "tmp/documents"
|
||||||
|
|
||||||
|
messaging:
|
||||||
|
driver: NATS
|
||||||
|
settings:
|
||||||
|
url_env: NATS_URL
|
||||||
|
host_env: NATS_HOST
|
||||||
|
port_env: NATS_PORT
|
||||||
|
username_env: NATS_USER
|
||||||
|
password_env: NATS_PASSWORD
|
||||||
|
broker_name: Billing Documents Service
|
||||||
|
max_reconnects: 10
|
||||||
|
reconnect_wait: 5
|
||||||
|
buffer_size: 1024
|
||||||
56
api/billing/documents/config.yml
Normal file
56
api/billing/documents/config.yml
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
runtime:
|
||||||
|
shutdown_timeout_seconds: 15
|
||||||
|
|
||||||
|
grpc:
|
||||||
|
network: tcp
|
||||||
|
address: ":50061"
|
||||||
|
advertise_host: "sendico_billing_documents"
|
||||||
|
enable_reflection: true
|
||||||
|
enable_health: true
|
||||||
|
|
||||||
|
metrics:
|
||||||
|
address: ":9409"
|
||||||
|
|
||||||
|
database:
|
||||||
|
driver: mongodb
|
||||||
|
settings:
|
||||||
|
host_env: DOCUMENTS_MONGO_HOST
|
||||||
|
port_env: DOCUMENTS_MONGO_PORT
|
||||||
|
database_env: DOCUMENTS_MONGO_DATABASE
|
||||||
|
user_env: DOCUMENTS_MONGO_USER
|
||||||
|
password_env: DOCUMENTS_MONGO_PASSWORD
|
||||||
|
auth_source_env: DOCUMENTS_MONGO_AUTH_SOURCE
|
||||||
|
replica_set_env: DOCUMENTS_MONGO_REPLICA_SET
|
||||||
|
|
||||||
|
documents:
|
||||||
|
issuer:
|
||||||
|
legal_name: "Sendico Ltd"
|
||||||
|
legal_address: "12 Market Street, London, UK"
|
||||||
|
logo_path: "/app/assets/logo.png"
|
||||||
|
templates:
|
||||||
|
acceptance_path: "/app/templates/acceptance.tpl"
|
||||||
|
protection:
|
||||||
|
owner_password: "sendico-documents"
|
||||||
|
storage:
|
||||||
|
driver: minio
|
||||||
|
s3:
|
||||||
|
endpoint: "s3.sendico.io"
|
||||||
|
region: "us-east-1"
|
||||||
|
bucket: "sendico-documents"
|
||||||
|
access_key_env: DOCUMENTS_S3_ACCESS_KEY
|
||||||
|
secret_access_key_env: DOCUMENTS_S3_SECRET_KEY
|
||||||
|
use_ssl: true
|
||||||
|
force_path_style: true
|
||||||
|
|
||||||
|
messaging:
|
||||||
|
driver: NATS
|
||||||
|
settings:
|
||||||
|
url_env: NATS_URL
|
||||||
|
host_env: NATS_HOST
|
||||||
|
port_env: NATS_PORT
|
||||||
|
username_env: NATS_USER
|
||||||
|
password_env: NATS_PASSWORD
|
||||||
|
broker_name: Billing Documents Service
|
||||||
|
max_reconnects: 10
|
||||||
|
reconnect_wait: 5
|
||||||
|
buffer_size: 1024
|
||||||
70
api/billing/documents/go.mod
Normal file
70
api/billing/documents/go.mod
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
module github.com/tech/sendico/billing/documents
|
||||||
|
|
||||||
|
go 1.25.7
|
||||||
|
|
||||||
|
replace github.com/tech/sendico/pkg => ../../pkg
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.41.3
|
||||||
|
github.com/aws/aws-sdk-go-v2/config v1.32.11
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.11
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.3
|
||||||
|
github.com/jung-kurt/gofpdf v1.16.2
|
||||||
|
github.com/prometheus/client_golang v1.23.2
|
||||||
|
github.com/shopspring/decimal v1.4.0
|
||||||
|
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.79.1
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.6 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.19 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.11 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.19 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/signin v1.0.7 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sso v1.30.12 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.41.8 // indirect
|
||||||
|
github.com/aws/smithy-go v1.24.2 // indirect
|
||||||
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
|
github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect
|
||||||
|
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.5 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // 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.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.20.1 // indirect
|
||||||
|
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||||
|
github.com/xdg-go/scram v1.2.0 // indirect
|
||||||
|
github.com/xdg-go/stringprep v1.0.4 // indirect
|
||||||
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||||
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
|
go.yaml.in/yaml/v2 v2.4.3 // 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.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
|
||||||
|
)
|
||||||
271
api/billing/documents/go.sum
Normal file
271
api/billing/documents/go.sum
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
|
||||||
|
dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
|
||||||
|
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
|
||||||
|
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||||
|
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||||
|
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.41.3 h1:4kQ/fa22KjDt13QCy1+bYADvdgcxpfH18f0zP542kZA=
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.41.3/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
|
||||||
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.6 h1:N4lRUXZpZ1KVEUn6hxtco/1d2lgYhNn1fHkkl8WhlyQ=
|
||||||
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.6/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
|
||||||
|
github.com/aws/aws-sdk-go-v2/config v1.32.11 h1:ftxI5sgz8jZkckuUHXfC/wMUc8u3fG1vQS0plr2F2Zs=
|
||||||
|
github.com/aws/aws-sdk-go-v2/config v1.32.11/go.mod h1:twF11+6ps9aNRKEDimksp923o44w/Thk9+8YIlzWMmo=
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.11 h1:NdV8cwCcAXrCWyxArt58BrvZJ9pZ9Fhf9w6Uh5W3Uyc=
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.11/go.mod h1:30yY2zqkMPdrvxBqzI9xQCM+WrlrZKSOpSJEsylVU+8=
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19 h1:INUvJxmhdEbVulJYHI061k4TVuS3jzzthNvjqvVvTKM=
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19/go.mod h1:FpZN2QISLdEBWkayloda+sZjVJL+e9Gl0k1SyTgcswU=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19 h1:/sECfyq2JTifMI2JPyZ4bdRN77zJmr6SrS1eL3augIA=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19/go.mod h1:dMf8A5oAqr9/oxOfLkC/c2LU/uMcALP0Rgn2BD5LWn0=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19 h1:AWeJMk33GTBf6J20XJe6qZoRSJo0WfUhsMdUKhoODXE=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19/go.mod h1:+GWrYoaAsV7/4pNHpwh1kiNLXkKaSoppxQq9lbH8Ejw=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5 h1:clHU5fm//kWS1C2HgtgWxfQbFbx4b6rx+5jzhgX9HrI=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.19 h1:3Y4oma5TiV7tT9wa8zRcdoXwZkGz9Q/wxbEUK7cMuAM=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.19/go.mod h1:V1K+TeJVD5JOk3D9e5tsX2KUdL7BlB+FV6cBhdobN8c=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6 h1:XAq62tBTJP/85lFD5oqOOe7YYgWxY9LvWq8plyDvDVg=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.11 h1:BYf7XNsJMzl4mObARUBUib+j2tf0U//JAAtTnYqvqCw=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.11/go.mod h1:aEUS4WrNk/+FxkBZZa7tVgp4pGH+kFGW40Y8rCPqt5g=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19 h1:X1Tow7suZk9UCJHE1Iw9GMZJJl0dAnKXXP1NaSDHwmw=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19/go.mod h1:/rARO8psX+4sfjUQXp5LLifjUt8DuATZ31WptNJTyQA=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.19 h1:JnQeStZvPHFHeyky/7LbMlyQjUa+jIBj36OlWm0pzIk=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.19/go.mod h1:HGyasyHvYdFQeJhvDHfH7HXkHh57htcJGKDZ+7z+I24=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.3 h1:+d0SsTvxtIJt4tSJ6wr+jrxEMDa6XeupjRv8H7Qitkk=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.3/go.mod h1:ROUNFvFWPwBlOu687WJNQ9cPvd2ccpFrnCiA1YGz50o=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/signin v1.0.7 h1:Y2cAXlClHsXkkOvWZFXATr34b0hxxloeQu/pAZz2row=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/signin v1.0.7/go.mod h1:idzZ7gmDeqeNrSPkdbtMp9qWMgcBwykA7P7Rzh5DXVU=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sso v1.30.12 h1:iSsvB9EtQ09YrsmIc44Heqlx5ByGErqhPK1ZQLppias=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sso v1.30.12/go.mod h1:fEWYKTRGoZNl8tZ77i61/ccwOMJdGxwOhWCkp6TXAr0=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16 h1:EnUdUqRP1CNzt2DkV67tJx6XDN4xlfBFm+bzeNOQVb0=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16/go.mod h1:Jic/xv0Rq/pFNCh3WwpH4BEqdbSAl+IyHro8LbibHD8=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.41.8 h1:XQTQTF75vnug2TXS8m7CVJfC2nniYPZnO1D4Np761Oo=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.41.8/go.mod h1:Xgx+PR1NUOjNmQY+tRMnouRp83JRM8pRMw/vCaVhPkI=
|
||||||
|
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
|
||||||
|
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||||
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
|
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||||
|
github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
|
||||||
|
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||||
|
github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||||
|
github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk=
|
||||||
|
github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
|
||||||
|
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||||
|
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
|
||||||
|
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||||
|
github.com/casbin/mongodb-adapter/v4 v4.3.0 h1:yYXky9v1by6vj/0QK7OyHyd/xpz4vzh0lCi7JKrS4qQ=
|
||||||
|
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=
|
||||||
|
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
|
||||||
|
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
|
||||||
|
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||||
|
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||||
|
github.com/docker/docker v27.3.1+incompatible h1:KttF0XoteNTicmUtBO0L2tP+J7FGRFTjaEF4k6WdhfI=
|
||||||
|
github.com/docker/docker v27.3.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||||
|
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
|
||||||
|
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
||||||
|
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||||
|
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||||
|
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
|
github.com/go-chi/chi/v5 v5.2.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=
|
||||||
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
|
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||||
|
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||||
|
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||||
|
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||||
|
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
|
||||||
|
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
|
||||||
|
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
|
||||||
|
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
|
github.com/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/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.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=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||||
|
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||||
|
github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54 h1:mFWunSatvkQQDhpdyuFAYwyAan3hzCuma+Pz8sqvOfg=
|
||||||
|
github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||||
|
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
|
||||||
|
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||||
|
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||||
|
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||||
|
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||||
|
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||||
|
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
|
||||||
|
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
|
||||||
|
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
|
||||||
|
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
|
||||||
|
github.com/moby/sys/user v0.3.0 h1:9ni5DlcW5an3SvRSx4MouotOygvzaXbaSrc/wGDFWPo=
|
||||||
|
github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
|
||||||
|
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
|
||||||
|
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
|
||||||
|
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
||||||
|
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
|
||||||
|
github.com/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.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=
|
||||||
|
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||||
|
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||||
|
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||||
|
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
|
||||||
|
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
|
||||||
|
github.com/phpdave11/gofpdi v1.0.7/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
|
||||||
|
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||||
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||||
|
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||||
|
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||||
|
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||||
|
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||||
|
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
|
||||||
|
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
||||||
|
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
|
||||||
|
github.com/prometheus/procfs v0.20.1/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=
|
||||||
|
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
|
||||||
|
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
|
||||||
|
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
|
||||||
|
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
|
||||||
|
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||||
|
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||||
|
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||||
|
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||||
|
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||||
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
|
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/testcontainers/testcontainers-go v0.33.0 h1:zJS9PfXYT5O0ZFXM2xxXfk4J5UMw/kRiISng037Gxdw=
|
||||||
|
github.com/testcontainers/testcontainers-go v0.33.0/go.mod h1:W80YpTa8D5C3Yy16icheD01UTDu+LmXIA2Keo+jWtT8=
|
||||||
|
github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0 h1:iXVA84s5hKMS5gn01GWOYHE3ymy/2b+0YkpFeTxB2XY=
|
||||||
|
github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0/go.mod h1:R6tMjTojRiaoo89fh/hf7tOmfzohdqSU17R9DwSVSog=
|
||||||
|
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
|
||||||
|
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
|
||||||
|
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
|
||||||
|
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
|
||||||
|
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
||||||
|
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||||
|
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
|
||||||
|
github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8=
|
||||||
|
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
|
||||||
|
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
||||||
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
|
||||||
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
|
||||||
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
|
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||||
|
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||||
|
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||||
|
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 h1:UP6IpuHFkUgOQL9FFQFrZ+5LiwhhYRbi7VZSIx6Nj5s=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM=
|
||||||
|
go.opentelemetry.io/otel v1.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=
|
||||||
|
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||||
|
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
|
||||||
|
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||||
|
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||||
|
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
|
golang.org/x/crypto v0.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.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=
|
||||||
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.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.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=
|
||||||
|
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-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=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
29
api/billing/documents/internal/appversion/version.go
Normal file
29
api/billing/documents/internal/appversion/version.go
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
package appversion
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/tech/sendico/pkg/version"
|
||||||
|
vf "github.com/tech/sendico/pkg/version/factory"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Build information populated at build time.
|
||||||
|
var (
|
||||||
|
Version string
|
||||||
|
Revision string
|
||||||
|
Branch string
|
||||||
|
BuildUser string
|
||||||
|
BuildDate string
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create initialises a version.Printer with the build details for this service.
|
||||||
|
func Create() version.Printer {
|
||||||
|
info := version.Info{
|
||||||
|
Program: "Sendico Billing Documents Service",
|
||||||
|
Revision: Revision,
|
||||||
|
Branch: Branch,
|
||||||
|
BuildUser: BuildUser,
|
||||||
|
BuildDate: BuildDate,
|
||||||
|
Version: Version,
|
||||||
|
}
|
||||||
|
|
||||||
|
return vf.Create(&info)
|
||||||
|
}
|
||||||
71
api/billing/documents/internal/docstore/local.go
Normal file
71
api/billing/documents/internal/docstore/local.go
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
package docstore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LocalStore struct {
|
||||||
|
logger mlogger.Logger
|
||||||
|
rootPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLocalStore(logger mlogger.Logger, cfg LocalConfig) (*LocalStore, error) {
|
||||||
|
root := strings.TrimSpace(cfg.RootPath)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ Store = (*LocalStore)(nil)
|
||||||
132
api/billing/documents/internal/docstore/s3.go
Normal file
132
api/billing/documents/internal/docstore/s3.go
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
package docstore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/aws/aws-sdk-go-v2/aws"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/config"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
type S3Store struct {
|
||||||
|
logger mlogger.Logger
|
||||||
|
client *s3.Client
|
||||||
|
bucket string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewS3Store(logger mlogger.Logger, cfg S3Config) (*S3Store, error) {
|
||||||
|
bucket := strings.TrimSpace(cfg.Bucket)
|
||||||
|
if bucket == "" {
|
||||||
|
return nil, merrors.InvalidArgument("docstore: bucket is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
accessKey := strings.TrimSpace(cfg.AccessKey)
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
region := strings.TrimSpace(cfg.Region)
|
||||||
|
if region == "" {
|
||||||
|
region = "us-east-1"
|
||||||
|
}
|
||||||
|
|
||||||
|
loadOpts := []func(*config.LoadOptions) error{
|
||||||
|
config.WithRegion(region),
|
||||||
|
}
|
||||||
|
if accessKey != "" || secretKey != "" {
|
||||||
|
loadOpts = append(loadOpts, config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(
|
||||||
|
accessKey,
|
||||||
|
secretKey,
|
||||||
|
"",
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint := strings.TrimSpace(cfg.Endpoint)
|
||||||
|
if endpoint != "" {
|
||||||
|
if !strings.HasPrefix(endpoint, "http://") && !strings.HasPrefix(endpoint, "https://") {
|
||||||
|
if cfg.UseSSL {
|
||||||
|
endpoint = "https://" + endpoint
|
||||||
|
} else {
|
||||||
|
endpoint = "http://" + endpoint
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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{
|
||||||
|
logger: logger.Named("docstore").Named("s3"),
|
||||||
|
client: client,
|
||||||
|
bucket: bucket,
|
||||||
|
}
|
||||||
|
store.logger.Info("Document storage initialised", zap.String("bucket", bucket), zap.String("endpoint", endpoint))
|
||||||
|
|
||||||
|
return store, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
Body: bytes.NewReader(data),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("Failed to upload document", zap.Error(err), zap.String("key", key))
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ Store = (*S3Store)(nil)
|
||||||
69
api/billing/documents/internal/docstore/store.go
Normal file
69
api/billing/documents/internal/docstore/store.go
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
package docstore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Driver identifies the document storage backend.
|
||||||
|
type Driver string
|
||||||
|
|
||||||
|
const (
|
||||||
|
DriverLocal Driver = "local_fs"
|
||||||
|
DriverS3 Driver = "s3"
|
||||||
|
DriverMinio Driver = "minio"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config configures the document storage backend.
|
||||||
|
type Config struct {
|
||||||
|
Driver Driver `yaml:"driver"`
|
||||||
|
Local *LocalConfig `yaml:"local"`
|
||||||
|
S3 *S3Config `yaml:"s3"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LocalConfig configures local filesystem storage.
|
||||||
|
type LocalConfig struct {
|
||||||
|
RootPath string `yaml:"root_path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// S3Config configures S3/Minio storage.
|
||||||
|
type S3Config struct {
|
||||||
|
Endpoint string `yaml:"endpoint"`
|
||||||
|
Region string `yaml:"region"`
|
||||||
|
Bucket string `yaml:"bucket"`
|
||||||
|
AccessKeyEnv string `yaml:"access_key_env"`
|
||||||
|
SecretKeyEnv string `yaml:"secret_access_key_env"`
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store defines storage operations for generated documents.
|
||||||
|
type Store interface {
|
||||||
|
Save(ctx context.Context, key string, data []byte) error
|
||||||
|
Load(ctx context.Context, key string) ([]byte, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a document store based on config.
|
||||||
|
func New(logger mlogger.Logger, cfg Config) (Store, error) {
|
||||||
|
switch strings.ToLower(string(cfg.Driver)) {
|
||||||
|
case string(DriverLocal):
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
153
api/billing/documents/internal/server/internal/serverimp.go
Normal file
153
api/billing/documents/internal/server/internal/serverimp.go
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
package serverimp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/billing/documents/internal/docstore"
|
||||||
|
"github.com/tech/sendico/billing/documents/internal/service/documents"
|
||||||
|
"github.com/tech/sendico/billing/documents/storage"
|
||||||
|
mongostorage "github.com/tech/sendico/billing/documents/storage/mongo"
|
||||||
|
"github.com/tech/sendico/pkg/api/routers"
|
||||||
|
"github.com/tech/sendico/pkg/db"
|
||||||
|
msg "github.com/tech/sendico/pkg/messaging"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"github.com/tech/sendico/pkg/server/grpcapp"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Imp struct {
|
||||||
|
logger mlogger.Logger
|
||||||
|
file string
|
||||||
|
debug bool
|
||||||
|
config *config
|
||||||
|
app *grpcapp.App[storage.Repository]
|
||||||
|
service *documents.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
type config struct {
|
||||||
|
*grpcapp.Config `yaml:",inline"`
|
||||||
|
|
||||||
|
Documents documents.Config `yaml:"documents"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create initialises the billing documents server implementation.
|
||||||
|
func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) {
|
||||||
|
return &Imp{
|
||||||
|
logger: logger.Named("server"),
|
||||||
|
file: file,
|
||||||
|
debug: debug,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Imp) Shutdown() {
|
||||||
|
if i.app == nil {
|
||||||
|
if i.service != nil {
|
||||||
|
i.service.Shutdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
timeout := 15 * time.Second
|
||||||
|
if i.config != nil && i.config.Runtime != nil {
|
||||||
|
timeout = i.config.Runtime.ShutdownTimeout()
|
||||||
|
}
|
||||||
|
|
||||||
|
if i.service != nil {
|
||||||
|
i.service.Shutdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
|
i.app.Shutdown(ctx)
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Imp) Start() error {
|
||||||
|
cfg, err := i.loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
i.config = cfg
|
||||||
|
|
||||||
|
repoFactory := func(logger mlogger.Logger, conn *db.MongoConnection) (storage.Repository, error) {
|
||||||
|
return mongostorage.New(logger, conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
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) { //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
|
||||||
|
}
|
||||||
|
|
||||||
|
app, err := grpcapp.NewApp(i.logger, "billing_documents", cfg.Config, i.debug, repoFactory, serviceFactory)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
i.app = app
|
||||||
|
|
||||||
|
return i.app.Start()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Imp) loadConfig() (*config, error) {
|
||||||
|
data, err := os.ReadFile(i.file)
|
||||||
|
if err != nil {
|
||||||
|
i.logger.Error("Could not read configuration file", zap.String("config_file", i.file), zap.Error(err))
|
||||||
|
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := &config{Config: &grpcapp.Config{}}
|
||||||
|
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||||
|
i.logger.Error("Failed to parse configuration", zap.Error(err))
|
||||||
|
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Runtime == nil {
|
||||||
|
cfg.Runtime = &grpcapp.RuntimeConfig{ShutdownTimeoutSeconds: 15}
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.GRPC == nil {
|
||||||
|
cfg.GRPC = &routers.GRPCConfig{
|
||||||
|
Network: "tcp",
|
||||||
|
Address: ":50061",
|
||||||
|
EnableReflection: true,
|
||||||
|
EnableHealth: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Metrics == nil {
|
||||||
|
cfg.Metrics = &grpcapp.MetricsConfig{Address: ":9409"}
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Documents.Storage.Driver == "" {
|
||||||
|
cfg.Documents.Storage.Driver = docstore.DriverLocal
|
||||||
|
if cfg.Documents.Storage.Local == nil {
|
||||||
|
cfg.Documents.Storage.Local = &docstore.LocalConfig{RootPath: "tmp/documents"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
12
api/billing/documents/internal/server/server.go
Normal file
12
api/billing/documents/internal/server/server.go
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
serverimp "github.com/tech/sendico/billing/documents/internal/server/internal"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"github.com/tech/sendico/pkg/server"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create constructs the billing documents server implementation.
|
||||||
|
func Create(logger mlogger.Logger, file string, debug bool) (server.Application, error) {
|
||||||
|
return serverimp.Create(logger, file, debug)
|
||||||
|
}
|
||||||
34
api/billing/documents/internal/service/documents/config.go
Normal file
34
api/billing/documents/internal/service/documents/config.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package documents
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/billing/documents/internal/docstore"
|
||||||
|
"github.com/tech/sendico/billing/documents/renderer"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config holds document service settings loaded from YAML.
|
||||||
|
type Config struct {
|
||||||
|
Issuer renderer.Issuer `yaml:"issuer"`
|
||||||
|
Templates TemplateConfig `yaml:"templates"`
|
||||||
|
Protection ProtectionConfig `yaml:"protection"`
|
||||||
|
Storage docstore.Config `yaml:"storage"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TemplateConfig defines document template locations.
|
||||||
|
type TemplateConfig struct {
|
||||||
|
AcceptancePath string `yaml:"acceptance_path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProtectionConfig configures PDF protection.
|
||||||
|
type ProtectionConfig struct {
|
||||||
|
OwnerPassword string `yaml:"owner_password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Config) AcceptanceTemplatePath() string {
|
||||||
|
if strings.TrimSpace(c.Templates.AcceptancePath) == "" {
|
||||||
|
return "templates/acceptance.tpl"
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Templates.AcceptancePath
|
||||||
|
}
|
||||||
110
api/billing/documents/internal/service/documents/metrics.go
Normal file
110
api/billing/documents/internal/service/documents/metrics.go
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
package documents
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||||
|
documentsv1 "github.com/tech/sendico/pkg/proto/billing/documents/v1"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
metricsOnce sync.Once
|
||||||
|
|
||||||
|
requestsTotal *prometheus.CounterVec
|
||||||
|
requestLatency *prometheus.HistogramVec
|
||||||
|
batchSize prometheus.Histogram
|
||||||
|
documentBytes *prometheus.HistogramVec
|
||||||
|
)
|
||||||
|
|
||||||
|
func initMetrics() {
|
||||||
|
metricsOnce.Do(func() {
|
||||||
|
requestsTotal = promauto.NewCounterVec(
|
||||||
|
prometheus.CounterOpts{
|
||||||
|
Namespace: "billing",
|
||||||
|
Subsystem: "documents",
|
||||||
|
Name: "requests_total",
|
||||||
|
Help: "Total number of billing document requests processed.",
|
||||||
|
},
|
||||||
|
[]string{"call", "status", "doc_type"},
|
||||||
|
)
|
||||||
|
|
||||||
|
requestLatency = promauto.NewHistogramVec(
|
||||||
|
prometheus.HistogramOpts{
|
||||||
|
Namespace: "billing",
|
||||||
|
Subsystem: "documents",
|
||||||
|
Name: "request_latency_seconds",
|
||||||
|
Help: "Latency of billing document requests.",
|
||||||
|
Buckets: prometheus.DefBuckets,
|
||||||
|
},
|
||||||
|
[]string{"call", "status", "doc_type"},
|
||||||
|
)
|
||||||
|
|
||||||
|
batchSize = promauto.NewHistogram(
|
||||||
|
prometheus.HistogramOpts{
|
||||||
|
Namespace: "billing",
|
||||||
|
Subsystem: "documents",
|
||||||
|
Name: "batch_size",
|
||||||
|
Help: "Number of payment references in batch resolution requests.",
|
||||||
|
Buckets: []float64{0, 1, 2, 5, 10, 20, 50, 100, 250, 500},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
documentBytes = promauto.NewHistogramVec(
|
||||||
|
prometheus.HistogramOpts{
|
||||||
|
Namespace: "billing",
|
||||||
|
Subsystem: "documents",
|
||||||
|
Name: "document_bytes",
|
||||||
|
Help: "Size of generated billing document payloads.",
|
||||||
|
Buckets: prometheus.ExponentialBuckets(1024, 2, 10),
|
||||||
|
},
|
||||||
|
[]string{"doc_type"},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func observeRequest(call string, docType documentsv1.DocumentType, statusLabel string, took time.Duration) {
|
||||||
|
typeLabel := docTypeLabel(docType)
|
||||||
|
requestsTotal.WithLabelValues(call, statusLabel, typeLabel).Inc()
|
||||||
|
requestLatency.WithLabelValues(call, statusLabel, typeLabel).Observe(took.Seconds())
|
||||||
|
}
|
||||||
|
|
||||||
|
func observeBatchSize(size int) {
|
||||||
|
batchSize.Observe(float64(size))
|
||||||
|
}
|
||||||
|
|
||||||
|
func observeDocumentBytes(docType documentsv1.DocumentType, size int) {
|
||||||
|
documentBytes.WithLabelValues(docTypeLabel(docType)).Observe(float64(size))
|
||||||
|
}
|
||||||
|
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
|
||||||
|
func docTypeLabel(docType documentsv1.DocumentType) string {
|
||||||
|
label := docType.String()
|
||||||
|
if label == "" {
|
||||||
|
return "DOCUMENT_TYPE_UNSPECIFIED"
|
||||||
|
}
|
||||||
|
|
||||||
|
return label
|
||||||
|
}
|
||||||
584
api/billing/documents/internal/service/documents/service.go
Normal file
584
api/billing/documents/internal/service/documents/service.go
Normal file
@@ -0,0 +1,584 @@
|
|||||||
|
package documents
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/billing/documents/internal/appversion"
|
||||||
|
"github.com/tech/sendico/billing/documents/internal/docstore"
|
||||||
|
"github.com/tech/sendico/billing/documents/renderer"
|
||||||
|
"github.com/tech/sendico/billing/documents/storage"
|
||||||
|
"github.com/tech/sendico/billing/documents/storage/model"
|
||||||
|
"github.com/tech/sendico/pkg/api/routers"
|
||||||
|
"github.com/tech/sendico/pkg/discovery"
|
||||||
|
msg "github.com/tech/sendico/pkg/messaging"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"github.com/tech/sendico/pkg/mservice"
|
||||||
|
documentsv1 "github.com/tech/sendico/pkg/proto/billing/documents/v1"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TemplateRenderer renders the acceptance template into tagged blocks.
|
||||||
|
type TemplateRenderer interface {
|
||||||
|
Render(snapshot model.ActSnapshot) ([]renderer.Block, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Option configures the documents service.
|
||||||
|
type Option func(*Service)
|
||||||
|
|
||||||
|
// WithDiscoveryInvokeURI configures the discovery invoke URI.
|
||||||
|
func WithDiscoveryInvokeURI(uri string) Option {
|
||||||
|
return func(s *Service) {
|
||||||
|
if s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.invokeURI = strings.TrimSpace(uri)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithProducer sets the messaging producer.
|
||||||
|
func WithProducer(producer msg.Producer) Option {
|
||||||
|
return func(s *Service) {
|
||||||
|
if s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.producer = producer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithConfig sets the service config.
|
||||||
|
func WithConfig(cfg Config) Option {
|
||||||
|
return func(s *Service) {
|
||||||
|
if s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.config = cfg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithDocumentStore sets the document storage backend.
|
||||||
|
func WithDocumentStore(store docstore.Store) Option {
|
||||||
|
return func(s *Service) {
|
||||||
|
if s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.docStore = store
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithTemplateRenderer overrides the template renderer (useful for tests).
|
||||||
|
func WithTemplateRenderer(renderer TemplateRenderer) Option {
|
||||||
|
return func(s *Service) {
|
||||||
|
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
|
||||||
|
producer msg.Producer
|
||||||
|
announcer *discovery.Announcer
|
||||||
|
invokeURI string
|
||||||
|
config Config
|
||||||
|
template TemplateRenderer
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
producer: producer,
|
||||||
|
}
|
||||||
|
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))
|
||||||
|
} else {
|
||||||
|
svc.template = tmpl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
svc.startDiscoveryAnnouncer()
|
||||||
|
|
||||||
|
return svc
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Register(router routers.GRPC) error {
|
||||||
|
return router.Register(func(reg grpc.ServiceRegistrar) {
|
||||||
|
documentsv1.RegisterDocumentServiceServer(reg, s)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Shutdown() {
|
||||||
|
if s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.announcer != nil {
|
||||||
|
s.announcer.Stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) BatchResolveDocuments(ctx context.Context, req *documentsv1.BatchResolveDocumentsRequest) (resp *documentsv1.BatchResolveDocumentsResponse, err error) {
|
||||||
|
start := time.Now()
|
||||||
|
paymentRefs := 0
|
||||||
|
if req != nil {
|
||||||
|
paymentRefs = len(req.GetPaymentRefs())
|
||||||
|
}
|
||||||
|
|
||||||
|
logger := s.logger.With(zap.Int("payment_refs", paymentRefs))
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
statusLabel := statusFromError(err)
|
||||||
|
observeRequest("batch_resolve", documentsv1.DocumentType_DOCUMENT_TYPE_UNSPECIFIED, statusLabel, time.Since(start))
|
||||||
|
observeBatchSize(paymentRefs)
|
||||||
|
|
||||||
|
itemsCount := 0
|
||||||
|
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...)
|
||||||
|
}()
|
||||||
|
|
||||||
|
_ = ctx
|
||||||
|
err = status.Error(codes.Unimplemented, "payment-level document flow removed; use GetOperationDocument")
|
||||||
|
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetDocument(ctx context.Context, req *documentsv1.GetDocumentRequest) (resp *documentsv1.GetDocumentResponse, err error) {
|
||||||
|
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)),
|
||||||
|
)
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
statusLabel := statusFromError(err)
|
||||||
|
observeRequest("get_document", docType, statusLabel, time.Since(start))
|
||||||
|
|
||||||
|
if resp != nil {
|
||||||
|
observeDocumentBytes(docType, len(resp.GetContent()))
|
||||||
|
}
|
||||||
|
|
||||||
|
contentBytes := 0
|
||||||
|
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...)
|
||||||
|
}()
|
||||||
|
|
||||||
|
_ = ctx
|
||||||
|
err = status.Error(codes.Unimplemented, "payment-level document flow removed; use GetOperationDocument")
|
||||||
|
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetOperationDocument(_ context.Context, req *documentsv1.GetOperationDocumentRequest) (resp *documentsv1.GetDocumentResponse, err error) {
|
||||||
|
start := time.Now()
|
||||||
|
organizationRef := ""
|
||||||
|
gatewayService := ""
|
||||||
|
operationRef := ""
|
||||||
|
|
||||||
|
if req != nil {
|
||||||
|
organizationRef = strings.TrimSpace(req.GetOrganizationRef())
|
||||||
|
gatewayService = strings.TrimSpace(req.GetGatewayService())
|
||||||
|
operationRef = strings.TrimSpace(req.GetOperationRef())
|
||||||
|
}
|
||||||
|
|
||||||
|
logger := s.logger.With(
|
||||||
|
zap.String("organization_ref", organizationRef),
|
||||||
|
zap.String("gateway_service", gatewayService),
|
||||||
|
zap.String("operation_ref", operationRef),
|
||||||
|
)
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
statusLabel := statusFromError(err)
|
||||||
|
docType := documentsv1.DocumentType_DOCUMENT_TYPE_UNSPECIFIED
|
||||||
|
observeRequest("get_operation_document", docType, statusLabel, time.Since(start))
|
||||||
|
|
||||||
|
if resp != nil {
|
||||||
|
observeDocumentBytes(docType, len(resp.GetContent()))
|
||||||
|
}
|
||||||
|
|
||||||
|
contentBytes := 0
|
||||||
|
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("GetOperationDocument failed", append(fields, zap.Error(err))...)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info("GetOperationDocument finished", fields...)
|
||||||
|
}()
|
||||||
|
|
||||||
|
if req == nil {
|
||||||
|
err = status.Error(codes.InvalidArgument, "request is required")
|
||||||
|
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if organizationRef == "" {
|
||||||
|
err = status.Error(codes.InvalidArgument, "organization_ref is required")
|
||||||
|
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if gatewayService == "" {
|
||||||
|
err = status.Error(codes.InvalidArgument, "gateway_service is required")
|
||||||
|
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if operationRef == "" {
|
||||||
|
err = status.Error(codes.InvalidArgument, "operation_ref is required")
|
||||||
|
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot := operationSnapshotFromRequest(req)
|
||||||
|
content, _, genErr := s.generateOperationPDF(snapshot)
|
||||||
|
if genErr != nil {
|
||||||
|
err = status.Error(codes.Internal, genErr.Error())
|
||||||
|
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = &documentsv1.GetDocumentResponse{
|
||||||
|
Content: content,
|
||||||
|
Filename: operationDocumentFilename(operationRef),
|
||||||
|
MimeType: "application/pdf",
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) startDiscoveryAnnouncer() {
|
||||||
|
if s == nil || s.producer == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
announce := discovery.Announcement{
|
||||||
|
Service: mservice.BillingDocuments,
|
||||||
|
Operations: []string{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 {
|
||||||
|
return string(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
errStorageUnavailable = serviceError("documents: storage not initialised")
|
||||||
|
errDocStoreUnavailable = serviceError("documents: document store not initialised")
|
||||||
|
errTemplateUnavailable = serviceError("documents: template renderer not initialised")
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Service) generateActPDF(snapshot model.ActSnapshot) ([]byte, string, error) {
|
||||||
|
blocks, err := s.template.Render(snapshot)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.renderPDFWithIntegrity(blocks)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) generateOperationPDF(snapshot operationSnapshot) ([]byte, string, error) {
|
||||||
|
return s.renderPDFWithIntegrity(buildOperationBlocks(snapshot))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) renderPDFWithIntegrity(blocks []renderer.Block) ([]byte, string, error) {
|
||||||
|
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[:])
|
||||||
|
|
||||||
|
finalBytes, err := generated.Render(blocks, footerHex)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return finalBytes, footerHex, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type operationSnapshot struct {
|
||||||
|
OrganizationRef string
|
||||||
|
GatewayService string
|
||||||
|
OperationRef string
|
||||||
|
PaymentRef string
|
||||||
|
OperationCode string
|
||||||
|
OperationLabel string
|
||||||
|
OperationState string
|
||||||
|
FailureCode string
|
||||||
|
FailureReason string
|
||||||
|
Amount string
|
||||||
|
Currency string
|
||||||
|
StartedAt time.Time
|
||||||
|
CompletedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func operationSnapshotFromRequest(req *documentsv1.GetOperationDocumentRequest) operationSnapshot {
|
||||||
|
snapshot := operationSnapshot{
|
||||||
|
OrganizationRef: strings.TrimSpace(req.GetOrganizationRef()),
|
||||||
|
GatewayService: strings.TrimSpace(req.GetGatewayService()),
|
||||||
|
OperationRef: strings.TrimSpace(req.GetOperationRef()),
|
||||||
|
PaymentRef: strings.TrimSpace(req.GetPaymentRef()),
|
||||||
|
OperationCode: strings.TrimSpace(req.GetOperationCode()),
|
||||||
|
OperationLabel: strings.TrimSpace(req.GetOperationLabel()),
|
||||||
|
OperationState: strings.TrimSpace(req.GetOperationState()),
|
||||||
|
FailureCode: strings.TrimSpace(req.GetFailureCode()),
|
||||||
|
FailureReason: strings.TrimSpace(req.GetFailureReason()),
|
||||||
|
Amount: strings.TrimSpace(req.GetAmount()),
|
||||||
|
Currency: strings.TrimSpace(req.GetCurrency()),
|
||||||
|
}
|
||||||
|
|
||||||
|
if ts := req.GetStartedAtUnixMs(); ts > 0 {
|
||||||
|
snapshot.StartedAt = time.UnixMilli(ts).UTC()
|
||||||
|
}
|
||||||
|
if ts := req.GetCompletedAtUnixMs(); ts > 0 {
|
||||||
|
snapshot.CompletedAt = time.UnixMilli(ts).UTC()
|
||||||
|
}
|
||||||
|
|
||||||
|
return snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildOperationBlocks(snapshot operationSnapshot) []renderer.Block {
|
||||||
|
rows := [][]string{
|
||||||
|
{"Organization", snapshot.OrganizationRef},
|
||||||
|
{"Gateway Service", snapshot.GatewayService},
|
||||||
|
{"Operation Ref", snapshot.OperationRef},
|
||||||
|
{"Payment Ref", safeValue(snapshot.PaymentRef)},
|
||||||
|
{"Code", safeValue(snapshot.OperationCode)},
|
||||||
|
{"State", safeValue(snapshot.OperationState)},
|
||||||
|
{"Label", safeValue(snapshot.OperationLabel)},
|
||||||
|
{"Started At (UTC)", formatSnapshotTime(snapshot.StartedAt)},
|
||||||
|
{"Completed At (UTC)", formatSnapshotTime(snapshot.CompletedAt)},
|
||||||
|
}
|
||||||
|
if snapshot.Amount != "" || snapshot.Currency != "" {
|
||||||
|
rows = append(rows, []string{"Amount", strings.TrimSpace(strings.TrimSpace(snapshot.Amount) + " " + strings.TrimSpace(snapshot.Currency))})
|
||||||
|
}
|
||||||
|
|
||||||
|
blocks := []renderer.Block{
|
||||||
|
{
|
||||||
|
Tag: renderer.TagTitle,
|
||||||
|
Lines: []string{"OPERATION BILLING DOCUMENT"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Tag: renderer.TagSubtitle,
|
||||||
|
Lines: []string{"Gateway operation statement"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Tag: renderer.TagMeta,
|
||||||
|
Lines: []string{
|
||||||
|
"Document Type: Operation",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Tag: renderer.TagSection,
|
||||||
|
Lines: []string{"OPERATION DETAILS"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Tag: renderer.TagKV,
|
||||||
|
Rows: rows,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if snapshot.FailureCode != "" || snapshot.FailureReason != "" {
|
||||||
|
blocks = append(blocks,
|
||||||
|
renderer.Block{Tag: renderer.TagSection, Lines: []string{"FAILURE DETAILS"}},
|
||||||
|
renderer.Block{
|
||||||
|
Tag: renderer.TagKV,
|
||||||
|
Rows: [][]string{
|
||||||
|
{"Failure Code", safeValue(snapshot.FailureCode)},
|
||||||
|
{"Failure Reason", safeValue(snapshot.FailureReason)},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return blocks
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatSnapshotTime(value time.Time) string {
|
||||||
|
if value.IsZero() {
|
||||||
|
return "n/a"
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
|
func safeValue(value string) string {
|
||||||
|
trimmed := strings.TrimSpace(value)
|
||||||
|
if trimmed == "" {
|
||||||
|
return "n/a"
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
func operationDocumentFilename(operationRef string) string {
|
||||||
|
clean := sanitizeFilenameComponent(operationRef)
|
||||||
|
if clean == "" {
|
||||||
|
clean = "operation"
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("operation_%s.pdf", clean)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeFilenameComponent(value string) string {
|
||||||
|
trimmed := strings.TrimSpace(value)
|
||||||
|
if trimmed == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
b.Grow(len(trimmed))
|
||||||
|
|
||||||
|
for _, r := range trimmed {
|
||||||
|
switch {
|
||||||
|
case r >= 'a' && r <= 'z':
|
||||||
|
b.WriteRune(r)
|
||||||
|
case r >= 'A' && r <= 'Z':
|
||||||
|
b.WriteRune(r)
|
||||||
|
case r >= '0' && r <= '9':
|
||||||
|
b.WriteRune(r)
|
||||||
|
case r == '-', r == '_':
|
||||||
|
b.WriteRune(r)
|
||||||
|
default:
|
||||||
|
b.WriteRune('_')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Trim(b.String(), "_")
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
case documentsv1.DocumentType_DOCUMENT_TYPE_INVOICE:
|
||||||
|
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"
|
||||||
|
case documentsv1.DocumentType_DOCUMENT_TYPE_INVOICE:
|
||||||
|
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)
|
||||||
|
}
|
||||||
236
api/billing/documents/internal/service/documents/service_test.go
Normal file
236
api/billing/documents/internal/service/documents/service_test.go
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
package documents
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/shopspring/decimal"
|
||||||
|
"github.com/tech/sendico/billing/documents/renderer"
|
||||||
|
"github.com/tech/sendico/billing/documents/storage"
|
||||||
|
"github.com/tech/sendico/billing/documents/storage/model"
|
||||||
|
documentsv1 "github.com/tech/sendico/pkg/proto/billing/documents/v1"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
type stubRepo struct {
|
||||||
|
store storage.DocumentsStore
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubRepo) Ping(_ context.Context) error { return nil }
|
||||||
|
func (s *stubRepo) Documents() storage.DocumentsStore { return s.store }
|
||||||
|
|
||||||
|
var _ storage.Repository = (*stubRepo)(nil)
|
||||||
|
|
||||||
|
type stubDocumentsStore struct {
|
||||||
|
record *model.DocumentRecord
|
||||||
|
updateCalls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubDocumentsStore) Create(_ context.Context, record *model.DocumentRecord) error {
|
||||||
|
s.record = record
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubDocumentsStore) Update(_ context.Context, record *model.DocumentRecord) error {
|
||||||
|
s.record = record
|
||||||
|
s.updateCalls++
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubDocumentsStore) GetByPaymentRef(_ context.Context, _ string) (*model.DocumentRecord, error) {
|
||||||
|
return s.record, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubDocumentsStore) ListByPaymentRefs(_ context.Context, _ []string) ([]*model.DocumentRecord, error) {
|
||||||
|
return []*model.DocumentRecord{s.record}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ storage.DocumentsStore = (*stubDocumentsStore)(nil)
|
||||||
|
|
||||||
|
type memDocStore struct {
|
||||||
|
data map[string][]byte
|
||||||
|
saveCount int
|
||||||
|
loadCount int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMemDocStore() *memDocStore {
|
||||||
|
return &memDocStore{data: map[string][]byte{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(_ context.Context, key string) ([]byte, error) {
|
||||||
|
m.loadCount++
|
||||||
|
data := m.data[key]
|
||||||
|
copyData := make([]byte, len(data))
|
||||||
|
copy(copyData, data)
|
||||||
|
|
||||||
|
return copyData, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memDocStore) Counts() (int, int) {
|
||||||
|
return m.saveCount, m.loadCount
|
||||||
|
}
|
||||||
|
|
||||||
|
type stubTemplate struct {
|
||||||
|
blocks []renderer.Block
|
||||||
|
calls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubTemplate) Render(_ model.ActSnapshot) ([]renderer.Block, error) {
|
||||||
|
s.calls++
|
||||||
|
|
||||||
|
return s.blocks, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateActPDF_IdempotentAndHashed(t *testing.T) {
|
||||||
|
snapshot := model.ActSnapshot{
|
||||||
|
PaymentID: "PAY-123",
|
||||||
|
Date: time.Date(2026, 1, 30, 0, 0, 0, 0, time.UTC),
|
||||||
|
ExecutorFullName: "Jane Doe",
|
||||||
|
Amount: decimal.RequireFromString("100.00"),
|
||||||
|
Currency: "USD",
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpl := &stubTemplate{
|
||||||
|
blocks: []renderer.Block{
|
||||||
|
{Tag: renderer.TagTitle, Lines: []string{"ACT"}},
|
||||||
|
{Tag: renderer.TagText, Lines: []string{"Executor: Jane Doe", "Amount: 100 USD"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := Config{
|
||||||
|
Issuer: renderer.Issuer{
|
||||||
|
LegalName: "Sendico Ltd",
|
||||||
|
LegalAddress: "12 Market Street, London, UK",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := NewService(zap.NewNop(), nil, nil,
|
||||||
|
WithConfig(cfg),
|
||||||
|
WithTemplateRenderer(tmpl),
|
||||||
|
)
|
||||||
|
|
||||||
|
pdf1, hash1, err := svc.generateActPDF(snapshot)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generateActPDF first call: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(pdf1) == 0 {
|
||||||
|
t.Fatalf("expected content on first call")
|
||||||
|
}
|
||||||
|
|
||||||
|
if hash1 == "" {
|
||||||
|
t.Fatalf("expected non-empty hash on first call")
|
||||||
|
}
|
||||||
|
|
||||||
|
footerHash := extractFooterHash(pdf1)
|
||||||
|
|
||||||
|
if footerHash == "" {
|
||||||
|
t.Fatalf("expected footer hash in PDF")
|
||||||
|
}
|
||||||
|
|
||||||
|
if hash1 != footerHash {
|
||||||
|
t.Fatalf("stored hash mismatch: got %s", hash1)
|
||||||
|
}
|
||||||
|
|
||||||
|
pdf2, hash2, err := svc.generateActPDF(snapshot)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generateActPDF second call: %v", err)
|
||||||
|
}
|
||||||
|
if hash2 == "" {
|
||||||
|
t.Fatalf("expected non-empty hash on second call")
|
||||||
|
}
|
||||||
|
footerHash2 := extractFooterHash(pdf2)
|
||||||
|
if footerHash2 == "" {
|
||||||
|
t.Fatalf("expected footer hash in second PDF")
|
||||||
|
}
|
||||||
|
if footerHash2 != hash2 {
|
||||||
|
t.Fatalf("second hash mismatch: got=%s want=%s", footerHash2, hash2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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])
|
||||||
|
}
|
||||||
|
|
||||||
|
func isHexDigit(b byte) bool {
|
||||||
|
return (b >= '0' && b <= '9') || (b >= 'a' && b <= 'f') || (b >= 'A' && b <= 'F')
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetOperationDocument_GeneratesPDF(t *testing.T) {
|
||||||
|
svc := NewService(zap.NewNop(), nil, nil, WithConfig(Config{
|
||||||
|
Issuer: renderer.Issuer{
|
||||||
|
LegalName: "Sendico Ltd",
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
resp, err := svc.GetOperationDocument(context.Background(), &documentsv1.GetOperationDocumentRequest{
|
||||||
|
OrganizationRef: "org-1",
|
||||||
|
GatewayService: "chain_gateway",
|
||||||
|
OperationRef: "pay-1:step-1",
|
||||||
|
PaymentRef: "pay-1",
|
||||||
|
OperationCode: "crypto.transfer",
|
||||||
|
OperationLabel: "Outbound transfer",
|
||||||
|
OperationState: "completed",
|
||||||
|
Amount: "100.50",
|
||||||
|
Currency: "USDT",
|
||||||
|
StartedAtUnixMs: time.Date(2026, 3, 4, 10, 0, 0, 0, time.UTC).UnixMilli(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetOperationDocument failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(resp.GetContent()) == 0 {
|
||||||
|
t.Fatalf("expected non-empty PDF content")
|
||||||
|
}
|
||||||
|
if got, want := resp.GetMimeType(), "application/pdf"; got != want {
|
||||||
|
t.Fatalf("mime_type mismatch: got=%q want=%q", got, want)
|
||||||
|
}
|
||||||
|
if got, want := resp.GetFilename(), "operation_pay-1_step-1.pdf"; got != want {
|
||||||
|
t.Fatalf("filename mismatch: got=%q want=%q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetOperationDocument_RequiresOperationRef(t *testing.T) {
|
||||||
|
svc := NewService(zap.NewNop(), nil, nil)
|
||||||
|
|
||||||
|
_, err := svc.GetOperationDocument(context.Background(), &documentsv1.GetOperationDocumentRequest{
|
||||||
|
OrganizationRef: "org-1",
|
||||||
|
GatewayService: "chain_gateway",
|
||||||
|
})
|
||||||
|
if status.Code(err) != codes.InvalidArgument {
|
||||||
|
t.Fatalf("expected InvalidArgument, got=%v err=%v", status.Code(err), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
63
api/billing/documents/internal/service/documents/template.go
Normal file
63
api/billing/documents/internal/service/documents/template.go
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
package documents
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"text/template"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/shopspring/decimal"
|
||||||
|
"github.com/tech/sendico/billing/documents/renderer"
|
||||||
|
"github.com/tech/sendico/billing/documents/storage/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type templateRenderer struct {
|
||||||
|
tpl *template.Template
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTemplateRenderer(path string) (*templateRenderer, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read template: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
funcs := template.FuncMap{
|
||||||
|
"money": formatMoney,
|
||||||
|
"date": formatDate,
|
||||||
|
}
|
||||||
|
|
||||||
|
tpl, err := template.New("acceptance").Funcs(funcs).Option("missingkey=error").Parse(string(data))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse template: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &templateRenderer{tpl: tpl}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *templateRenderer) Render(snapshot model.ActSnapshot) ([]renderer.Block, error) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := r.tpl.Execute(&buf, snapshot); err != nil {
|
||||||
|
return nil, fmt.Errorf("execute template: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderer.ParseBlocks(buf.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatMoney(amount decimal.Decimal, currency string) string {
|
||||||
|
currency = strings.TrimSpace(currency)
|
||||||
|
if currency == "" {
|
||||||
|
return amount.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%s %s", amount.String(), currency)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatDate(t time.Time) string {
|
||||||
|
if t.IsZero() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return t.Format("2006-01-02")
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package documents
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/shopspring/decimal"
|
||||||
|
"github.com/tech/sendico/billing/documents/renderer"
|
||||||
|
"github.com/tech/sendico/billing/documents/storage/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTemplateRenderer_Render(t *testing.T) {
|
||||||
|
path := filepath.Join("..", "..", "..", "templates", "acceptance.tpl")
|
||||||
|
|
||||||
|
tmpl, err := newTemplateRenderer(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newTemplateRenderer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot := model.ActSnapshot{
|
||||||
|
PaymentID: "PAY-001",
|
||||||
|
Date: time.Date(2026, 1, 30, 0, 0, 0, 0, time.UTC),
|
||||||
|
ExecutorFullName: "Jane Doe",
|
||||||
|
Amount: decimal.RequireFromString("123.45"),
|
||||||
|
Currency: "USD",
|
||||||
|
}
|
||||||
|
|
||||||
|
blocks, err := tmpl.Render(snapshot)
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !slices.Contains(title.Lines, "ACT OF ACCEPTANCE OF SERVICES") {
|
||||||
|
t.Fatalf("expected title content not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
kv := findBlock(blocks, renderer.TagKV)
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
table := findBlock(blocks, renderer.TagTable)
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func findBlock(blocks []renderer.Block, tag renderer.Tag) *renderer.Block {
|
||||||
|
for i := range blocks {
|
||||||
|
if blocks[i].Tag == tag {
|
||||||
|
return &blocks[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
17
api/billing/documents/main.go
Normal file
17
api/billing/documents/main.go
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/tech/sendico/billing/documents/internal/appversion"
|
||||||
|
si "github.com/tech/sendico/billing/documents/internal/server"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"github.com/tech/sendico/pkg/server"
|
||||||
|
smain "github.com/tech/sendico/pkg/server/main"
|
||||||
|
)
|
||||||
|
|
||||||
|
func factory(logger mlogger.Logger, file string, debug bool) (server.Application, error) {
|
||||||
|
return si.Create(logger, file, debug)
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
smain.RunServer("main", appversion.Create(), factory)
|
||||||
|
}
|
||||||
52
api/billing/documents/renderer/header.go
Normal file
52
api/billing/documents/renderer/header.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
package renderer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/jung-kurt/gofpdf"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Issuer describes the document issuer.
|
||||||
|
type Issuer struct {
|
||||||
|
LegalName string `yaml:"legal_name"`
|
||||||
|
LegalAddress string `yaml:"legal_address"`
|
||||||
|
LogoPath string `yaml:"logo_path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func drawHeader(pdf *gofpdf.Fpdf, issuer Issuer, marginLeft, marginTop float64) (float64, error) {
|
||||||
|
startX := marginLeft
|
||||||
|
startY := marginTop
|
||||||
|
logoWidth := 0.0
|
||||||
|
|
||||||
|
if strings.TrimSpace(issuer.LogoPath) != "" {
|
||||||
|
logoWidth = 24
|
||||||
|
pdf.ImageOptions(issuer.LogoPath, startX, startY, logoWidth, 0, false, gofpdf.ImageOptions{ReadDpi: true}, 0, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
textX := startX
|
||||||
|
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, "")
|
||||||
|
pdf.SetX(textX)
|
||||||
|
pdf.SetFont("Helvetica", "", 10)
|
||||||
|
pdf.MultiCell(0, 4.5, issuer.LegalAddress, "", "L", false)
|
||||||
|
|
||||||
|
if pdf.Error() != nil {
|
||||||
|
return 0, pdf.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
currentY := pdf.GetY()
|
||||||
|
|
||||||
|
if logoWidth > 0 {
|
||||||
|
logoBottom := startY + logoWidth
|
||||||
|
if logoBottom > currentY {
|
||||||
|
currentY = logoBottom
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentY - startY, nil
|
||||||
|
}
|
||||||
259
api/billing/documents/renderer/layout.go
Normal file
259
api/billing/documents/renderer/layout.go
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
package renderer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/jung-kurt/gofpdf"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
pageMarginLeft = 20.0
|
||||||
|
pageMarginRight = 20.0
|
||||||
|
pageMarginTop = 20.0
|
||||||
|
pageMarginBottom = 22.0
|
||||||
|
)
|
||||||
|
|
||||||
|
// Renderer builds a PDF document from tagged blocks.
|
||||||
|
type Renderer struct {
|
||||||
|
Issuer Issuer
|
||||||
|
OwnerPassword string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render generates the PDF bytes for the provided blocks and footer hash.
|
||||||
|
func (r Renderer) Render(blocks []Block, footerHash string) ([]byte, error) {
|
||||||
|
pdf := gofpdf.New("P", "mm", "A4", "")
|
||||||
|
pdf.SetMargins(pageMarginLeft, pageMarginTop, pageMarginRight)
|
||||||
|
pdf.SetAutoPageBreak(true, pageMarginBottom)
|
||||||
|
pdf.SetCompression(false)
|
||||||
|
pdf.SetAuthor(r.Issuer.LegalName, false)
|
||||||
|
pdf.SetTitle("Act of Acceptance", false)
|
||||||
|
|
||||||
|
owner := strings.TrimSpace(r.OwnerPassword)
|
||||||
|
if owner != "" {
|
||||||
|
pdf.SetProtection(gofpdf.CnProtectPrint, "", owner)
|
||||||
|
}
|
||||||
|
|
||||||
|
pdf.SetFooterFunc(func() {
|
||||||
|
pdf.SetY(-15)
|
||||||
|
pdf.SetFont("Helvetica", "", 8)
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
if err := pdf.Output(buf); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderBlock(pdf *gofpdf.Fpdf, block Block) {
|
||||||
|
switch block.Tag {
|
||||||
|
case TagSpacer:
|
||||||
|
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)
|
||||||
|
case TagKV:
|
||||||
|
renderKeyValue(pdf, block)
|
||||||
|
case TagTable:
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderKeyValue(pdf *gofpdf.Fpdf, block Block) {
|
||||||
|
pdf.SetFont("Helvetica", "", 10)
|
||||||
|
|
||||||
|
usable := usableWidth(pdf)
|
||||||
|
keyWidth := math.Round(usable * 0.35)
|
||||||
|
valueWidth := usable - keyWidth
|
||||||
|
lineHeight := 5.0
|
||||||
|
|
||||||
|
for _, row := range block.Rows {
|
||||||
|
if len(row) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
key := row[0]
|
||||||
|
|
||||||
|
value := ""
|
||||||
|
if len(row) > 1 {
|
||||||
|
value = row[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
x := pdf.GetX()
|
||||||
|
y := pdf.GetY()
|
||||||
|
|
||||||
|
pdf.SetXY(x, y)
|
||||||
|
pdf.SetFont("Helvetica", "B", 10)
|
||||||
|
pdf.MultiCell(keyWidth, lineHeight, key, "", "L", false)
|
||||||
|
leftY := pdf.GetY()
|
||||||
|
|
||||||
|
pdf.SetXY(x+keyWidth, y)
|
||||||
|
pdf.SetFont("Helvetica", "", 10)
|
||||||
|
pdf.MultiCell(valueWidth, lineHeight, value, "", "L", false)
|
||||||
|
rightY := pdf.GetY()
|
||||||
|
|
||||||
|
pdf.SetY(maxFloat(leftY, rightY))
|
||||||
|
}
|
||||||
|
|
||||||
|
pdf.Ln(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
lineHeight := 6.0
|
||||||
|
|
||||||
|
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 {
|
||||||
|
pdf.CellFormat(col2, lineHeight, "", "1", 1, "R", false, 0, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
leftY := pdf.GetY()
|
||||||
|
pdf.SetXY(x+col1, y)
|
||||||
|
pdf.MultiCell(col2, lineHeight, colB, "1", "R", false)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
func maxFloat(a, b float64) float64 {
|
||||||
|
if a > b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
return b
|
||||||
|
}
|
||||||
107
api/billing/documents/renderer/renderer_test.go
Normal file
107
api/billing/documents/renderer/renderer_test.go
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
package renderer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/hex"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"unicode/utf16"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRenderer_RenderContainsText(t *testing.T) {
|
||||||
|
blocks := []Block{
|
||||||
|
{Tag: TagTitle, Lines: []string{"ACT"}},
|
||||||
|
{Tag: TagText, Lines: []string{"Executor: Jane Doe", "Amount: 100 USD"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
r := Renderer{
|
||||||
|
Issuer: Issuer{
|
||||||
|
LegalName: "Sendico Ltd",
|
||||||
|
LegalAddress: "12 Market Street, London, UK",
|
||||||
|
},
|
||||||
|
OwnerPassword: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
pdfBytes, err := r.Render(blocks, "deadbeef")
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
utf16BytesBOM := encodeUTF16BE(text, true)
|
||||||
|
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
|
||||||
|
}
|
||||||
99
api/billing/documents/renderer/tags.go
Normal file
99
api/billing/documents/renderer/tags.go
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
package renderer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Tag defines supported template blocks.
|
||||||
|
type Tag string
|
||||||
|
|
||||||
|
const (
|
||||||
|
TagSpacer Tag = "spacer"
|
||||||
|
TagTitle Tag = "title"
|
||||||
|
TagSubtitle Tag = "subtitle"
|
||||||
|
TagMeta Tag = "meta"
|
||||||
|
TagSection Tag = "section"
|
||||||
|
TagText Tag = "text"
|
||||||
|
TagKV Tag = "kv"
|
||||||
|
TagTable Tag = "table"
|
||||||
|
TagSign Tag = "sign"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Block represents a tagged content block extracted from template output.
|
||||||
|
type Block struct {
|
||||||
|
Tag Tag
|
||||||
|
Lines []string
|
||||||
|
Rows [][]string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseBlocks converts tagged template output into structured blocks.
|
||||||
|
func ParseBlocks(input string) ([]Block, error) {
|
||||||
|
scanner := bufio.NewScanner(strings.NewReader(input))
|
||||||
|
blocks := make([]Block, 0)
|
||||||
|
|
||||||
|
var current *Block
|
||||||
|
|
||||||
|
flush := func() {
|
||||||
|
if current != nil {
|
||||||
|
blocks = append(blocks, *current)
|
||||||
|
current = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
if current == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse blocks: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
flush()
|
||||||
|
|
||||||
|
return blocks, nil
|
||||||
|
}
|
||||||
93
api/billing/documents/storage/model/document.go
Normal file
93
api/billing/documents/storage/model/document.go
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/shopspring/decimal"
|
||||||
|
"github.com/tech/sendico/pkg/db/storable"
|
||||||
|
documentsv1 "github.com/tech/sendico/pkg/proto/billing/documents/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
DocumentRecordsCollection = "document_records"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DocumentType mirrors the protobuf enum but stores string names for Mongo compatibility.
|
||||||
|
type DocumentType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
DocumentTypeUnspecified DocumentType = "DOCUMENT_TYPE_UNSPECIFIED"
|
||||||
|
DocumentTypeInvoice DocumentType = "DOCUMENT_TYPE_INVOICE"
|
||||||
|
DocumentTypeAct DocumentType = "DOCUMENT_TYPE_ACT"
|
||||||
|
DocumentTypeReceipt DocumentType = "DOCUMENT_TYPE_RECEIPT"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DocumentTypeFromProto converts a protobuf enum to the storage representation.
|
||||||
|
func DocumentTypeFromProto(t documentsv1.DocumentType) DocumentType {
|
||||||
|
if name, ok := documentsv1.DocumentType_name[int32(t)]; ok {
|
||||||
|
return DocumentType(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return DocumentTypeUnspecified
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proto converts the storage representation to a protobuf enum.
|
||||||
|
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"`
|
||||||
|
ExecutorFullName string `bson:"executorFullName" json:"executorFullName"`
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collection implements storable.Storable.
|
||||||
|
func (*DocumentRecord) Collection() string {
|
||||||
|
return DocumentRecordsCollection
|
||||||
|
}
|
||||||
72
api/billing/documents/storage/mongo/repository.go
Normal file
72
api/billing/documents/storage/mongo/repository.go
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
package mongo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/billing/documents/storage"
|
||||||
|
"github.com/tech/sendico/billing/documents/storage/mongo/store"
|
||||||
|
"github.com/tech/sendico/pkg/db"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Store struct {
|
||||||
|
logger mlogger.Logger
|
||||||
|
conn *db.MongoConnection
|
||||||
|
db *mongo.Database
|
||||||
|
documents storage.DocumentsStore
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a repository backed by MongoDB for the billing documents service.
|
||||||
|
func New(logger mlogger.Logger, conn *db.MongoConnection) (*Store, error) {
|
||||||
|
if conn == nil {
|
||||||
|
return nil, merrors.InvalidArgument("mongo connection is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
client := conn.Client()
|
||||||
|
if client == nil {
|
||||||
|
return nil, merrors.Internal("mongo client not initialised")
|
||||||
|
}
|
||||||
|
|
||||||
|
database := conn.Database()
|
||||||
|
result := &Store{
|
||||||
|
logger: logger.Named("storage").Named("mongo"),
|
||||||
|
conn: conn,
|
||||||
|
db: database,
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := result.Ping(ctx); err != nil {
|
||||||
|
result.logger.Error("Mongo ping failed during store init", zap.Error(err))
|
||||||
|
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
documentsStore, err := store.NewDocuments(result.logger, database)
|
||||||
|
if err != nil {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Ping(ctx context.Context) error {
|
||||||
|
return s.conn.Ping(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Documents() storage.DocumentsStore {
|
||||||
|
return s.documents
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ storage.Repository = (*Store)(nil)
|
||||||
159
api/billing/documents/storage/mongo/store/documents.go
Normal file
159
api/billing/documents/storage/mongo/store/documents.go
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/billing/documents/storage"
|
||||||
|
"github.com/tech/sendico/billing/documents/storage/model"
|
||||||
|
"github.com/tech/sendico/pkg/db/repository"
|
||||||
|
"github.com/tech/sendico/pkg/db/repository/builder"
|
||||||
|
ri "github.com/tech/sendico/pkg/db/repository/index"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Documents struct {
|
||||||
|
logger mlogger.Logger
|
||||||
|
repo repository.Repository
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDocuments constructs a Mongo-backed documents store.
|
||||||
|
func NewDocuments(logger mlogger.Logger, db *mongo.Database) (*Documents, error) {
|
||||||
|
if db == nil {
|
||||||
|
return nil, merrors.InvalidArgument("documentsStore: database is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
repo := repository.CreateMongoRepository(db, model.DocumentRecordsCollection)
|
||||||
|
|
||||||
|
indexes := []*ri.Definition{
|
||||||
|
{
|
||||||
|
Keys: []ri.Key{{Field: "paymentRef", Sort: ri.Asc}},
|
||||||
|
Unique: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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()))
|
||||||
|
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
childLogger := logger.Named("documents")
|
||||||
|
childLogger.Debug("Documents store initialised")
|
||||||
|
|
||||||
|
return &Documents{
|
||||||
|
logger: childLogger,
|
||||||
|
repo: repo,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Documents) Create(ctx context.Context, record *model.DocumentRecord) error {
|
||||||
|
if record == nil {
|
||||||
|
return merrors.InvalidArgument("documentsStore: nil record")
|
||||||
|
}
|
||||||
|
|
||||||
|
record.Normalize()
|
||||||
|
|
||||||
|
if record.PaymentRef == "" {
|
||||||
|
return merrors.InvalidArgument("documentsStore: empty paymentRef")
|
||||||
|
}
|
||||||
|
|
||||||
|
record.Update()
|
||||||
|
if err := d.repo.Insert(ctx, record, repository.Filter("paymentRef", record.PaymentRef)); err != nil {
|
||||||
|
if errors.Is(err, merrors.ErrDataConflict) {
|
||||||
|
return storage.ErrDuplicateDocument
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
d.logger.Debug("Document record created", zap.String("payment_ref", record.PaymentRef))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Documents) Update(ctx context.Context, record *model.DocumentRecord) error {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Documents) GetByPaymentRef(ctx context.Context, paymentRef string) (*model.DocumentRecord, error) {
|
||||||
|
paymentRef = strings.TrimSpace(paymentRef)
|
||||||
|
if paymentRef == "" {
|
||||||
|
return nil, merrors.InvalidArgument("documentsStore: empty paymentRef")
|
||||||
|
}
|
||||||
|
|
||||||
|
entity := &model.DocumentRecord{}
|
||||||
|
if err := d.repo.FindOneByFilter(ctx, repository.Filter("paymentRef", paymentRef), entity); err != nil {
|
||||||
|
if errors.Is(err, merrors.ErrNoData) {
|
||||||
|
return nil, storage.ErrDocumentNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return entity, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Documents) ListByPaymentRefs(ctx context.Context, paymentRefs []string) ([]*model.DocumentRecord, error) {
|
||||||
|
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 {
|
||||||
|
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))
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
records = append(records, &rec)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := d.repo.FindManyByFilter(ctx, query, decoder); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return records, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ storage.DocumentsStore = (*Documents)(nil)
|
||||||
32
api/billing/documents/storage/storage.go
Normal file
32
api/billing/documents/storage/storage.go
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/billing/documents/storage/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type storageError string
|
||||||
|
|
||||||
|
func (e storageError) Error() string {
|
||||||
|
return string(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrDocumentNotFound = storageError("billing.documents.storage: document record not found")
|
||||||
|
ErrDuplicateDocument = storageError("billing.documents.storage: duplicate document record")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Repository defines the root storage contract for the billing documents service.
|
||||||
|
type Repository interface {
|
||||||
|
Ping(ctx context.Context) error
|
||||||
|
Documents() DocumentsStore
|
||||||
|
}
|
||||||
|
|
||||||
|
// DocumentsStore exposes persistence operations for document records.
|
||||||
|
type DocumentsStore interface {
|
||||||
|
Create(ctx context.Context, record *model.DocumentRecord) error
|
||||||
|
Update(ctx context.Context, record *model.DocumentRecord) error
|
||||||
|
GetByPaymentRef(ctx context.Context, paymentRef string) (*model.DocumentRecord, error)
|
||||||
|
ListByPaymentRefs(ctx context.Context, paymentRefs []string) ([]*model.DocumentRecord, error)
|
||||||
|
}
|
||||||
67
api/billing/documents/templates/acceptance.tpl
Normal file
67
api/billing/documents/templates/acceptance.tpl
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
#spacer
|
||||||
|
|
||||||
|
|
||||||
|
#title
|
||||||
|
ACT OF ACCEPTANCE OF SERVICES
|
||||||
|
|
||||||
|
#subtitle
|
||||||
|
under the Public Offer Agreement
|
||||||
|
|
||||||
|
#meta
|
||||||
|
Date: {{ date .Date }}
|
||||||
|
Act No: {{ .PaymentID }}
|
||||||
|
|
||||||
|
|
||||||
|
#section
|
||||||
|
PARTIES
|
||||||
|
|
||||||
|
#text
|
||||||
|
This Act is made between the following Parties.
|
||||||
|
|
||||||
|
#kv
|
||||||
|
Executor | {{ .ExecutorFullName }}
|
||||||
|
Status | Individual
|
||||||
|
|
||||||
|
|
||||||
|
#section
|
||||||
|
BASIS
|
||||||
|
|
||||||
|
#text
|
||||||
|
This Act is issued pursuant to the Public Offer Agreement
|
||||||
|
accepted by the Executor by joining the offer.
|
||||||
|
|
||||||
|
|
||||||
|
#section
|
||||||
|
SERVICES RENDERED
|
||||||
|
|
||||||
|
#text
|
||||||
|
The Executor has rendered services to the Customer
|
||||||
|
in accordance with the terms of the Public Offer Agreement.
|
||||||
|
|
||||||
|
|
||||||
|
#section
|
||||||
|
REMUNERATION
|
||||||
|
|
||||||
|
#table
|
||||||
|
Description | Amount
|
||||||
|
Services rendered under the Public Offer Agreement | {{ money .Amount .Currency }}
|
||||||
|
|
||||||
|
|
||||||
|
#section
|
||||||
|
CONFIRMATION
|
||||||
|
|
||||||
|
#text
|
||||||
|
The Customer confirms that the services were rendered properly
|
||||||
|
and accepted without any claims.
|
||||||
|
|
||||||
|
The remuneration for the services was paid to the Executor
|
||||||
|
using the bank card details provided by the Executor.
|
||||||
|
|
||||||
|
|
||||||
|
#section
|
||||||
|
SIGNATURES
|
||||||
|
|
||||||
|
#sign
|
||||||
|
Customer ___________________________
|
||||||
|
|
||||||
|
Executor ___________________________
|
||||||
@@ -1,32 +1,46 @@
|
|||||||
# Config file for Air in TOML format
|
root = "."
|
||||||
|
testdata_dir = "testdata"
|
||||||
root = "./../.."
|
|
||||||
tmp_dir = "tmp"
|
tmp_dir = "tmp"
|
||||||
|
|
||||||
[build]
|
[build]
|
||||||
cmd = "go build -o app -ldflags \"-X 'github.com/tech/sendico/billing/fees/internal/appversion.BuildUser=$(whoami)' -X 'github.com/tech/sendico/billing/fees/internal/appversion.Version=$APP_V' -X 'github.com/tech/sendico/billing/fees/internal/appversion.Branch=$BUILD_BRANCH' -X 'github.com/tech/sendico/billing/fees/internal/appversion.Revision=$GIT_REV' -X 'github.com/tech/sendico/billing/fees/internal/appversion.BuildDate=$(date)'\""
|
args_bin = []
|
||||||
bin = "./app"
|
entrypoint = "./tmp/main"
|
||||||
full_bin = "./app --debug --config.file=config.yml"
|
cmd = "go build -o ./tmp/main ."
|
||||||
include_ext = ["go", "yaml", "yml"]
|
delay = 1000
|
||||||
exclude_dir = ["billing/fees/tmp", "pkg/.git", "billing/fees/env"]
|
exclude_dir = ["assets", "tmp", "vendor", "testdata"]
|
||||||
exclude_regex = ["_test\\.go"]
|
exclude_file = []
|
||||||
exclude_unchanged = true
|
exclude_regex = ["_test.go", "_templ.go"]
|
||||||
follow_symlink = true
|
exclude_unchanged = false
|
||||||
log = "air.log"
|
follow_symlink = false
|
||||||
delay = 0
|
full_bin = ""
|
||||||
stop_on_error = true
|
include_dir = []
|
||||||
send_interrupt = true
|
include_ext = ["go", "tpl", "tmpl", "html"]
|
||||||
kill_delay = 500
|
include_file = []
|
||||||
args_bin = []
|
kill_delay = "0s"
|
||||||
|
log = "build-errors.log"
|
||||||
[log]
|
poll = false
|
||||||
time = false
|
poll_interval = 0
|
||||||
|
post_cmd = []
|
||||||
|
pre_cmd = []
|
||||||
|
rerun = false
|
||||||
|
rerun_delay = 500
|
||||||
|
send_interrupt = false
|
||||||
|
stop_on_error = false
|
||||||
|
|
||||||
[color]
|
[color]
|
||||||
main = "magenta"
|
app = ""
|
||||||
watcher = "cyan"
|
build = "yellow"
|
||||||
build = "yellow"
|
main = "magenta"
|
||||||
runner = "green"
|
runner = "green"
|
||||||
|
watcher = "cyan"
|
||||||
|
|
||||||
|
[log]
|
||||||
|
main_only = false
|
||||||
|
time = false
|
||||||
|
|
||||||
[misc]
|
[misc]
|
||||||
clean_on_exit = true
|
clean_on_exit = false
|
||||||
|
|
||||||
|
[screen]
|
||||||
|
clear_on_rebuild = false
|
||||||
|
keep_scroll = true
|
||||||
|
|||||||
198
api/billing/fees/.golangci.yml
Normal file
198
api/billing/fees/.golangci.yml
Normal 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: []
|
||||||
42
api/billing/fees/config.dev.yml
Normal file
42
api/billing/fees/config.dev.yml
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
runtime:
|
||||||
|
shutdown_timeout_seconds: 15
|
||||||
|
|
||||||
|
grpc:
|
||||||
|
network: tcp
|
||||||
|
address: ":50060"
|
||||||
|
advertise_host: "dev-billing-fees"
|
||||||
|
enable_reflection: true
|
||||||
|
enable_health: true
|
||||||
|
|
||||||
|
metrics:
|
||||||
|
address: ":9402"
|
||||||
|
|
||||||
|
database:
|
||||||
|
driver: mongodb
|
||||||
|
settings:
|
||||||
|
host_env: FEES_MONGO_HOST
|
||||||
|
port_env: FEES_MONGO_PORT
|
||||||
|
database_env: FEES_MONGO_DATABASE
|
||||||
|
user_env: FEES_MONGO_USER
|
||||||
|
password_env: FEES_MONGO_PASSWORD
|
||||||
|
auth_source_env: FEES_MONGO_AUTH_SOURCE
|
||||||
|
replica_set_env: FEES_MONGO_REPLICA_SET
|
||||||
|
|
||||||
|
messaging:
|
||||||
|
driver: NATS
|
||||||
|
settings:
|
||||||
|
url_env: NATS_URL
|
||||||
|
host_env: NATS_HOST
|
||||||
|
port_env: NATS_PORT
|
||||||
|
username_env: NATS_USER
|
||||||
|
password_env: NATS_PASSWORD
|
||||||
|
broker_name: Billing Fees Service
|
||||||
|
max_reconnects: 10
|
||||||
|
reconnect_wait: 5
|
||||||
|
buffer_size: 1024
|
||||||
|
|
||||||
|
oracle:
|
||||||
|
address: "dev-fx-oracle:50051"
|
||||||
|
dial_timeout_seconds: 5
|
||||||
|
call_timeout_seconds: 3
|
||||||
|
insecure: true
|
||||||
@@ -4,6 +4,7 @@ runtime:
|
|||||||
grpc:
|
grpc:
|
||||||
network: tcp
|
network: tcp
|
||||||
address: ":50060"
|
address: ":50060"
|
||||||
|
advertise_host: "sendico_billing_fees"
|
||||||
enable_reflection: true
|
enable_reflection: true
|
||||||
enable_health: true
|
enable_health: true
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ messaging:
|
|||||||
broker_name: Billing Fees Service
|
broker_name: Billing Fees Service
|
||||||
max_reconnects: 10
|
max_reconnects: 10
|
||||||
reconnect_wait: 5
|
reconnect_wait: 5
|
||||||
|
buffer_size: 1024
|
||||||
|
|
||||||
oracle:
|
oracle:
|
||||||
address: "sendico_fx_oracle:50051"
|
address: "sendico_fx_oracle:50051"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
module github.com/tech/sendico/billing/fees
|
module github.com/tech/sendico/billing/fees
|
||||||
|
|
||||||
go 1.25.3
|
go 1.25.7
|
||||||
|
|
||||||
replace github.com/tech/sendico/pkg => ../../pkg
|
replace github.com/tech/sendico/pkg => ../../pkg
|
||||||
|
|
||||||
@@ -9,46 +9,47 @@ replace github.com/tech/sendico/fx/oracle => ../../fx/oracle
|
|||||||
require (
|
require (
|
||||||
github.com/tech/sendico/fx/oracle v0.0.0
|
github.com/tech/sendico/fx/oracle v0.0.0
|
||||||
github.com/tech/sendico/pkg v0.1.0
|
github.com/tech/sendico/pkg v0.1.0
|
||||||
go.mongodb.org/mongo-driver v1.17.6
|
|
||||||
go.uber.org/zap v1.27.1
|
go.uber.org/zap v1.27.1
|
||||||
google.golang.org/grpc v1.77.0
|
google.golang.org/grpc v1.79.1
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/casbin/mongodb-adapter/v4 v4.3.0 // indirect
|
||||||
|
go.mongodb.org/mongo-driver/v2 v2.5.0
|
||||||
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
|
github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect
|
||||||
github.com/casbin/casbin/v2 v2.134.0 // indirect
|
github.com/casbin/casbin/v2 v2.135.0 // indirect
|
||||||
github.com/casbin/govaluate v1.10.0 // indirect
|
github.com/casbin/govaluate v1.10.0 // indirect
|
||||||
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
|
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/go-chi/chi/v5 v5.2.3 // indirect
|
github.com/go-chi/chi/v5 v5.2.5 // indirect
|
||||||
github.com/golang/snappy v1.0.0 // indirect
|
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/klauspost/compress v1.18.2 // indirect
|
github.com/klauspost/compress v1.18.4 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||||
github.com/montanaflynn/stats v0.7.1 // indirect
|
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/nats-io/nats.go v1.47.0 // indirect
|
github.com/nats-io/nats.go v1.49.0 // indirect
|
||||||
github.com/nats-io/nkeys v0.4.12 // indirect
|
github.com/nats-io/nkeys v0.4.15 // indirect
|
||||||
github.com/nats-io/nuid v1.0.1 // indirect
|
github.com/nats-io/nuid v1.0.1 // indirect
|
||||||
github.com/prometheus/client_golang v1.23.2
|
github.com/prometheus/client_golang v1.23.2
|
||||||
github.com/prometheus/client_model v0.6.2 // indirect
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
github.com/prometheus/common v0.67.4 // indirect
|
github.com/prometheus/common v0.67.5 // indirect
|
||||||
github.com/prometheus/procfs v0.19.2 // indirect
|
github.com/prometheus/procfs v0.20.1 // indirect
|
||||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||||
github.com/xdg-go/scram v1.2.0 // indirect
|
github.com/xdg-go/scram v1.2.0 // indirect
|
||||||
github.com/xdg-go/stringprep v1.0.4 // indirect
|
github.com/xdg-go/stringprep v1.0.4 // indirect
|
||||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||||
golang.org/x/crypto v0.45.0 // indirect
|
golang.org/x/crypto v0.48.0 // indirect
|
||||||
golang.org/x/net v0.47.0 // indirect
|
golang.org/x/net v0.51.0 // indirect
|
||||||
golang.org/x/sync v0.18.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.38.0 // indirect
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
golang.org/x/text v0.31.0 // indirect
|
golang.org/x/text v0.34.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
|
||||||
google.golang.org/protobuf v1.36.10
|
google.golang.org/protobuf v1.36.11
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,15 +7,15 @@ github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA
|
|||||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
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/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
|
github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||||
github.com/casbin/casbin/v2 v2.134.0 h1:wyO3hZb487GzlGVAI2hUoHQT0ehFD+9B5P+HVG9BVTM=
|
github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk=
|
||||||
github.com/casbin/casbin/v2 v2.134.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
|
github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
|
||||||
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||||
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
|
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
|
||||||
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||||
github.com/casbin/mongodb-adapter/v3 v3.7.0 h1:w9c3bea1BGK4eZTAmk17JkY52yv/xSZDSHKji8q+z6E=
|
github.com/casbin/mongodb-adapter/v4 v4.3.0 h1:yYXky9v1by6vj/0QK7OyHyd/xpz4vzh0lCi7JKrS4qQ=
|
||||||
github.com/casbin/mongodb-adapter/v3 v3.7.0/go.mod h1:F1mu4ojoJVE/8VhIMxMedhjfwRDdIXgANYs6Sd0MgVA=
|
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 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
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 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
@@ -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/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 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
|
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||||
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
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 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
@@ -53,14 +53,12 @@ github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
|
|||||||
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
|
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
|
||||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
|
|
||||||
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
|
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
|
||||||
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
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 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
@@ -89,16 +87,14 @@ github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g
|
|||||||
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
|
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
|
||||||
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
||||||
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
|
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
|
||||||
github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE=
|
|
||||||
github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
|
|
||||||
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
github.com/nats-io/nats.go v1.47.0 h1:YQdADw6J/UfGUd2Oy6tn4Hq6YHxCaJrVKayxxFqYrgM=
|
github.com/nats-io/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE=
|
||||||
github.com/nats-io/nats.go v1.47.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
github.com/nats-io/nats.go v1.49.0/go.mod h1:fDCn3mN5cY8HooHwE2ukiLb4p4G4ImmzvXyJt+tGwdw=
|
||||||
github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
|
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
|
||||||
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
|
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
|
||||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||||
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||||
@@ -115,10 +111,10 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h
|
|||||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||||
github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc=
|
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
|
||||||
github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI=
|
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.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
|
||||||
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
|
||||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
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/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||||
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
|
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
|
||||||
@@ -150,22 +146,22 @@ github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfS
|
|||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||||
go.mongodb.org/mongo-driver v1.17.6 h1:87JUG1wZfWsr6rIz3ZmpH90rL5tea7O3IHuSwHUpsss=
|
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||||
go.mongodb.org/mongo-driver v1.17.6/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
|
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
go.opentelemetry.io/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 h1:UP6IpuHFkUgOQL9FFQFrZ+5LiwhhYRbi7VZSIx6Nj5s=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM=
|
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.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
|
||||||
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
|
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
|
||||||
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
|
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
|
||||||
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
|
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
|
||||||
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
|
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
|
||||||
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
|
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
|
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
|
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
|
||||||
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
|
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
|
||||||
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
|
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 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||||
@@ -176,35 +172,35 @@ 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=
|
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-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.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
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/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-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-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-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.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
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-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.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-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-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.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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
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-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
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.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.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.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
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-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-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
@@ -212,12 +208,12 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
|||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
||||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
|||||||
@@ -24,5 +24,6 @@ func Create() version.Printer {
|
|||||||
BuildDate: BuildDate,
|
BuildDate: BuildDate,
|
||||||
Version: Version,
|
Version: Version,
|
||||||
}
|
}
|
||||||
|
|
||||||
return vf.Create(&info)
|
return vf.Create(&info)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,11 +26,13 @@ type Imp struct {
|
|||||||
config *config
|
config *config
|
||||||
app *grpcapp.App[storage.Repository]
|
app *grpcapp.App[storage.Repository]
|
||||||
oracleClient oracleclient.Client
|
oracleClient oracleclient.Client
|
||||||
|
service *fees.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
type config struct {
|
type config struct {
|
||||||
*grpcapp.Config `yaml:",inline"`
|
*grpcapp.Config `yaml:",inline"`
|
||||||
Oracle OracleConfig `yaml:"oracle"`
|
|
||||||
|
Oracle OracleConfig `yaml:"oracle"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type OracleConfig struct {
|
type OracleConfig struct {
|
||||||
@@ -44,6 +46,7 @@ func (c OracleConfig) dialTimeout() time.Duration {
|
|||||||
if c.DialTimeoutSecs <= 0 {
|
if c.DialTimeoutSecs <= 0 {
|
||||||
return 5 * time.Second
|
return 5 * time.Second
|
||||||
}
|
}
|
||||||
|
|
||||||
return time.Duration(c.DialTimeoutSecs) * time.Second
|
return time.Duration(c.DialTimeoutSecs) * time.Second
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,6 +54,7 @@ func (c OracleConfig) callTimeout() time.Duration {
|
|||||||
if c.CallTimeoutSecs <= 0 {
|
if c.CallTimeoutSecs <= 0 {
|
||||||
return 3 * time.Second
|
return 3 * time.Second
|
||||||
}
|
}
|
||||||
|
|
||||||
return time.Duration(c.CallTimeoutSecs) * time.Second
|
return time.Duration(c.CallTimeoutSecs) * time.Second
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,9 +69,14 @@ func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) {
|
|||||||
|
|
||||||
func (i *Imp) Shutdown() {
|
func (i *Imp) Shutdown() {
|
||||||
if i.app == nil {
|
if i.app == nil {
|
||||||
|
if i.service != nil {
|
||||||
|
i.service.Shutdown()
|
||||||
|
}
|
||||||
|
|
||||||
if i.oracleClient != nil {
|
if i.oracleClient != nil {
|
||||||
_ = i.oracleClient.Close()
|
_ = i.oracleClient.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,6 +85,10 @@ func (i *Imp) Shutdown() {
|
|||||||
timeout = i.config.Runtime.ShutdownTimeout()
|
timeout = i.config.Runtime.ShutdownTimeout()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if i.service != nil {
|
||||||
|
i.service.Shutdown()
|
||||||
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
i.app.Shutdown(ctx)
|
i.app.Shutdown(ctx)
|
||||||
cancel()
|
cancel()
|
||||||
@@ -90,6 +103,7 @@ func (i *Imp) Start() error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
i.config = cfg
|
i.config = cfg
|
||||||
|
|
||||||
repoFactory := func(logger mlogger.Logger, conn *db.MongoConnection) (storage.Repository, error) {
|
repoFactory := func(logger mlogger.Logger, conn *db.MongoConnection) (storage.Repository, error) {
|
||||||
@@ -97,22 +111,23 @@ func (i *Imp) Start() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var oracleClient oracleclient.Client
|
var oracleClient oracleclient.Client
|
||||||
|
|
||||||
if addr := strings.TrimSpace(cfg.Oracle.Address); addr != "" {
|
if addr := strings.TrimSpace(cfg.Oracle.Address); addr != "" {
|
||||||
dialCtx, cancel := context.WithTimeout(context.Background(), cfg.Oracle.dialTimeout())
|
dialCtx, cancel := context.WithTimeout(context.Background(), cfg.Oracle.dialTimeout())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
oc, err := oracleclient.New(dialCtx, oracleclient.Config{
|
oracleConn, err := oracleclient.New(dialCtx, oracleclient.Config{
|
||||||
Address: addr,
|
Address: addr,
|
||||||
DialTimeout: cfg.Oracle.dialTimeout(),
|
DialTimeout: cfg.Oracle.dialTimeout(),
|
||||||
CallTimeout: cfg.Oracle.callTimeout(),
|
CallTimeout: cfg.Oracle.callTimeout(),
|
||||||
Insecure: cfg.Oracle.InsecureTransport,
|
Insecure: cfg.Oracle.InsecureTransport,
|
||||||
})
|
})
|
||||||
if err != nil {
|
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 {
|
} else {
|
||||||
oracleClient = oc
|
oracleClient = oracleConn
|
||||||
i.oracleClient = oc
|
i.oracleClient = oracleConn
|
||||||
i.logger.Info("connected to oracle service", zap.String("address", addr))
|
i.logger.Info("Connected to oracle service", zap.String("address", addr))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,13 +136,24 @@ func (i *Imp) Start() error {
|
|||||||
if oracleClient != nil {
|
if oracleClient != nil {
|
||||||
opts = append(opts, fees.WithOracleClient(oracleClient))
|
opts = append(opts, fees.WithOracleClient(oracleClient))
|
||||||
}
|
}
|
||||||
return fees.NewService(logger, repo, producer, opts...), nil
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
app, err := grpcapp.NewApp(i.logger, "billing_fees", cfg.Config, i.debug, repoFactory, serviceFactory)
|
app, err := grpcapp.NewApp(i.logger, "billing_fees", cfg.Config, i.debug, repoFactory, serviceFactory)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
i.app = app
|
i.app = app
|
||||||
|
|
||||||
return i.app.Start()
|
return i.app.Start()
|
||||||
@@ -137,12 +163,14 @@ func (i *Imp) loadConfig() (*config, error) {
|
|||||||
data, err := os.ReadFile(i.file)
|
data, err := os.ReadFile(i.file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
i.logger.Error("Could not read configuration file", zap.String("config_file", i.file), zap.Error(err))
|
i.logger.Error("Could not read configuration file", zap.String("config_file", i.file), zap.Error(err))
|
||||||
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg := &config{Config: &grpcapp.Config{}}
|
cfg := &config{Config: &grpcapp.Config{}}
|
||||||
if err := yaml.Unmarshal(data, cfg); err != nil {
|
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||||
i.logger.Error("Failed to parse configuration", zap.Error(err))
|
i.logger.Error("Failed to parse configuration", zap.Error(err))
|
||||||
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,448 +2,16 @@ package fees
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"math/big"
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/billing/fees/internal/service/fees/types"
|
||||||
"github.com/tech/sendico/billing/fees/storage/model"
|
"github.com/tech/sendico/billing/fees/storage/model"
|
||||||
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
|
||||||
dmath "github.com/tech/sendico/pkg/decimal"
|
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
|
||||||
"github.com/tech/sendico/pkg/mlogger"
|
|
||||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||||
accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1"
|
|
||||||
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
|
||||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
|
||||||
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
|
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Calculator isolates fee rule evaluation logic so it can be reused and tested.
|
// Calculator isolates fee rule evaluation logic so it can be reused and tested.
|
||||||
|
// Implementation lives under internal/service/fees/internal/calculator.
|
||||||
type Calculator interface {
|
type Calculator interface {
|
||||||
Compute(ctx context.Context, plan *model.FeePlan, intent *feesv1.Intent, bookedAt time.Time, trace *tracev1.TraceContext) (*CalculationResult, error)
|
Compute(ctx context.Context, plan *model.FeePlan, intent *feesv1.Intent, bookedAt time.Time, trace *tracev1.TraceContext) (*types.CalculationResult, error)
|
||||||
}
|
|
||||||
|
|
||||||
// CalculationResult contains derived fee lines and audit metadata.
|
|
||||||
type CalculationResult struct {
|
|
||||||
Lines []*feesv1.DerivedPostingLine
|
|
||||||
Applied []*feesv1.AppliedRule
|
|
||||||
FxUsed *feesv1.FXUsed
|
|
||||||
}
|
|
||||||
|
|
||||||
// quoteCalculator is the default Calculator implementation.
|
|
||||||
type fxOracle interface {
|
|
||||||
LatestRate(ctx context.Context, req oracleclient.LatestRateParams) (*oracleclient.RateSnapshot, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
type quoteCalculator struct {
|
|
||||||
logger mlogger.Logger
|
|
||||||
oracle fxOracle
|
|
||||||
}
|
|
||||||
|
|
||||||
func newQuoteCalculator(logger mlogger.Logger, oracle fxOracle) Calculator {
|
|
||||||
return "eCalculator{
|
|
||||||
logger: logger.Named("calculator"),
|
|
||||||
oracle: oracle,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *quoteCalculator) Compute(ctx context.Context, plan *model.FeePlan, intent *feesv1.Intent, bookedAt time.Time, _ *tracev1.TraceContext) (*CalculationResult, error) {
|
|
||||||
if plan == nil {
|
|
||||||
return nil, merrors.InvalidArgument("plan is required")
|
|
||||||
}
|
|
||||||
if intent == nil {
|
|
||||||
return nil, merrors.InvalidArgument("intent is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
trigger := convertTrigger(intent.GetTrigger())
|
|
||||||
if trigger == model.TriggerUnspecified {
|
|
||||||
return nil, merrors.InvalidArgument("unsupported trigger")
|
|
||||||
}
|
|
||||||
|
|
||||||
baseAmount, err := dmath.RatFromString(intent.GetBaseAmount().GetAmount())
|
|
||||||
if err != nil {
|
|
||||||
return nil, merrors.InvalidArgument("invalid base amount")
|
|
||||||
}
|
|
||||||
if baseAmount.Sign() < 0 {
|
|
||||||
return nil, merrors.InvalidArgument("base amount cannot be negative")
|
|
||||||
}
|
|
||||||
|
|
||||||
baseScale := inferScale(intent.GetBaseAmount().GetAmount())
|
|
||||||
|
|
||||||
rules := make([]model.FeeRule, len(plan.Rules))
|
|
||||||
copy(rules, plan.Rules)
|
|
||||||
sort.SliceStable(rules, func(i, j int) bool {
|
|
||||||
if rules[i].Priority == rules[j].Priority {
|
|
||||||
return rules[i].RuleID < rules[j].RuleID
|
|
||||||
}
|
|
||||||
return rules[i].Priority < rules[j].Priority
|
|
||||||
})
|
|
||||||
|
|
||||||
lines := make([]*feesv1.DerivedPostingLine, 0, len(rules))
|
|
||||||
applied := make([]*feesv1.AppliedRule, 0, len(rules))
|
|
||||||
|
|
||||||
planID := ""
|
|
||||||
if planRef := plan.GetID(); planRef != nil && !planRef.IsZero() {
|
|
||||||
planID = planRef.Hex()
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, rule := range rules {
|
|
||||||
if !shouldApplyRule(rule, trigger, intent.GetAttributes(), bookedAt) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
ledgerAccountRef := strings.TrimSpace(rule.LedgerAccountRef)
|
|
||||||
if ledgerAccountRef == "" {
|
|
||||||
c.logger.Warn("fee rule missing ledger account reference", zap.String("rule_id", rule.RuleID))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
amount, scale, calcErr := c.calculateRuleAmount(baseAmount, baseScale, rule)
|
|
||||||
if calcErr != nil {
|
|
||||||
if !errors.Is(calcErr, merrors.ErrInvalidArg) {
|
|
||||||
c.logger.Warn("failed to calculate fee rule amount", zap.String("rule_id", rule.RuleID), zap.Error(calcErr))
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if amount.Sign() == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
currency := intent.GetBaseAmount().GetCurrency()
|
|
||||||
if override := strings.TrimSpace(rule.Currency); override != "" {
|
|
||||||
currency = override
|
|
||||||
}
|
|
||||||
|
|
||||||
entrySide := mapEntrySide(rule.EntrySide)
|
|
||||||
if entrySide == accountingv1.EntrySide_ENTRY_SIDE_UNSPECIFIED {
|
|
||||||
entrySide = accountingv1.EntrySide_ENTRY_SIDE_CREDIT
|
|
||||||
}
|
|
||||||
|
|
||||||
meta := map[string]string{
|
|
||||||
"fee_rule_id": rule.RuleID,
|
|
||||||
}
|
|
||||||
if planID != "" {
|
|
||||||
meta["fee_plan_id"] = planID
|
|
||||||
}
|
|
||||||
if rule.Metadata != nil {
|
|
||||||
if taxCode := strings.TrimSpace(rule.Metadata["tax_code"]); taxCode != "" {
|
|
||||||
meta["tax_code"] = taxCode
|
|
||||||
}
|
|
||||||
if taxRate := strings.TrimSpace(rule.Metadata["tax_rate"]); taxRate != "" {
|
|
||||||
meta["tax_rate"] = taxRate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
lines = append(lines, &feesv1.DerivedPostingLine{
|
|
||||||
LedgerAccountRef: ledgerAccountRef,
|
|
||||||
Money: &moneyv1.Money{
|
|
||||||
Amount: dmath.FormatRat(amount, scale),
|
|
||||||
Currency: currency,
|
|
||||||
},
|
|
||||||
LineType: mapLineType(rule.LineType),
|
|
||||||
Side: entrySide,
|
|
||||||
Meta: meta,
|
|
||||||
})
|
|
||||||
|
|
||||||
applied = append(applied, &feesv1.AppliedRule{
|
|
||||||
RuleId: rule.RuleID,
|
|
||||||
RuleVersion: planID,
|
|
||||||
Formula: rule.Formula,
|
|
||||||
Rounding: mapRoundingMode(rule.Rounding),
|
|
||||||
TaxCode: metadataValue(rule.Metadata, "tax_code"),
|
|
||||||
TaxRate: metadataValue(rule.Metadata, "tax_rate"),
|
|
||||||
Parameters: cloneStringMap(rule.Metadata),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
var fxUsed *feesv1.FXUsed
|
|
||||||
if trigger == model.TriggerFXConversion && c.oracle != nil {
|
|
||||||
fxUsed = c.buildFxUsed(ctx, intent)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &CalculationResult{
|
|
||||||
Lines: lines,
|
|
||||||
Applied: applied,
|
|
||||||
FxUsed: fxUsed,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *quoteCalculator) calculateRuleAmount(baseAmount *big.Rat, baseScale uint32, rule model.FeeRule) (*big.Rat, uint32, error) {
|
|
||||||
scale, err := resolveRuleScale(rule, baseScale)
|
|
||||||
if err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
result := new(big.Rat)
|
|
||||||
|
|
||||||
if percentage := strings.TrimSpace(rule.Percentage); percentage != "" {
|
|
||||||
percentageRat, perr := dmath.RatFromString(percentage)
|
|
||||||
if perr != nil {
|
|
||||||
return nil, 0, merrors.InvalidArgument("invalid percentage")
|
|
||||||
}
|
|
||||||
result = dmath.AddRat(result, dmath.MulRat(baseAmount, percentageRat))
|
|
||||||
}
|
|
||||||
|
|
||||||
if fixed := strings.TrimSpace(rule.FixedAmount); fixed != "" {
|
|
||||||
fixedRat, ferr := dmath.RatFromString(fixed)
|
|
||||||
if ferr != nil {
|
|
||||||
return nil, 0, merrors.InvalidArgument("invalid fixed amount")
|
|
||||||
}
|
|
||||||
result = dmath.AddRat(result, fixedRat)
|
|
||||||
}
|
|
||||||
|
|
||||||
if minStr := strings.TrimSpace(rule.MinimumAmount); minStr != "" {
|
|
||||||
minRat, merr := dmath.RatFromString(minStr)
|
|
||||||
if merr != nil {
|
|
||||||
return nil, 0, merrors.InvalidArgument("invalid minimum amount")
|
|
||||||
}
|
|
||||||
if dmath.CmpRat(result, minRat) < 0 {
|
|
||||||
result = new(big.Rat).Set(minRat)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if maxStr := strings.TrimSpace(rule.MaximumAmount); maxStr != "" {
|
|
||||||
maxRat, merr := dmath.RatFromString(maxStr)
|
|
||||||
if merr != nil {
|
|
||||||
return nil, 0, merrors.InvalidArgument("invalid maximum amount")
|
|
||||||
}
|
|
||||||
if dmath.CmpRat(result, maxRat) > 0 {
|
|
||||||
result = new(big.Rat).Set(maxRat)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if result.Sign() < 0 {
|
|
||||||
result = new(big.Rat).Abs(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
rounded, rerr := dmath.RoundRatToScale(result, scale, toDecimalRounding(rule.Rounding))
|
|
||||||
if rerr != nil {
|
|
||||||
return nil, 0, rerr
|
|
||||||
}
|
|
||||||
|
|
||||||
return rounded, scale, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
const (
|
|
||||||
attrFxBaseCurrency = "fx_base_currency"
|
|
||||||
attrFxQuoteCurrency = "fx_quote_currency"
|
|
||||||
attrFxProvider = "fx_provider"
|
|
||||||
attrFxSide = "fx_side"
|
|
||||||
attrFxRateOverride = "fx_rate"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (c *quoteCalculator) buildFxUsed(ctx context.Context, intent *feesv1.Intent) *feesv1.FXUsed {
|
|
||||||
if intent == nil || c.oracle == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
attrs := intent.GetAttributes()
|
|
||||||
base := strings.TrimSpace(attrs[attrFxBaseCurrency])
|
|
||||||
quote := strings.TrimSpace(attrs[attrFxQuoteCurrency])
|
|
||||||
if base == "" || quote == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
pair := &fxv1.CurrencyPair{Base: base, Quote: quote}
|
|
||||||
provider := strings.TrimSpace(attrs[attrFxProvider])
|
|
||||||
|
|
||||||
snapshot, err := c.oracle.LatestRate(ctx, oracleclient.LatestRateParams{
|
|
||||||
Meta: oracleclient.RequestMeta{},
|
|
||||||
Pair: pair,
|
|
||||||
Provider: provider,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
c.logger.Warn("fees: failed to fetch FX context", zap.Error(err))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if snapshot == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
rateValue := strings.TrimSpace(attrs[attrFxRateOverride])
|
|
||||||
if rateValue == "" {
|
|
||||||
rateValue = snapshot.Mid
|
|
||||||
}
|
|
||||||
if rateValue == "" {
|
|
||||||
rateValue = snapshot.Ask
|
|
||||||
}
|
|
||||||
if rateValue == "" {
|
|
||||||
rateValue = snapshot.Bid
|
|
||||||
}
|
|
||||||
|
|
||||||
return &feesv1.FXUsed{
|
|
||||||
Pair: pair,
|
|
||||||
Side: parseFxSide(strings.TrimSpace(attrs[attrFxSide])),
|
|
||||||
Rate: &moneyv1.Decimal{Value: rateValue},
|
|
||||||
AsofUnixMs: snapshot.AsOf.UnixMilli(),
|
|
||||||
Provider: snapshot.Provider,
|
|
||||||
RateRef: snapshot.RateRef,
|
|
||||||
SpreadBps: &moneyv1.Decimal{Value: snapshot.SpreadBps},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseFxSide(value string) fxv1.Side {
|
|
||||||
switch strings.ToLower(value) {
|
|
||||||
case "buy_base", "buy_base_sell_quote", "buy":
|
|
||||||
return fxv1.Side_BUY_BASE_SELL_QUOTE
|
|
||||||
case "sell_base", "sell_base_buy_quote", "sell":
|
|
||||||
return fxv1.Side_SELL_BASE_BUY_QUOTE
|
|
||||||
default:
|
|
||||||
return fxv1.Side_SIDE_UNSPECIFIED
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func inferScale(amount string) uint32 {
|
|
||||||
value := strings.TrimSpace(amount)
|
|
||||||
if value == "" {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
if idx := strings.IndexAny(value, "eE"); idx >= 0 {
|
|
||||||
value = value[:idx]
|
|
||||||
}
|
|
||||||
if strings.HasPrefix(value, "+") || strings.HasPrefix(value, "-") {
|
|
||||||
value = value[1:]
|
|
||||||
}
|
|
||||||
if dot := strings.IndexByte(value, '.'); dot >= 0 {
|
|
||||||
return uint32(len(value[dot+1:]))
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func shouldApplyRule(rule model.FeeRule, trigger model.Trigger, attributes map[string]string, bookedAt time.Time) bool {
|
|
||||||
if rule.Trigger != trigger {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if rule.EffectiveFrom.After(bookedAt) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if rule.EffectiveTo != nil && rule.EffectiveTo.Before(bookedAt) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return ruleMatchesAttributes(rule, attributes)
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveRuleScale(rule model.FeeRule, fallback uint32) (uint32, error) {
|
|
||||||
if rule.Metadata != nil {
|
|
||||||
for _, field := range []string{"scale", "decimals", "precision"} {
|
|
||||||
if value, ok := rule.Metadata[field]; ok && strings.TrimSpace(value) != "" {
|
|
||||||
return parseScale(field, value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fallback, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseScale(field, value string) (uint32, error) {
|
|
||||||
clean := strings.TrimSpace(value)
|
|
||||||
if clean == "" {
|
|
||||||
return 0, merrors.InvalidArgument(field + " is empty")
|
|
||||||
}
|
|
||||||
parsed, err := strconv.ParseUint(clean, 10, 32)
|
|
||||||
if err != nil {
|
|
||||||
return 0, merrors.InvalidArgument("invalid " + field + " value")
|
|
||||||
}
|
|
||||||
return uint32(parsed), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func metadataValue(meta map[string]string, key string) string {
|
|
||||||
if meta == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(meta[key])
|
|
||||||
}
|
|
||||||
|
|
||||||
func cloneStringMap(src map[string]string) map[string]string {
|
|
||||||
if len(src) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
cloned := make(map[string]string, len(src))
|
|
||||||
for k, v := range src {
|
|
||||||
cloned[k] = v
|
|
||||||
}
|
|
||||||
return cloned
|
|
||||||
}
|
|
||||||
|
|
||||||
func ruleMatchesAttributes(rule model.FeeRule, attributes map[string]string) bool {
|
|
||||||
if len(rule.AppliesTo) == 0 {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
for key, value := range rule.AppliesTo {
|
|
||||||
if attributes == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if attrValue, ok := attributes[key]; !ok || attrValue != value {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func convertTrigger(trigger feesv1.Trigger) model.Trigger {
|
|
||||||
switch trigger {
|
|
||||||
case feesv1.Trigger_TRIGGER_CAPTURE:
|
|
||||||
return model.TriggerCapture
|
|
||||||
case feesv1.Trigger_TRIGGER_REFUND:
|
|
||||||
return model.TriggerRefund
|
|
||||||
case feesv1.Trigger_TRIGGER_DISPUTE:
|
|
||||||
return model.TriggerDispute
|
|
||||||
case feesv1.Trigger_TRIGGER_PAYOUT:
|
|
||||||
return model.TriggerPayout
|
|
||||||
case feesv1.Trigger_TRIGGER_FX_CONVERSION:
|
|
||||||
return model.TriggerFXConversion
|
|
||||||
default:
|
|
||||||
return model.TriggerUnspecified
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func mapLineType(lineType string) accountingv1.PostingLineType {
|
|
||||||
switch strings.ToLower(lineType) {
|
|
||||||
case "tax":
|
|
||||||
return accountingv1.PostingLineType_POSTING_LINE_TAX
|
|
||||||
case "spread":
|
|
||||||
return accountingv1.PostingLineType_POSTING_LINE_SPREAD
|
|
||||||
case "reversal":
|
|
||||||
return accountingv1.PostingLineType_POSTING_LINE_REVERSAL
|
|
||||||
default:
|
|
||||||
return accountingv1.PostingLineType_POSTING_LINE_FEE
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func mapEntrySide(entrySide string) accountingv1.EntrySide {
|
|
||||||
switch strings.ToLower(entrySide) {
|
|
||||||
case "debit":
|
|
||||||
return accountingv1.EntrySide_ENTRY_SIDE_DEBIT
|
|
||||||
case "credit":
|
|
||||||
return accountingv1.EntrySide_ENTRY_SIDE_CREDIT
|
|
||||||
default:
|
|
||||||
return accountingv1.EntrySide_ENTRY_SIDE_UNSPECIFIED
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func toDecimalRounding(mode string) dmath.RoundingMode {
|
|
||||||
switch strings.ToLower(strings.TrimSpace(mode)) {
|
|
||||||
case "half_up":
|
|
||||||
return dmath.RoundingModeHalfUp
|
|
||||||
case "down":
|
|
||||||
return dmath.RoundingModeDown
|
|
||||||
case "half_even", "bankers":
|
|
||||||
return dmath.RoundingModeHalfEven
|
|
||||||
default:
|
|
||||||
return dmath.RoundingModeHalfEven
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func mapRoundingMode(mode string) moneyv1.RoundingMode {
|
|
||||||
switch strings.ToLower(mode) {
|
|
||||||
case "half_up":
|
|
||||||
return moneyv1.RoundingMode_ROUND_HALF_UP
|
|
||||||
case "down":
|
|
||||||
return moneyv1.RoundingMode_ROUND_DOWN
|
|
||||||
default:
|
|
||||||
return moneyv1.RoundingMode_ROUND_HALF_EVEN
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,568 @@
|
|||||||
|
package calculator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"maps"
|
||||||
|
"math/big"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/billing/fees/internal/service/fees/types"
|
||||||
|
"github.com/tech/sendico/billing/fees/storage/model"
|
||||||
|
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
||||||
|
dmath "github.com/tech/sendico/pkg/decimal"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||||
|
accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1"
|
||||||
|
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
||||||
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
|
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fxOracle captures the oracle dependency for FX conversions.
|
||||||
|
type fxOracle interface {
|
||||||
|
LatestRate(ctx context.Context, req oracleclient.LatestRateParams) (*oracleclient.RateSnapshot, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// New constructs the default calculator implementation.
|
||||||
|
func New(logger mlogger.Logger, oracle fxOracle) *quoteCalculator {
|
||||||
|
if logger == nil {
|
||||||
|
logger = zap.NewNop()
|
||||||
|
}
|
||||||
|
|
||||||
|
return "eCalculator{
|
||||||
|
logger: logger.Named("calculator"),
|
||||||
|
oracle: oracle,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type quoteCalculator struct {
|
||||||
|
logger mlogger.Logger
|
||||||
|
oracle fxOracle
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *quoteCalculator) Compute(ctx context.Context, plan *model.FeePlan, intent *feesv1.Intent, bookedAt time.Time, _ *tracev1.TraceContext) (*types.CalculationResult, error) {
|
||||||
|
baseAmount, baseScale, trigger, err := validateComputeInputs(plan, intent)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rules := make([]model.FeeRule, len(plan.Rules))
|
||||||
|
copy(rules, plan.Rules)
|
||||||
|
sort.SliceStable(rules, func(i, j int) bool {
|
||||||
|
if rules[i].Priority == rules[j].Priority {
|
||||||
|
return rules[i].RuleID < rules[j].RuleID
|
||||||
|
}
|
||||||
|
|
||||||
|
return rules[i].Priority < rules[j].Priority
|
||||||
|
})
|
||||||
|
|
||||||
|
planID := planIDFrom(plan)
|
||||||
|
lines := make([]*feesv1.DerivedPostingLine, 0, len(rules))
|
||||||
|
applied := make([]*feesv1.AppliedRule, 0, len(rules))
|
||||||
|
|
||||||
|
for _, rule := range rules {
|
||||||
|
if !shouldApplyRule(rule, trigger, intent.GetAttributes(), bookedAt) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
amount, scale, calcErr := c.calculateRuleAmount(baseAmount, baseScale, rule)
|
||||||
|
if calcErr != nil {
|
||||||
|
if !errors.Is(calcErr, merrors.ErrInvalidArg) {
|
||||||
|
c.logger.Warn("Failed to calculate fee rule amount", zap.String("rule_id", rule.RuleID), zap.Error(calcErr))
|
||||||
|
}
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if amount.Sign() == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
currency := resolvedCurrency(intent.GetBaseAmount().GetCurrency(), rule.Currency)
|
||||||
|
entrySide := resolvedEntrySide(rule)
|
||||||
|
|
||||||
|
lines = append(lines, buildPostingLine(rule, amount, scale, currency, entrySide, planID))
|
||||||
|
applied = append(applied, buildAppliedRule(rule, planID))
|
||||||
|
}
|
||||||
|
|
||||||
|
var fxUsed *feesv1.FXUsed
|
||||||
|
if trigger == model.TriggerFXConversion && c.oracle != nil {
|
||||||
|
fxUsed = c.buildFxUsed(ctx, intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &types.CalculationResult{
|
||||||
|
Lines: lines,
|
||||||
|
Applied: applied,
|
||||||
|
FxUsed: fxUsed,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *quoteCalculator) calculateRuleAmount(baseAmount *big.Rat, baseScale uint32, rule model.FeeRule) (*big.Rat, uint32, error) {
|
||||||
|
scale, err := resolveRuleScale(rule, baseScale)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := new(big.Rat)
|
||||||
|
|
||||||
|
result, err = applyPercentage(result, baseAmount, rule.Percentage)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err = applyFixed(result, rule.FixedAmount)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err = applyMin(result, rule.MinimumAmount)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err = applyMax(result, rule.MaximumAmount)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Sign() < 0 {
|
||||||
|
result = new(big.Rat).Abs(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
rounded, rerr := dmath.RoundRatToScale(result, scale, toDecimalRounding(rule.Rounding))
|
||||||
|
if rerr != nil {
|
||||||
|
return nil, 0, rerr
|
||||||
|
}
|
||||||
|
|
||||||
|
return rounded, scale, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
attrFxProvider = "fx_provider"
|
||||||
|
attrFxSide = "fx_side"
|
||||||
|
attrFxRateOverride = "fx_rate"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c *quoteCalculator) buildFxUsed(ctx context.Context, intent *feesv1.Intent) *feesv1.FXUsed {
|
||||||
|
if intent == nil || c.oracle == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
attrs := intent.GetAttributes()
|
||||||
|
|
||||||
|
base := strings.TrimSpace(attrs[attrFxBaseCurrency])
|
||||||
|
|
||||||
|
quote := strings.TrimSpace(attrs[attrFxQuoteCurrency])
|
||||||
|
if base == "" || quote == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
pair := &fxv1.CurrencyPair{Base: base, Quote: quote}
|
||||||
|
provider := strings.TrimSpace(attrs[attrFxProvider])
|
||||||
|
|
||||||
|
snapshot, err := c.oracle.LatestRate(ctx, oracleclient.LatestRateParams{
|
||||||
|
Meta: oracleclient.RequestMeta{},
|
||||||
|
Pair: pair,
|
||||||
|
Provider: provider,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
c.logger.Warn("Fees: failed to fetch FX context", zap.Error(err))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if snapshot == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
rateValue := strings.TrimSpace(attrs[attrFxRateOverride])
|
||||||
|
|
||||||
|
if rateValue == "" {
|
||||||
|
rateValue = snapshot.Mid
|
||||||
|
}
|
||||||
|
|
||||||
|
if rateValue == "" {
|
||||||
|
rateValue = snapshot.Ask
|
||||||
|
}
|
||||||
|
|
||||||
|
if rateValue == "" {
|
||||||
|
rateValue = snapshot.Bid
|
||||||
|
}
|
||||||
|
|
||||||
|
return &feesv1.FXUsed{
|
||||||
|
Pair: pair,
|
||||||
|
Side: parseFxSide(strings.TrimSpace(attrs[attrFxSide])),
|
||||||
|
Rate: &moneyv1.Decimal{Value: rateValue},
|
||||||
|
AsofUnixMs: snapshot.AsOf.UnixMilli(),
|
||||||
|
Provider: snapshot.Provider,
|
||||||
|
RateRef: snapshot.RateRef,
|
||||||
|
SpreadBps: &moneyv1.Decimal{Value: snapshot.SpreadBps},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseFxSide(value string) fxv1.Side {
|
||||||
|
switch strings.ToLower(value) {
|
||||||
|
case "buy_base", "buy_base_sell_quote", "buy":
|
||||||
|
return fxv1.Side_BUY_BASE_SELL_QUOTE
|
||||||
|
case "sell_base", "sell_base_buy_quote", "sell":
|
||||||
|
return fxv1.Side_SELL_BASE_BUY_QUOTE
|
||||||
|
default:
|
||||||
|
return fxv1.Side_SIDE_UNSPECIFIED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func inferScale(amount string) uint32 {
|
||||||
|
value := strings.TrimSpace(amount)
|
||||||
|
if value == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if idx := strings.IndexAny(value, "eE"); idx >= 0 {
|
||||||
|
value = value[:idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(value, "+") || strings.HasPrefix(value, "-") {
|
||||||
|
value = value[1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, after, found := strings.Cut(value, "."); found {
|
||||||
|
return uint32(len(after)) //nolint:gosec // decimal scale; cannot overflow
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldApplyRule(rule model.FeeRule, trigger model.Trigger, attributes map[string]string, bookedAt time.Time) bool {
|
||||||
|
if rule.Trigger != trigger {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if rule.EffectiveFrom.After(bookedAt) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if rule.EffectiveTo != nil && rule.EffectiveTo.Before(bookedAt) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return ruleMatchesAttributes(rule, attributes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveRuleScale(rule model.FeeRule, fallback uint32) (uint32, error) {
|
||||||
|
if rule.Metadata != nil {
|
||||||
|
for _, field := range []string{"scale", "decimals", "precision"} {
|
||||||
|
if value, ok := rule.Metadata[field]; ok && strings.TrimSpace(value) != "" {
|
||||||
|
return parseScale(field, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseScale(field, value string) (uint32, error) {
|
||||||
|
clean := strings.TrimSpace(value)
|
||||||
|
if clean == "" {
|
||||||
|
return 0, merrors.InvalidArgument(field + " is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := strconv.ParseUint(clean, 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
return 0, merrors.InvalidArgument("invalid " + field + " value")
|
||||||
|
}
|
||||||
|
|
||||||
|
return uint32(parsed), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func 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])
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneStringMap(src map[string]string) map[string]string {
|
||||||
|
if len(src) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cloned := make(map[string]string, len(src))
|
||||||
|
maps.Copy(cloned, src)
|
||||||
|
|
||||||
|
return cloned
|
||||||
|
}
|
||||||
|
|
||||||
|
func ruleMatchesAttributes(rule model.FeeRule, attributes map[string]string) bool {
|
||||||
|
if len(rule.AppliesTo) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, value := range rule.AppliesTo {
|
||||||
|
if attributes == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
attrValue, ok := attributes[key]
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if !matchesAttributeValue(value, attrValue) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchesAttributeValue(expected, actual string) bool {
|
||||||
|
trimmed := strings.TrimSpace(expected)
|
||||||
|
if trimmed == "" {
|
||||||
|
return actual == ""
|
||||||
|
}
|
||||||
|
|
||||||
|
for value := range strings.SplitSeq(trimmed, ",") {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if value == "*" || value == actual {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapLineType(lineType string) accountingv1.PostingLineType {
|
||||||
|
switch strings.ToLower(lineType) {
|
||||||
|
case "tax":
|
||||||
|
return accountingv1.PostingLineType_POSTING_LINE_TAX
|
||||||
|
case "spread":
|
||||||
|
return accountingv1.PostingLineType_POSTING_LINE_SPREAD
|
||||||
|
case "reversal":
|
||||||
|
return accountingv1.PostingLineType_POSTING_LINE_REVERSAL
|
||||||
|
default:
|
||||||
|
return accountingv1.PostingLineType_POSTING_LINE_FEE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapEntrySide(entrySide string) accountingv1.EntrySide {
|
||||||
|
switch strings.ToLower(entrySide) {
|
||||||
|
case "debit":
|
||||||
|
return accountingv1.EntrySide_ENTRY_SIDE_DEBIT
|
||||||
|
case "credit":
|
||||||
|
return accountingv1.EntrySide_ENTRY_SIDE_CREDIT
|
||||||
|
default:
|
||||||
|
return accountingv1.EntrySide_ENTRY_SIDE_UNSPECIFIED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toDecimalRounding(mode string) dmath.RoundingMode {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(mode)) {
|
||||||
|
case "half_up":
|
||||||
|
return dmath.RoundingModeHalfUp
|
||||||
|
case "down":
|
||||||
|
return dmath.RoundingModeDown
|
||||||
|
case "half_even", "bankers":
|
||||||
|
return dmath.RoundingModeHalfEven
|
||||||
|
default:
|
||||||
|
return dmath.RoundingModeHalfEven
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapRoundingMode(mode string) moneyv1.RoundingMode {
|
||||||
|
switch strings.ToLower(mode) {
|
||||||
|
case "half_up":
|
||||||
|
return moneyv1.RoundingMode_ROUND_HALF_UP
|
||||||
|
case "down":
|
||||||
|
return moneyv1.RoundingMode_ROUND_DOWN
|
||||||
|
default:
|
||||||
|
return moneyv1.RoundingMode_ROUND_HALF_EVEN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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:
|
||||||
|
return model.TriggerRefund
|
||||||
|
case feesv1.Trigger_TRIGGER_DISPUTE:
|
||||||
|
return model.TriggerDispute
|
||||||
|
case feesv1.Trigger_TRIGGER_PAYOUT:
|
||||||
|
return model.TriggerPayout
|
||||||
|
case feesv1.Trigger_TRIGGER_FX_CONVERSION:
|
||||||
|
return model.TriggerFXConversion
|
||||||
|
default:
|
||||||
|
return model.TriggerUnspecified
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package resolver
|
||||||
|
|
||||||
|
import "github.com/tech/sendico/pkg/merrors"
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrNoFeeRuleFound indicates that no applicable rule exists for the given context.
|
||||||
|
ErrNoFeeRuleFound = merrors.ErrNoData
|
||||||
|
// ErrConflictingFeeRules indicates multiple rules share the same highest priority.
|
||||||
|
ErrConflictingFeeRules = merrors.ErrDataConflict
|
||||||
|
)
|
||||||
307
api/billing/fees/internal/service/fees/internal/resolver/impl.go
Normal file
307
api/billing/fees/internal/service/fees/internal/resolver/impl.go
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
package resolver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
type planFinder interface {
|
||||||
|
FindActiveOrgPlan(ctx context.Context, orgRef bson.ObjectID, at time.Time) (*model.FeePlan, error)
|
||||||
|
FindActiveGlobalPlan(ctx context.Context, at time.Time) (*model.FeePlan, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type feeResolver struct {
|
||||||
|
plans storage.PlansStore
|
||||||
|
finder planFinder
|
||||||
|
logger mlogger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
logger: logger.Named("resolver"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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, 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", 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, asOf, attrs)
|
||||||
|
if err != nil {
|
||||||
|
if !errors.Is(err, ErrNoFeeRuleFound) {
|
||||||
|
r.logger.Warn("Failed selecting rule in global plan", zap.Error(err))
|
||||||
|
} else {
|
||||||
|
globalFields := zapFieldsForPlan(plan)
|
||||||
|
globalFields = append([]zap.Field{
|
||||||
|
zap.String("trigger", string(trigger)),
|
||||||
|
zap.Time("booked_at", asOf),
|
||||||
|
zap.Any("attributes", attrs),
|
||||||
|
}, globalFields...)
|
||||||
|
r.logger.Debug("No matching rule in global plan", globalFields...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
zap.String("rule_id", rule.RuleID),
|
||||||
|
zap.Int("rule_priority", rule.Priority),
|
||||||
|
zap.Any("rule_applies_to", rule.AppliesTo),
|
||||||
|
zap.Time("rule_effective_from", rule.EffectiveFrom),
|
||||||
|
}
|
||||||
|
if rule.EffectiveTo != nil {
|
||||||
|
fields = append(fields, zap.Time("rule_effective_to", *rule.EffectiveTo))
|
||||||
|
}
|
||||||
|
|
||||||
|
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, asOf time.Time) (*model.FeePlan, error) {
|
||||||
|
if r.finder != nil {
|
||||||
|
return r.finder.FindActiveGlobalPlan(ctx, asOf)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Treat zero ObjectID as global in legacy path.
|
||||||
|
return r.plans.GetActivePlan(ctx, bson.NilObjectID, asOf)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
highestPriority int
|
||||||
|
)
|
||||||
|
|
||||||
|
for _, rule := range plan.Rules {
|
||||||
|
if !ruleIsActive(rule, trigger, asOf) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !matchesAppliesTo(rule.AppliesTo, attrs) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if selected == nil || rule.Priority > highestPriority {
|
||||||
|
matched := rule
|
||||||
|
selected = &matched
|
||||||
|
highestPriority = rule.Priority
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if rule.Priority == highestPriority {
|
||||||
|
return nil, merrors.DataConflict("fees: conflicting fee rules")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchesAppliesValue(expected, actual string) bool {
|
||||||
|
trimmed := strings.TrimSpace(expected)
|
||||||
|
if trimmed == "" {
|
||||||
|
return actual == ""
|
||||||
|
}
|
||||||
|
|
||||||
|
for value := range strings.SplitSeq(trimmed, ",") {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if value == "*" || value == actual {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
zap.Time("plan_effective_from", plan.EffectiveFrom),
|
||||||
|
zap.Int("plan_rules_count", len(plan.Rules)),
|
||||||
|
}
|
||||||
|
if plan.EffectiveTo != nil {
|
||||||
|
fields = append(fields, zap.Time("plan_effective_to", *plan.EffectiveTo))
|
||||||
|
} 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
package resolver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/billing/fees/storage"
|
||||||
|
"github.com/tech/sendico/billing/fees/storage/model"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResolver_GlobalFallbackWhenOrgMissing(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
globalPlan := &model.FeePlan{
|
||||||
|
Active: true,
|
||||||
|
EffectiveFrom: now.Add(-time.Hour),
|
||||||
|
Rules: []model.FeeRule{
|
||||||
|
{RuleID: "global_capture", Trigger: model.TriggerCapture, Priority: 5, Percentage: "0.05", EffectiveFrom: now.Add(-time.Hour)},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
store := &memoryPlansStore{plans: []*model.FeePlan{globalPlan}}
|
||||||
|
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.Parallel()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
org := bson.NewObjectID()
|
||||||
|
globalPlan := &model.FeePlan{
|
||||||
|
Active: true,
|
||||||
|
EffectiveFrom: now.Add(-time.Hour),
|
||||||
|
Rules: []model.FeeRule{
|
||||||
|
{RuleID: "global_capture", Trigger: model.TriggerCapture, Priority: 5, Percentage: "0.05", EffectiveFrom: now.Add(-time.Hour)},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
orgPlan := &model.FeePlan{
|
||||||
|
Active: true,
|
||||||
|
EffectiveFrom: now.Add(-time.Hour),
|
||||||
|
Rules: []model.FeeRule{
|
||||||
|
{RuleID: "org_capture", Trigger: model.TriggerCapture, Priority: 10, Percentage: "0.03", EffectiveFrom: now.Add(-time.Hour)},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
orgPlan.OrganizationRef = &org
|
||||||
|
store := &memoryPlansStore{plans: []*model.FeePlan{globalPlan, orgPlan}}
|
||||||
|
resolver := New(store, zap.NewNop())
|
||||||
|
|
||||||
|
_, rule, err := resolver.ResolveFeeRule(context.Background(), &org, model.TriggerCapture, now, nil)
|
||||||
|
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.Parallel()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
org := bson.NewObjectID()
|
||||||
|
plan := &model.FeePlan{
|
||||||
|
Active: true,
|
||||||
|
EffectiveFrom: now.Add(-time.Hour),
|
||||||
|
Rules: []model.FeeRule{
|
||||||
|
{RuleID: "low", Trigger: model.TriggerCapture, Priority: 100, Percentage: "0.05", EffectiveFrom: now.Add(-time.Hour)},
|
||||||
|
{RuleID: "high", Trigger: model.TriggerCapture, Priority: 200, Percentage: "0.03", EffectiveFrom: now.Add(-time.Hour)},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
plan.OrganizationRef = &org
|
||||||
|
|
||||||
|
store := &memoryPlansStore{plans: []*model.FeePlan{plan}}
|
||||||
|
resolver := New(store, zap.NewNop())
|
||||||
|
|
||||||
|
_, rule, err := resolver.ResolveFeeRule(context.Background(), &org, model.TriggerCapture, now, nil)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
plan.Rules = append(plan.Rules, model.FeeRule{
|
||||||
|
RuleID: "conflict",
|
||||||
|
Trigger: model.TriggerCapture,
|
||||||
|
Priority: 200,
|
||||||
|
Percentage: "0.02",
|
||||||
|
EffectiveFrom: now.Add(-time.Hour),
|
||||||
|
})
|
||||||
|
|
||||||
|
if _, _, err := resolver.ResolveFeeRule(context.Background(), &org, model.TriggerCapture, now, nil); !errors.Is(err, ErrConflictingFeeRules) {
|
||||||
|
t.Fatalf("expected conflicting fee rules error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolver_EffectiveDateFiltering(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
org := bson.NewObjectID()
|
||||||
|
past := now.Add(-24 * time.Hour)
|
||||||
|
future := now.Add(24 * time.Hour)
|
||||||
|
|
||||||
|
orgPlan := &model.FeePlan{
|
||||||
|
Active: true,
|
||||||
|
EffectiveFrom: past,
|
||||||
|
Rules: []model.FeeRule{
|
||||||
|
{RuleID: "expired", Trigger: model.TriggerCapture, Priority: 100, Percentage: "0.05", EffectiveFrom: past, EffectiveTo: &past},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
orgPlan.OrganizationRef = &org
|
||||||
|
|
||||||
|
globalPlan := &model.FeePlan{
|
||||||
|
Active: true,
|
||||||
|
EffectiveFrom: past,
|
||||||
|
Rules: []model.FeeRule{
|
||||||
|
{RuleID: "current", Trigger: model.TriggerCapture, Priority: 100, Percentage: "0.05", EffectiveFrom: past, EffectiveTo: &future},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
store := &memoryPlansStore{plans: []*model.FeePlan{orgPlan, globalPlan}}
|
||||||
|
resolver := New(store, zap.NewNop())
|
||||||
|
|
||||||
|
_, rule, err := resolver.ResolveFeeRule(context.Background(), &org, model.TriggerCapture, now, nil)
|
||||||
|
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.Parallel()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
plan := &model.FeePlan{
|
||||||
|
Active: true,
|
||||||
|
EffectiveFrom: now.Add(-time.Hour),
|
||||||
|
Rules: []model.FeeRule{
|
||||||
|
{RuleID: "card", Trigger: model.TriggerCapture, Priority: 200, Percentage: "0.03", AppliesTo: map[string]string{"paymentMethod": "card"}, EffectiveFrom: now.Add(-time.Hour)},
|
||||||
|
{RuleID: "default", Trigger: model.TriggerCapture, Priority: 100, Percentage: "0.05", EffectiveFrom: now.Add(-time.Hour)},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
store := &memoryPlansStore{plans: []*model.FeePlan{plan}}
|
||||||
|
resolver := New(store, zap.NewNop())
|
||||||
|
|
||||||
|
_, rule, err := resolver.ResolveFeeRule(context.Background(), nil, model.TriggerCapture, now, map[string]string{"paymentMethod": "card"})
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, rule, err = resolver.ResolveFeeRule(context.Background(), nil, model.TriggerCapture, now, map[string]string{"paymentMethod": "bank"})
|
||||||
|
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.Parallel()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
plan := &model.FeePlan{
|
||||||
|
Active: true,
|
||||||
|
EffectiveFrom: now.Add(-time.Hour),
|
||||||
|
Rules: []model.FeeRule{
|
||||||
|
{RuleID: "network_multi", Trigger: model.TriggerCapture, Priority: 300, Percentage: "0.03", AppliesTo: map[string]string{"network": "tron, solana"}, EffectiveFrom: now.Add(-time.Hour)},
|
||||||
|
{RuleID: "asset_any", Trigger: model.TriggerCapture, Priority: 200, Percentage: "0.02", AppliesTo: map[string]string{"asset": "*"}, EffectiveFrom: now.Add(-time.Hour)},
|
||||||
|
{RuleID: "default", Trigger: model.TriggerCapture, Priority: 100, Percentage: "0.05", EffectiveFrom: now.Add(-time.Hour)},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
store := &memoryPlansStore{plans: []*model.FeePlan{plan}}
|
||||||
|
resolver := New(store, zap.NewNop())
|
||||||
|
|
||||||
|
_, rule, err := resolver.ResolveFeeRule(context.Background(), nil, model.TriggerCapture, now, map[string]string{"network": "tron"})
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, rule, err = resolver.ResolveFeeRule(context.Background(), nil, model.TriggerCapture, now, map[string]string{"asset": "USDT"})
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, rule, err = resolver.ResolveFeeRule(context.Background(), nil, model.TriggerCapture, now, map[string]string{"network": "eth"})
|
||||||
|
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.Parallel()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
plan := &model.FeePlan{
|
||||||
|
Active: true,
|
||||||
|
EffectiveFrom: now.Add(-time.Hour),
|
||||||
|
Rules: []model.FeeRule{
|
||||||
|
{RuleID: "capture", Trigger: model.TriggerCapture, Priority: 10, Percentage: "0.05", EffectiveFrom: now.Add(-time.Hour)},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
store := &memoryPlansStore{plans: []*model.FeePlan{plan}}
|
||||||
|
resolver := New(store, zap.NewNop())
|
||||||
|
|
||||||
|
if _, _, err := resolver.ResolveFeeRule(context.Background(), nil, model.TriggerRefund, now, nil); !errors.Is(err, ErrNoFeeRuleFound) {
|
||||||
|
t.Fatalf("expected ErrNoFeeRuleFound, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolver_MultipleActivePlansConflict(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
org := bson.NewObjectID()
|
||||||
|
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)},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
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)},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
plan2.OrganizationRef = &org
|
||||||
|
|
||||||
|
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) {
|
||||||
|
t.Fatalf("expected conflicting plans error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type memoryPlansStore struct {
|
||||||
|
plans []*model.FeePlan
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memoryPlansStore) Create(context.Context, *model.FeePlan) error { return nil }
|
||||||
|
func (m *memoryPlansStore) Update(context.Context, *model.FeePlan) error { return nil }
|
||||||
|
func (m *memoryPlansStore) Get(context.Context, bson.ObjectID) (*model.FeePlan, error) {
|
||||||
|
return nil, storage.ErrFeePlanNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
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, asOf); err == nil {
|
||||||
|
return plan, nil
|
||||||
|
} else if !errors.Is(err, storage.ErrFeePlanNotFound) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return m.FindActiveGlobalPlan(ctx, asOf)
|
||||||
|
}
|
||||||
|
|
||||||
|
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(asOf) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
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, 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(asOf) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ storage.PlansStore = (*memoryPlansStore)(nil)
|
||||||
106
api/billing/fees/internal/service/fees/logging.go
Normal file
106
api/billing/fees/internal/service/fees/logging.go
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
package fees
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||||
|
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func requestLogFields(meta *feesv1.RequestMeta, intent *feesv1.Intent) []zap.Field {
|
||||||
|
fields := logFieldsFromRequestMeta(meta)
|
||||||
|
fields = append(fields, logFieldsFromIntent(intent)...)
|
||||||
|
|
||||||
|
return fields
|
||||||
|
}
|
||||||
|
|
||||||
|
func logFieldsFromRequestMeta(meta *feesv1.RequestMeta) []zap.Field {
|
||||||
|
if meta == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fields := make([]zap.Field, 0, 4)
|
||||||
|
if org := strings.TrimSpace(meta.GetOrganizationRef()); org != "" {
|
||||||
|
fields = append(fields, zap.String("organization_ref", org))
|
||||||
|
}
|
||||||
|
|
||||||
|
fields = append(fields, logFieldsFromTrace(meta.GetTrace())...)
|
||||||
|
|
||||||
|
return fields
|
||||||
|
}
|
||||||
|
|
||||||
|
func logFieldsFromIntent(intent *feesv1.Intent) []zap.Field {
|
||||||
|
if intent == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fields := make([]zap.Field, 0, 5)
|
||||||
|
if trigger := intent.GetTrigger(); trigger != feesv1.Trigger_TRIGGER_UNSPECIFIED {
|
||||||
|
fields = append(fields, zap.String("trigger", trigger.String()))
|
||||||
|
}
|
||||||
|
|
||||||
|
if base := intent.GetBaseAmount(); base != nil {
|
||||||
|
if amount := strings.TrimSpace(base.GetAmount()); amount != "" {
|
||||||
|
fields = append(fields, zap.String("base_amount", amount))
|
||||||
|
}
|
||||||
|
|
||||||
|
if currency := strings.TrimSpace(base.GetCurrency()); currency != "" {
|
||||||
|
fields = append(fields, zap.String("base_currency", currency))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if booked := intent.GetBookedAt(); booked != nil && booked.IsValid() {
|
||||||
|
fields = append(fields, zap.Time("booked_at", booked.AsTime()))
|
||||||
|
}
|
||||||
|
|
||||||
|
if attrs := intent.GetAttributes(); len(attrs) > 0 {
|
||||||
|
fields = append(fields, zap.Int("attributes_count", len(attrs)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return fields
|
||||||
|
}
|
||||||
|
|
||||||
|
func logFieldsFromTrace(trace *tracev1.TraceContext) []zap.Field {
|
||||||
|
if trace == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fields := make([]zap.Field, 0, 3)
|
||||||
|
if reqRef := strings.TrimSpace(trace.GetRequestRef()); reqRef != "" {
|
||||||
|
fields = append(fields, zap.String("request_ref", reqRef))
|
||||||
|
}
|
||||||
|
|
||||||
|
if idem := strings.TrimSpace(trace.GetIdempotencyKey()); idem != "" {
|
||||||
|
fields = append(fields, zap.String("idempotency_key", idem))
|
||||||
|
}
|
||||||
|
|
||||||
|
if traceRef := strings.TrimSpace(trace.GetTraceRef()); traceRef != "" {
|
||||||
|
fields = append(fields, zap.String("trace_ref", traceRef))
|
||||||
|
}
|
||||||
|
|
||||||
|
return fields
|
||||||
|
}
|
||||||
|
|
||||||
|
func logFieldsFromTokenPayload(payload *feeQuoteTokenPayload) []zap.Field {
|
||||||
|
if payload == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fields := make([]zap.Field, 0, 6)
|
||||||
|
if org := strings.TrimSpace(payload.OrganizationRef); org != "" {
|
||||||
|
fields = append(fields, zap.String("organization_ref", org))
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.ExpiresAtUnixMs > 0 {
|
||||||
|
fields = append(fields,
|
||||||
|
zap.Int64("expires_at_unix_ms", payload.ExpiresAtUnixMs),
|
||||||
|
zap.Time("expires_at", time.UnixMilli(payload.ExpiresAtUnixMs)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fields = append(fields, logFieldsFromIntent(payload.Intent)...)
|
||||||
|
fields = append(fields, logFieldsFromTrace(payload.Trace)...)
|
||||||
|
|
||||||
|
return fields
|
||||||
|
}
|
||||||
@@ -50,6 +50,7 @@ func observeMetrics(call string, trigger feesv1.Trigger, statusLabel string, fxU
|
|||||||
if trigger == feesv1.Trigger_TRIGGER_UNSPECIFIED {
|
if trigger == feesv1.Trigger_TRIGGER_UNSPECIFIED {
|
||||||
triggerLabel = "TRIGGER_UNSPECIFIED"
|
triggerLabel = "TRIGGER_UNSPECIFIED"
|
||||||
}
|
}
|
||||||
|
|
||||||
fxLabel := strconv.FormatBool(fxUsed)
|
fxLabel := strconv.FormatBool(fxUsed)
|
||||||
quoteRequestsTotal.WithLabelValues(call, triggerLabel, statusLabel, fxLabel).Inc()
|
quoteRequestsTotal.WithLabelValues(call, triggerLabel, statusLabel, fxLabel).Inc()
|
||||||
quoteLatency.WithLabelValues(call, triggerLabel, statusLabel, fxLabel).Observe(took.Seconds())
|
quoteLatency.WithLabelValues(call, triggerLabel, statusLabel, fxLabel).Observe(took.Seconds())
|
||||||
@@ -59,13 +60,16 @@ func statusFromError(err error) string {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
return "success"
|
return "success"
|
||||||
}
|
}
|
||||||
|
|
||||||
st, ok := status.FromError(err)
|
st, ok := status.FromError(err)
|
||||||
if !ok {
|
if !ok {
|
||||||
return "error"
|
return "error"
|
||||||
}
|
}
|
||||||
|
|
||||||
code := st.Code()
|
code := st.Code()
|
||||||
if code == codes.OK {
|
if code == codes.OK {
|
||||||
return "success"
|
return "success"
|
||||||
}
|
}
|
||||||
|
|
||||||
return strings.ToLower(code.String())
|
return strings.ToLower(code.String())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package fees
|
package fees
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
internalcalculator "github.com/tech/sendico/billing/fees/internal/service/fees/internal/calculator"
|
||||||
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
||||||
clockpkg "github.com/tech/sendico/pkg/clock"
|
clockpkg "github.com/tech/sendico/pkg/clock"
|
||||||
)
|
)
|
||||||
@@ -30,8 +33,25 @@ func WithCalculator(calculator Calculator) Option {
|
|||||||
func WithOracleClient(oracle oracleclient.Client) Option {
|
func WithOracleClient(oracle oracleclient.Client) Option {
|
||||||
return func(s *Service) {
|
return func(s *Service) {
|
||||||
s.oracle = oracle
|
s.oracle = oracle
|
||||||
if qc, ok := s.calculator.(*quoteCalculator); ok {
|
// Rebuild default calculator if none was injected.
|
||||||
qc.oracle = oracle
|
if s.calculator == nil {
|
||||||
|
s.calculator = internalcalculator.New(s.logger, oracle)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithFeeResolver injects a custom fee resolver (useful for tests).
|
||||||
|
func WithFeeResolver(r FeeResolver) Option {
|
||||||
|
return func(s *Service) {
|
||||||
|
if r != nil {
|
||||||
|
s.resolver = r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithDiscoveryInvokeURI sets the invoke URI used when announcing the service in discovery.
|
||||||
|
func WithDiscoveryInvokeURI(invokeURI string) Option {
|
||||||
|
return func(s *Service) {
|
||||||
|
s.invokeURI = strings.TrimSpace(invokeURI)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
15
api/billing/fees/internal/service/fees/resolver.go
Normal file
15
api/billing/fees/internal/service/fees/resolver.go
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
package fees
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/billing/fees/storage/model"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FeeResolver centralises plan/rule resolution with org override and global fallback.
|
||||||
|
// Implementations live under the internal/resolver package.
|
||||||
|
type FeeResolver interface {
|
||||||
|
ResolveFeeRule(ctx context.Context, orgID *bson.ObjectID, trigger model.Trigger, at time.Time, attrs map[string]string) (*model.FeePlan, *model.FeeRule, error)
|
||||||
|
}
|
||||||
@@ -8,16 +8,23 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/billing/fees/internal/appversion"
|
||||||
|
internalcalculator "github.com/tech/sendico/billing/fees/internal/service/fees/internal/calculator"
|
||||||
|
"github.com/tech/sendico/billing/fees/internal/service/fees/internal/resolver"
|
||||||
"github.com/tech/sendico/billing/fees/storage"
|
"github.com/tech/sendico/billing/fees/storage"
|
||||||
|
"github.com/tech/sendico/billing/fees/storage/model"
|
||||||
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
||||||
"github.com/tech/sendico/pkg/api/routers"
|
"github.com/tech/sendico/pkg/api/routers"
|
||||||
clockpkg "github.com/tech/sendico/pkg/clock"
|
clockpkg "github.com/tech/sendico/pkg/clock"
|
||||||
|
"github.com/tech/sendico/pkg/discovery"
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
msg "github.com/tech/sendico/pkg/messaging"
|
msg "github.com/tech/sendico/pkg/messaging"
|
||||||
"github.com/tech/sendico/pkg/mlogger"
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"github.com/tech/sendico/pkg/mservice"
|
||||||
|
"github.com/tech/sendico/pkg/mutil/mzap"
|
||||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||||
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
|
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
|
||||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
@@ -26,13 +33,17 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
|
feesv1.UnimplementedFeeEngineServer
|
||||||
|
|
||||||
logger mlogger.Logger
|
logger mlogger.Logger
|
||||||
storage storage.Repository
|
storage storage.Repository
|
||||||
producer msg.Producer
|
producer msg.Producer
|
||||||
clock clockpkg.Clock
|
clock clockpkg.Clock
|
||||||
calculator Calculator
|
calculator Calculator
|
||||||
oracle oracleclient.Client
|
oracle oracleclient.Client
|
||||||
feesv1.UnimplementedFeeEngineServer
|
resolver FeeResolver
|
||||||
|
announcer *discovery.Announcer
|
||||||
|
invokeURI string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Producer, opts ...Option) *Service {
|
func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Producer, opts ...Option) *Service {
|
||||||
@@ -42,6 +53,7 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro
|
|||||||
producer: producer,
|
producer: producer,
|
||||||
clock: clockpkg.NewSystem(),
|
clock: clockpkg.NewSystem(),
|
||||||
}
|
}
|
||||||
|
|
||||||
initMetrics()
|
initMetrics()
|
||||||
|
|
||||||
for _, opt := range opts {
|
for _, opt := range opts {
|
||||||
@@ -51,10 +63,17 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro
|
|||||||
if svc.clock == nil {
|
if svc.clock == nil {
|
||||||
svc.clock = clockpkg.NewSystem()
|
svc.clock = clockpkg.NewSystem()
|
||||||
}
|
}
|
||||||
|
|
||||||
if svc.calculator == nil {
|
if svc.calculator == nil {
|
||||||
svc.calculator = newQuoteCalculator(svc.logger, svc.oracle)
|
svc.calculator = internalcalculator.New(svc.logger, svc.oracle)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if svc.resolver == nil {
|
||||||
|
svc.resolver = resolver.New(repo.Plans(), svc.logger)
|
||||||
|
}
|
||||||
|
|
||||||
|
svc.startDiscoveryAnnouncer()
|
||||||
|
|
||||||
return svc
|
return svc
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,34 +83,85 @@ func (s *Service) Register(router routers.GRPC) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) QuoteFees(ctx context.Context, req *feesv1.QuoteFeesRequest) (resp *feesv1.QuoteFeesResponse, err error) {
|
func (s *Service) Shutdown() {
|
||||||
start := s.clock.Now()
|
if s == nil {
|
||||||
trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED
|
return
|
||||||
if req != nil && req.GetIntent() != nil {
|
|
||||||
trigger = req.GetIntent().GetTrigger()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if s.announcer != nil {
|
||||||
|
s.announcer.Stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) QuoteFees(ctx context.Context, req *feesv1.QuoteFeesRequest) (resp *feesv1.QuoteFeesResponse, err error) {
|
||||||
|
var (
|
||||||
|
meta *feesv1.RequestMeta
|
||||||
|
intent *feesv1.Intent
|
||||||
|
)
|
||||||
|
if req != nil {
|
||||||
|
meta = req.GetMeta()
|
||||||
|
intent = req.GetIntent()
|
||||||
|
}
|
||||||
|
|
||||||
|
logger := s.logger.With(requestLogFields(meta, intent)...)
|
||||||
|
|
||||||
|
start := s.clock.Now()
|
||||||
|
|
||||||
|
trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED
|
||||||
|
if intent != nil {
|
||||||
|
trigger = intent.GetTrigger()
|
||||||
|
}
|
||||||
|
|
||||||
var fxUsed bool
|
var fxUsed bool
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
statusLabel := statusFromError(err)
|
statusLabel := statusFromError(err)
|
||||||
|
linesCount := 0
|
||||||
|
appliedCount := 0
|
||||||
|
|
||||||
if err == nil && resp != nil {
|
if err == nil && resp != nil {
|
||||||
fxUsed = resp.GetFxUsed() != nil
|
fxUsed = resp.GetFxUsed() != nil
|
||||||
|
linesCount = len(resp.GetLines())
|
||||||
|
appliedCount = len(resp.GetApplied())
|
||||||
}
|
}
|
||||||
|
|
||||||
observeMetrics("quote", trigger, statusLabel, fxUsed, time.Since(start))
|
observeMetrics("quote", trigger, statusLabel, fxUsed, time.Since(start))
|
||||||
|
|
||||||
|
logFields := []zap.Field{
|
||||||
|
zap.String("status", statusLabel),
|
||||||
|
zap.Duration("duration", time.Since(start)),
|
||||||
|
zap.Bool("fx_used", fxUsed),
|
||||||
|
zap.String("trigger", trigger.String()),
|
||||||
|
zap.Int("lines", linesCount),
|
||||||
|
zap.Int("applied_rules", appliedCount),
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("QuoteFees finished", append(logFields, zap.Error(err))...)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info("QuoteFees finished", logFields...)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
logger.Debug("QuoteFees request received")
|
||||||
|
|
||||||
if err = s.validateQuoteRequest(req); err != nil {
|
if err = s.validateQuoteRequest(req); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
orgRef, parseErr := primitive.ObjectIDFromHex(req.GetMeta().GetOrganizationRef())
|
orgRef, parseErr := bson.ObjectIDFromHex(req.GetMeta().GetOrganizationRef())
|
||||||
if parseErr != nil {
|
if parseErr != nil {
|
||||||
|
logger.Warn("QuoteFees invalid organization_ref", zap.Error(parseErr))
|
||||||
err = status.Error(codes.InvalidArgument, "invalid organization_ref")
|
err = status.Error(codes.InvalidArgument, "invalid organization_ref")
|
||||||
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
if computeErr != nil {
|
||||||
err = computeErr
|
err = computeErr
|
||||||
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,25 +169,34 @@ func (s *Service) QuoteFees(ctx context.Context, req *feesv1.QuoteFeesRequest) (
|
|||||||
Meta: &feesv1.ResponseMeta{Trace: req.GetMeta().GetTrace()},
|
Meta: &feesv1.ResponseMeta{Trace: req.GetMeta().GetTrace()},
|
||||||
Lines: lines,
|
Lines: lines,
|
||||||
Applied: applied,
|
Applied: applied,
|
||||||
FxUsed: fx,
|
FxUsed: fxResult,
|
||||||
}
|
}
|
||||||
|
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFeesRequest) (resp *feesv1.PrecomputeFeesResponse, err error) {
|
func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFeesRequest) (resp *feesv1.PrecomputeFeesResponse, err error) {
|
||||||
start := s.clock.Now()
|
var (
|
||||||
trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED
|
meta *feesv1.RequestMeta
|
||||||
if req != nil && req.GetIntent() != nil {
|
intent *feesv1.Intent
|
||||||
trigger = req.GetIntent().GetTrigger()
|
)
|
||||||
|
if req != nil {
|
||||||
|
meta = req.GetMeta()
|
||||||
|
intent = req.GetIntent()
|
||||||
}
|
}
|
||||||
var fxUsed bool
|
|
||||||
defer func() {
|
logger := s.logger.With(requestLogFields(meta, intent)...)
|
||||||
statusLabel := statusFromError(err)
|
|
||||||
if err == nil && resp != nil {
|
start := s.clock.Now()
|
||||||
fxUsed = resp.GetFxUsed() != nil
|
|
||||||
}
|
trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED
|
||||||
observeMetrics("precompute", trigger, statusLabel, fxUsed, time.Since(start))
|
if intent != nil {
|
||||||
}()
|
trigger = intent.GetTrigger()
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { s.observePrecomputeFees(logger, err, resp, trigger, start) }()
|
||||||
|
|
||||||
|
logger.Debug("PrecomputeFees request received")
|
||||||
|
|
||||||
if err = s.validatePrecomputeRequest(req); err != nil {
|
if err = s.validatePrecomputeRequest(req); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -125,15 +204,18 @@ func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFees
|
|||||||
|
|
||||||
now := s.clock.Now()
|
now := s.clock.Now()
|
||||||
|
|
||||||
orgRef, parseErr := primitive.ObjectIDFromHex(req.GetMeta().GetOrganizationRef())
|
orgRef, parseErr := bson.ObjectIDFromHex(req.GetMeta().GetOrganizationRef())
|
||||||
if parseErr != nil {
|
if parseErr != nil {
|
||||||
|
logger.Warn("PrecomputeFees invalid organization_ref", zap.Error(parseErr))
|
||||||
err = status.Error(codes.InvalidArgument, "invalid organization_ref")
|
err = status.Error(codes.InvalidArgument, "invalid organization_ref")
|
||||||
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
if computeErr != nil {
|
||||||
err = computeErr
|
err = computeErr
|
||||||
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,6 +223,7 @@ func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFees
|
|||||||
if ttl <= 0 {
|
if ttl <= 0 {
|
||||||
ttl = 60000
|
ttl = 60000
|
||||||
}
|
}
|
||||||
|
|
||||||
expiresAt := now.Add(time.Duration(ttl) * time.Millisecond)
|
expiresAt := now.Add(time.Duration(ttl) * time.Millisecond)
|
||||||
|
|
||||||
payload := feeQuoteTokenPayload{
|
payload := feeQuoteTokenPayload{
|
||||||
@@ -152,8 +235,9 @@ func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFees
|
|||||||
|
|
||||||
var token string
|
var token string
|
||||||
if token, err = encodeTokenPayload(payload); err != nil {
|
if token, err = encodeTokenPayload(payload); err != nil {
|
||||||
s.logger.Warn("failed to encode fee quote token", zap.Error(err))
|
logger.Warn("Failed to encode fee quote token", zap.Error(err))
|
||||||
err = status.Error(codes.Internal, "failed to encode fee quote token")
|
err = status.Error(codes.Internal, "failed to encode fee quote token")
|
||||||
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,31 +247,34 @@ func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFees
|
|||||||
ExpiresAt: timestamppb.New(expiresAt),
|
ExpiresAt: timestamppb.New(expiresAt),
|
||||||
Lines: lines,
|
Lines: lines,
|
||||||
Applied: applied,
|
Applied: applied,
|
||||||
FxUsed: fx,
|
FxUsed: fxResult,
|
||||||
}
|
}
|
||||||
|
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) ValidateFeeToken(ctx context.Context, req *feesv1.ValidateFeeTokenRequest) (resp *feesv1.ValidateFeeTokenResponse, err error) {
|
func (s *Service) ValidateFeeToken(ctx context.Context, req *feesv1.ValidateFeeTokenRequest) (resp *feesv1.ValidateFeeTokenResponse, err error) {
|
||||||
|
tokenLen := 0
|
||||||
|
if req != nil {
|
||||||
|
tokenLen = len(strings.TrimSpace(req.GetFeeQuoteToken()))
|
||||||
|
}
|
||||||
|
|
||||||
|
logger := s.logger.With(zap.Int("token_length", tokenLen))
|
||||||
|
|
||||||
start := s.clock.Now()
|
start := s.clock.Now()
|
||||||
|
|
||||||
trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED
|
trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED
|
||||||
var fxUsed bool
|
|
||||||
defer func() {
|
var resultReason string
|
||||||
statusLabel := statusFromError(err)
|
|
||||||
if err == nil && resp != nil {
|
defer func() { s.observeValidateFeeToken(logger, err, resp, trigger, resultReason, start) }()
|
||||||
if !resp.GetValid() {
|
|
||||||
statusLabel = "invalid"
|
logger.Debug("ValidateFeeToken request received")
|
||||||
}
|
|
||||||
fxUsed = resp.GetFxUsed() != nil
|
|
||||||
if resp.GetIntent() != nil {
|
|
||||||
trigger = resp.GetIntent().GetTrigger()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
observeMetrics("validate", trigger, statusLabel, fxUsed, time.Since(start))
|
|
||||||
}()
|
|
||||||
|
|
||||||
if req == nil || strings.TrimSpace(req.GetFeeQuoteToken()) == "" {
|
if req == nil || strings.TrimSpace(req.GetFeeQuoteToken()) == "" {
|
||||||
|
resultReason = "missing_token"
|
||||||
err = status.Error(codes.InvalidArgument, "fee_quote_token is required")
|
err = status.Error(codes.InvalidArgument, "fee_quote_token is required")
|
||||||
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,28 +282,46 @@ func (s *Service) ValidateFeeToken(ctx context.Context, req *feesv1.ValidateFeeT
|
|||||||
|
|
||||||
payload, decodeErr := decodeTokenPayload(req.GetFeeQuoteToken())
|
payload, decodeErr := decodeTokenPayload(req.GetFeeQuoteToken())
|
||||||
if decodeErr != nil {
|
if decodeErr != nil {
|
||||||
s.logger.Warn("failed to decode fee quote token", zap.Error(decodeErr))
|
resultReason = "invalid_token"
|
||||||
|
|
||||||
|
logger.Warn("Failed to decode fee quote token", zap.Error(decodeErr))
|
||||||
|
|
||||||
resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "invalid_token"}
|
resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "invalid_token"}
|
||||||
|
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
trigger = payload.Intent.GetTrigger()
|
logger = logger.With(logFieldsFromTokenPayload(&payload)...)
|
||||||
|
|
||||||
|
if payload.Intent != nil {
|
||||||
|
trigger = payload.Intent.GetTrigger()
|
||||||
|
}
|
||||||
|
|
||||||
if now.UnixMilli() > payload.ExpiresAtUnixMs {
|
if now.UnixMilli() > payload.ExpiresAtUnixMs {
|
||||||
|
resultReason = "expired"
|
||||||
|
|
||||||
|
logger.Info("Fee quote token expired")
|
||||||
|
|
||||||
resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "expired"}
|
resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "expired"}
|
||||||
|
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
orgRef, parseErr := primitive.ObjectIDFromHex(payload.OrganizationRef)
|
orgRef, parseErr := bson.ObjectIDFromHex(payload.OrganizationRef)
|
||||||
if parseErr != nil {
|
if parseErr != nil {
|
||||||
s.logger.Warn("token contained invalid organization reference", zap.Error(parseErr))
|
resultReason = "invalid_token"
|
||||||
|
|
||||||
|
logger.Warn("Token contained invalid organization reference", zap.Error(parseErr))
|
||||||
|
|
||||||
resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "invalid_token"}
|
resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "invalid_token"}
|
||||||
|
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
if computeErr != nil {
|
||||||
err = computeErr
|
err = computeErr
|
||||||
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,8 +331,9 @@ func (s *Service) ValidateFeeToken(ctx context.Context, req *feesv1.ValidateFeeT
|
|||||||
Intent: payload.Intent,
|
Intent: payload.Intent,
|
||||||
Lines: lines,
|
Lines: lines,
|
||||||
Applied: applied,
|
Applied: applied,
|
||||||
FxUsed: fx,
|
FxUsed: fxResult,
|
||||||
}
|
}
|
||||||
|
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,24 +341,31 @@ func (s *Service) validateQuoteRequest(req *feesv1.QuoteFeesRequest) error {
|
|||||||
if req == nil {
|
if req == nil {
|
||||||
return status.Error(codes.InvalidArgument, "request is required")
|
return status.Error(codes.InvalidArgument, "request is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.GetMeta() == nil || strings.TrimSpace(req.GetMeta().GetOrganizationRef()) == "" {
|
if req.GetMeta() == nil || strings.TrimSpace(req.GetMeta().GetOrganizationRef()) == "" {
|
||||||
return status.Error(codes.InvalidArgument, "meta.organization_ref is required")
|
return status.Error(codes.InvalidArgument, "meta.organization_ref is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.GetIntent() == nil {
|
if req.GetIntent() == nil {
|
||||||
return status.Error(codes.InvalidArgument, "intent is required")
|
return status.Error(codes.InvalidArgument, "intent is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.GetIntent().GetTrigger() == feesv1.Trigger_TRIGGER_UNSPECIFIED {
|
if req.GetIntent().GetTrigger() == feesv1.Trigger_TRIGGER_UNSPECIFIED {
|
||||||
return status.Error(codes.InvalidArgument, "intent.trigger is required")
|
return status.Error(codes.InvalidArgument, "intent.trigger is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.GetIntent().GetBaseAmount() == nil {
|
if req.GetIntent().GetBaseAmount() == nil {
|
||||||
return status.Error(codes.InvalidArgument, "intent.base_amount is required")
|
return status.Error(codes.InvalidArgument, "intent.base_amount is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.TrimSpace(req.GetIntent().GetBaseAmount().GetAmount()) == "" {
|
if strings.TrimSpace(req.GetIntent().GetBaseAmount().GetAmount()) == "" {
|
||||||
return status.Error(codes.InvalidArgument, "intent.base_amount.amount is required")
|
return status.Error(codes.InvalidArgument, "intent.base_amount.amount is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.TrimSpace(req.GetIntent().GetBaseAmount().GetCurrency()) == "" {
|
if strings.TrimSpace(req.GetIntent().GetBaseAmount().GetCurrency()) == "" {
|
||||||
return status.Error(codes.InvalidArgument, "intent.base_amount.currency is required")
|
return status.Error(codes.InvalidArgument, "intent.base_amount.currency is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,40 +373,160 @@ func (s *Service) validatePrecomputeRequest(req *feesv1.PrecomputeFeesRequest) e
|
|||||||
if req == nil {
|
if req == nil {
|
||||||
return status.Error(codes.InvalidArgument, "request is required")
|
return status.Error(codes.InvalidArgument, "request is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.validateQuoteRequest(&feesv1.QuoteFeesRequest{Meta: req.GetMeta(), Intent: req.GetIntent()})
|
return s.validateQuoteRequest(&feesv1.QuoteFeesRequest{Meta: req.GetMeta(), Intent: req.GetIntent()})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) computeQuote(ctx context.Context, orgRef primitive.ObjectID, intent *feesv1.Intent, overrides *feesv1.PolicyOverrides, trace *tracev1.TraceContext) ([]*feesv1.DerivedPostingLine, []*feesv1.AppliedRule, *feesv1.FXUsed, error) {
|
func (s *Service) computeQuote(ctx context.Context, orgRef bson.ObjectID, intent *feesv1.Intent, overrides *feesv1.PolicyOverrides, trace *tracev1.TraceContext) ([]*feesv1.DerivedPostingLine, []*feesv1.AppliedRule, *feesv1.FXUsed, error) {
|
||||||
return s.computeQuoteWithTime(ctx, orgRef, intent, overrides, trace, s.clock.Now())
|
return s.computeQuoteWithTime(ctx, orgRef, intent, overrides, trace, s.clock.Now())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) computeQuoteWithTime(ctx context.Context, orgRef primitive.ObjectID, intent *feesv1.Intent, overrides *feesv1.PolicyOverrides, trace *tracev1.TraceContext, now time.Time) ([]*feesv1.DerivedPostingLine, []*feesv1.AppliedRule, *feesv1.FXUsed, error) {
|
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
|
bookedAt := resolvedBookedAt(intent, now)
|
||||||
if intent.GetBookedAt() != nil && intent.GetBookedAt().IsValid() {
|
|
||||||
bookedAt = intent.GetBookedAt().AsTime()
|
logFields := []zap.Field{zap.Time("booked_at_used", bookedAt)}
|
||||||
|
if !orgRef.IsZero() {
|
||||||
|
logFields = append(logFields, mzap.ObjRef("organization_ref", orgRef))
|
||||||
}
|
}
|
||||||
|
|
||||||
plan, err := s.storage.Plans().GetActivePlan(ctx, orgRef, bookedAt)
|
logFields = append(logFields, logFieldsFromIntent(intent)...)
|
||||||
if err != nil {
|
logFields = append(logFields, logFieldsFromTrace(trace)...)
|
||||||
if errors.Is(err, storage.ErrFeePlanNotFound) {
|
logger := s.logger.With(logFields...)
|
||||||
return nil, nil, nil, status.Error(codes.NotFound, "fee plan not found")
|
|
||||||
}
|
var orgPtr *bson.ObjectID
|
||||||
s.logger.Warn("failed to load active fee plan", zap.Error(err))
|
if !orgRef.IsZero() {
|
||||||
return nil, nil, nil, status.Error(codes.Internal, "failed to load fee plan")
|
orgPtr = &orgRef
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
|
||||||
|
return nil, nil, nil, mapResolveError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
originalRules := plan.Rules
|
||||||
|
plan.Rules = []model.FeeRule{*rule}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
plan.Rules = originalRules
|
||||||
|
}()
|
||||||
|
|
||||||
result, calcErr := s.calculator.Compute(ctx, plan, intent, bookedAt, trace)
|
result, calcErr := s.calculator.Compute(ctx, plan, intent, bookedAt, trace)
|
||||||
if calcErr != nil {
|
if calcErr != nil {
|
||||||
if errors.Is(calcErr, merrors.ErrInvalidArg) {
|
if errors.Is(calcErr, merrors.ErrInvalidArg) {
|
||||||
return nil, nil, nil, status.Error(codes.InvalidArgument, calcErr.Error())
|
return nil, nil, nil, status.Error(codes.InvalidArgument, calcErr.Error())
|
||||||
}
|
}
|
||||||
s.logger.Warn("failed to compute fee quote", zap.Error(calcErr))
|
|
||||||
|
logger.Warn("Failed to compute fee quote", zap.Error(calcErr))
|
||||||
|
|
||||||
return nil, nil, nil, status.Error(codes.Internal, "failed to compute fee quote")
|
return nil, nil, nil, status.Error(codes.Internal, "failed to compute fee quote")
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.Lines, result.Applied, result.FxUsed, nil
|
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 {
|
type feeQuoteTokenPayload struct {
|
||||||
OrganizationRef string `json:"organization_ref"`
|
OrganizationRef string `json:"organization_ref"`
|
||||||
Intent *feesv1.Intent `json:"intent"`
|
Intent *feesv1.Intent `json:"intent"`
|
||||||
@@ -306,17 +539,36 @@ func encodeTokenPayload(payload feeQuoteTokenPayload) (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", merrors.Internal("fees: failed to serialize token payload")
|
return "", merrors.Internal("fees: failed to serialize token payload")
|
||||||
}
|
}
|
||||||
|
|
||||||
return base64.StdEncoding.EncodeToString(data), nil
|
return base64.StdEncoding.EncodeToString(data), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func decodeTokenPayload(token string) (feeQuoteTokenPayload, error) {
|
func decodeTokenPayload(token string) (feeQuoteTokenPayload, error) {
|
||||||
var payload feeQuoteTokenPayload
|
var payload feeQuoteTokenPayload
|
||||||
|
|
||||||
data, err := base64.StdEncoding.DecodeString(token)
|
data, err := base64.StdEncoding.DecodeString(token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return payload, merrors.InvalidArgument("fees: invalid token encoding")
|
return payload, merrors.InvalidArgument("fees: invalid token encoding")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := json.Unmarshal(data, &payload); err != nil {
|
if err := json.Unmarshal(data, &payload); err != nil {
|
||||||
return payload, merrors.InvalidArgument("fees: invalid token payload")
|
return payload, merrors.InvalidArgument("fees: invalid token payload")
|
||||||
}
|
}
|
||||||
|
|
||||||
return payload, nil
|
return payload, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) startDiscoveryAnnouncer() {
|
||||||
|
if s == nil || s.producer == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
announce := discovery.Announcement{
|
||||||
|
Service: mservice.BillingFees,
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ package fees
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/billing/fees/internal/service/fees/types"
|
||||||
"github.com/tech/sendico/billing/fees/storage"
|
"github.com/tech/sendico/billing/fees/storage"
|
||||||
"github.com/tech/sendico/billing/fees/storage/model"
|
"github.com/tech/sendico/billing/fees/storage/model"
|
||||||
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
||||||
@@ -12,16 +14,16 @@ import (
|
|||||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
|
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
|
||||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestQuoteFees_ComputesDerivedLines(t *testing.T) {
|
func TestQuoteFees_ComputesDerivedLines(t *testing.T) {
|
||||||
t.Helper()
|
t.Parallel()
|
||||||
|
|
||||||
now := time.Date(2024, 1, 10, 16, 0, 0, 0, time.UTC)
|
now := time.Date(2024, 1, 10, 16, 0, 0, 0, time.UTC)
|
||||||
orgRef := primitive.NewObjectID()
|
orgRef := bson.NewObjectID()
|
||||||
|
|
||||||
plan := &model.FeePlan{
|
plan := &model.FeePlan{
|
||||||
Active: true,
|
Active: true,
|
||||||
@@ -46,8 +48,8 @@ func TestQuoteFees_ComputesDerivedLines(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
plan.SetID(primitive.NewObjectID())
|
plan.SetID(bson.NewObjectID())
|
||||||
plan.SetOrganizationRef(orgRef)
|
plan.OrganizationRef = &orgRef
|
||||||
|
|
||||||
service := NewService(
|
service := NewService(
|
||||||
zap.NewNop(),
|
zap.NewNop(),
|
||||||
@@ -91,9 +93,11 @@ func TestQuoteFees_ComputesDerivedLines(t *testing.T) {
|
|||||||
if got := line.GetMoney().GetAmount(); got != "3.20" {
|
if got := line.GetMoney().GetAmount(); got != "3.20" {
|
||||||
t.Fatalf("expected fee amount 3.20, got %s", got)
|
t.Fatalf("expected fee amount 3.20, got %s", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
if line.GetMoney().GetCurrency() != "USD" {
|
if line.GetMoney().GetCurrency() != "USD" {
|
||||||
t.Fatalf("expected currency USD, got %s", line.GetMoney().GetCurrency())
|
t.Fatalf("expected currency USD, got %s", line.GetMoney().GetCurrency())
|
||||||
}
|
}
|
||||||
|
|
||||||
if line.GetLedgerAccountRef() != "acct:fees" {
|
if line.GetLedgerAccountRef() != "acct:fees" {
|
||||||
t.Fatalf("unexpected ledger account ref %s", line.GetLedgerAccountRef())
|
t.Fatalf("unexpected ledger account ref %s", line.GetLedgerAccountRef())
|
||||||
}
|
}
|
||||||
@@ -109,19 +113,21 @@ func TestQuoteFees_ComputesDerivedLines(t *testing.T) {
|
|||||||
if applied.GetTaxCode() != "VAT" || applied.GetTaxRate() != "0.20" {
|
if applied.GetTaxCode() != "VAT" || applied.GetTaxRate() != "0.20" {
|
||||||
t.Fatalf("applied rule metadata mismatch: %+v", applied)
|
t.Fatalf("applied rule metadata mismatch: %+v", applied)
|
||||||
}
|
}
|
||||||
|
|
||||||
if applied.GetRounding() != moneyv1.RoundingMode_ROUND_HALF_UP {
|
if applied.GetRounding() != moneyv1.RoundingMode_ROUND_HALF_UP {
|
||||||
t.Fatalf("expected rounding HALF_UP, got %v", applied.GetRounding())
|
t.Fatalf("expected rounding HALF_UP, got %v", applied.GetRounding())
|
||||||
}
|
}
|
||||||
|
|
||||||
if applied.GetParameters()["scale"] != "2" {
|
if applied.GetParameters()["scale"] != "2" {
|
||||||
t.Fatalf("expected parameters to carry metadata scale, got %+v", applied.GetParameters())
|
t.Fatalf("expected parameters to carry metadata scale, got %+v", applied.GetParameters())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestQuoteFees_FiltersByAttributesAndDates(t *testing.T) {
|
func TestQuoteFees_FiltersByAttributesAndDates(t *testing.T) {
|
||||||
t.Helper()
|
t.Parallel()
|
||||||
|
|
||||||
now := time.Date(2024, 5, 20, 9, 30, 0, 0, time.UTC)
|
now := time.Date(2024, 5, 20, 9, 30, 0, 0, time.UTC)
|
||||||
orgRef := primitive.NewObjectID()
|
orgRef := bson.NewObjectID()
|
||||||
|
|
||||||
plan := &model.FeePlan{
|
plan := &model.FeePlan{
|
||||||
Active: true,
|
Active: true,
|
||||||
@@ -160,8 +166,8 @@ func TestQuoteFees_FiltersByAttributesAndDates(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
plan.SetID(primitive.NewObjectID())
|
plan.SetID(bson.NewObjectID())
|
||||||
plan.SetOrganizationRef(orgRef)
|
plan.OrganizationRef = &orgRef
|
||||||
|
|
||||||
service := NewService(
|
service := NewService(
|
||||||
zap.NewNop(),
|
zap.NewNop(),
|
||||||
@@ -187,23 +193,26 @@ func TestQuoteFees_FiltersByAttributesAndDates(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("QuoteFees returned error: %v", err)
|
t.Fatalf("QuoteFees returned error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(resp.GetLines()) != 1 {
|
if len(resp.GetLines()) != 1 {
|
||||||
t.Fatalf("expected only base rule to fire, got %d lines", len(resp.GetLines()))
|
t.Fatalf("expected only base rule to fire, got %d lines", len(resp.GetLines()))
|
||||||
}
|
}
|
||||||
|
|
||||||
line := resp.GetLines()[0]
|
line := resp.GetLines()[0]
|
||||||
if line.GetLedgerAccountRef() != "acct:base" {
|
if line.GetLedgerAccountRef() != "acct:base" {
|
||||||
t.Fatalf("expected base rule to apply, got %s", line.GetLedgerAccountRef())
|
t.Fatalf("expected base rule to apply, got %s", line.GetLedgerAccountRef())
|
||||||
}
|
}
|
||||||
|
|
||||||
if line.GetMoney().GetAmount() != "5.00" {
|
if line.GetMoney().GetAmount() != "5.00" {
|
||||||
t.Fatalf("expected 5.00 amount, got %s", line.GetMoney().GetAmount())
|
t.Fatalf("expected 5.00 amount, got %s", line.GetMoney().GetAmount())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestQuoteFees_RoundingDown(t *testing.T) {
|
func TestQuoteFees_RoundingDown(t *testing.T) {
|
||||||
t.Helper()
|
t.Parallel()
|
||||||
|
|
||||||
now := time.Date(2024, 3, 15, 12, 0, 0, 0, time.UTC)
|
now := time.Date(2024, 3, 15, 12, 0, 0, 0, time.UTC)
|
||||||
orgRef := primitive.NewObjectID()
|
orgRef := bson.NewObjectID()
|
||||||
|
|
||||||
plan := &model.FeePlan{
|
plan := &model.FeePlan{
|
||||||
Active: true,
|
Active: true,
|
||||||
@@ -221,8 +230,8 @@ func TestQuoteFees_RoundingDown(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
plan.SetID(primitive.NewObjectID())
|
plan.SetID(bson.NewObjectID())
|
||||||
plan.SetOrganizationRef(orgRef)
|
plan.OrganizationRef = &orgRef
|
||||||
|
|
||||||
service := NewService(
|
service := NewService(
|
||||||
zap.NewNop(),
|
zap.NewNop(),
|
||||||
@@ -247,27 +256,39 @@ func TestQuoteFees_RoundingDown(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("QuoteFees returned error: %v", err)
|
t.Fatalf("QuoteFees returned error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(resp.GetLines()) != 1 {
|
if len(resp.GetLines()) != 1 {
|
||||||
t.Fatalf("expected single derived line, got %d", len(resp.GetLines()))
|
t.Fatalf("expected single derived line, got %d", len(resp.GetLines()))
|
||||||
}
|
}
|
||||||
|
|
||||||
if resp.GetLines()[0].GetMoney().GetAmount() != "0.01" {
|
if resp.GetLines()[0].GetMoney().GetAmount() != "0.01" {
|
||||||
t.Fatalf("expected rounding down to 0.01, got %s", resp.GetLines()[0].GetMoney().GetAmount())
|
t.Fatalf("expected rounding down to 0.01, got %s", resp.GetLines()[0].GetMoney().GetAmount())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestQuoteFees_UsesInjectedCalculator(t *testing.T) {
|
func TestQuoteFees_UsesInjectedCalculator(t *testing.T) {
|
||||||
t.Helper()
|
t.Parallel()
|
||||||
|
|
||||||
now := time.Date(2024, 6, 1, 8, 0, 0, 0, time.UTC)
|
now := time.Date(2024, 6, 1, 8, 0, 0, 0, time.UTC)
|
||||||
orgRef := primitive.NewObjectID()
|
orgRef := bson.NewObjectID()
|
||||||
plan := &model.FeePlan{
|
plan := &model.FeePlan{
|
||||||
Active: true,
|
Active: true,
|
||||||
EffectiveFrom: now.Add(-time.Hour),
|
EffectiveFrom: now.Add(-time.Hour),
|
||||||
|
Rules: []model.FeeRule{
|
||||||
|
{
|
||||||
|
RuleID: "stub",
|
||||||
|
Trigger: model.TriggerCapture,
|
||||||
|
Priority: 1,
|
||||||
|
Percentage: "0.01",
|
||||||
|
LedgerAccountRef: "acct:stub",
|
||||||
|
EffectiveFrom: now.Add(-time.Hour),
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
plan.SetID(primitive.NewObjectID())
|
plan.SetID(bson.NewObjectID())
|
||||||
plan.SetOrganizationRef(orgRef)
|
plan.OrganizationRef = &orgRef
|
||||||
|
|
||||||
result := &CalculationResult{
|
result := &types.CalculationResult{
|
||||||
Lines: []*feesv1.DerivedPostingLine{
|
Lines: []*feesv1.DerivedPostingLine{
|
||||||
{
|
{
|
||||||
LedgerAccountRef: "acct:stub",
|
LedgerAccountRef: "acct:stub",
|
||||||
@@ -304,25 +325,29 @@ func TestQuoteFees_UsesInjectedCalculator(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("QuoteFees returned error: %v", err)
|
t.Fatalf("QuoteFees returned error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !calc.called {
|
if !calc.called {
|
||||||
t.Fatalf("expected calculator to be invoked")
|
t.Fatalf("expected calculator to be invoked")
|
||||||
}
|
}
|
||||||
|
|
||||||
if calc.gotPlan != plan {
|
if calc.gotPlan != plan {
|
||||||
t.Fatalf("expected calculator to receive plan pointer")
|
t.Fatalf("expected calculator to receive plan pointer")
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(resp.GetLines()) != len(result.Lines) {
|
if len(resp.GetLines()) != len(result.Lines) {
|
||||||
t.Fatalf("expected %d lines, got %d", len(result.Lines), len(resp.GetLines()))
|
t.Fatalf("expected %d lines, got %d", len(result.Lines), len(resp.GetLines()))
|
||||||
}
|
}
|
||||||
|
|
||||||
if resp.GetLines()[0].GetLedgerAccountRef() != "acct:stub" {
|
if resp.GetLines()[0].GetLedgerAccountRef() != "acct:stub" {
|
||||||
t.Fatalf("unexpected ledger account in response: %s", resp.GetLines()[0].GetLedgerAccountRef())
|
t.Fatalf("unexpected ledger account in response: %s", resp.GetLines()[0].GetLedgerAccountRef())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestQuoteFees_PopulatesFxUsed(t *testing.T) {
|
func TestQuoteFees_PopulatesFxUsed(t *testing.T) {
|
||||||
t.Helper()
|
t.Parallel()
|
||||||
|
|
||||||
now := time.Date(2024, 7, 1, 9, 30, 0, 0, time.UTC)
|
now := time.Date(2024, 7, 1, 9, 30, 0, 0, time.UTC)
|
||||||
orgRef := primitive.NewObjectID()
|
orgRef := bson.NewObjectID()
|
||||||
|
|
||||||
plan := &model.FeePlan{
|
plan := &model.FeePlan{
|
||||||
Active: true,
|
Active: true,
|
||||||
@@ -340,11 +365,11 @@ func TestQuoteFees_PopulatesFxUsed(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
plan.SetID(primitive.NewObjectID())
|
plan.SetID(bson.NewObjectID())
|
||||||
plan.SetOrganizationRef(orgRef)
|
plan.OrganizationRef = &orgRef
|
||||||
|
|
||||||
fakeOracle := &oracleclient.Fake{
|
fakeOracle := &oracleclient.Fake{
|
||||||
LatestRateFn: func(ctx context.Context, req oracleclient.LatestRateParams) (*oracleclient.RateSnapshot, error) {
|
LatestRateFn: func(_ context.Context, req oracleclient.LatestRateParams) (*oracleclient.RateSnapshot, error) {
|
||||||
return &oracleclient.RateSnapshot{
|
return &oracleclient.RateSnapshot{
|
||||||
Pair: req.Pair,
|
Pair: req.Pair,
|
||||||
Mid: "1.2300",
|
Mid: "1.2300",
|
||||||
@@ -387,10 +412,12 @@ func TestQuoteFees_PopulatesFxUsed(t *testing.T) {
|
|||||||
if resp.GetFxUsed() == nil {
|
if resp.GetFxUsed() == nil {
|
||||||
t.Fatalf("expected FxUsed to be populated")
|
t.Fatalf("expected FxUsed to be populated")
|
||||||
}
|
}
|
||||||
|
|
||||||
fx := resp.GetFxUsed()
|
fx := resp.GetFxUsed()
|
||||||
if fx.GetProvider() != "TestProvider" || fx.GetRate().GetValue() != "1.2300" {
|
if fx.GetProvider() != "TestProvider" || fx.GetRate().GetValue() != "1.2300" {
|
||||||
t.Fatalf("unexpected FxUsed payload: %+v", fx)
|
t.Fatalf("unexpected FxUsed payload: %+v", fx)
|
||||||
}
|
}
|
||||||
|
|
||||||
if fx.GetPair().GetBase() != "USD" || fx.GetPair().GetQuote() != "EUR" {
|
if fx.GetPair().GetBase() != "USD" || fx.GetPair().GetQuote() != "EUR" {
|
||||||
t.Fatalf("unexpected currency pair: %+v", fx.GetPair())
|
t.Fatalf("unexpected currency pair: %+v", fx.GetPair())
|
||||||
}
|
}
|
||||||
@@ -409,7 +436,8 @@ func (s *stubRepository) Plans() storage.PlansStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type stubPlansStore struct {
|
type stubPlansStore struct {
|
||||||
plan *model.FeePlan
|
plan *model.FeePlan
|
||||||
|
globalPlan *model.FeePlan
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *stubPlansStore) Create(context.Context, *model.FeePlan) error {
|
func (s *stubPlansStore) Create(context.Context, *model.FeePlan) error {
|
||||||
@@ -420,29 +448,66 @@ func (s *stubPlansStore) Update(context.Context, *model.FeePlan) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *stubPlansStore) Get(context.Context, primitive.ObjectID) (*model.FeePlan, error) {
|
func (s *stubPlansStore) Get(context.Context, bson.ObjectID) (*model.FeePlan, error) {
|
||||||
return nil, storage.ErrFeePlanNotFound
|
return nil, storage.ErrFeePlanNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *stubPlansStore) GetActivePlan(_ context.Context, orgRef primitive.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(ctx, orgRef, asOf); err == nil {
|
||||||
|
return plan, nil
|
||||||
|
} else if !errors.Is(err, storage.ErrFeePlanNotFound) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.FindActiveGlobalPlan(ctx, asOf)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubPlansStore) FindActiveOrgPlan(_ context.Context, orgRef bson.ObjectID, asOf time.Time) (*model.FeePlan, error) {
|
||||||
if s.plan == nil {
|
if s.plan == nil {
|
||||||
return nil, storage.ErrFeePlanNotFound
|
return nil, storage.ErrFeePlanNotFound
|
||||||
}
|
}
|
||||||
if s.plan.GetOrganizationRef() != orgRef {
|
|
||||||
|
if (s.plan.OrganizationRef != nil) && (*s.plan.OrganizationRef != orgRef) {
|
||||||
return nil, storage.ErrFeePlanNotFound
|
return nil, storage.ErrFeePlanNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
if !s.plan.Active {
|
if !s.plan.Active {
|
||||||
return nil, storage.ErrFeePlanNotFound
|
return nil, storage.ErrFeePlanNotFound
|
||||||
}
|
}
|
||||||
if !s.plan.EffectiveFrom.Before(at) && !s.plan.EffectiveFrom.Equal(at) {
|
|
||||||
|
if s.plan.EffectiveFrom.After(asOf) {
|
||||||
return nil, storage.ErrFeePlanNotFound
|
return nil, storage.ErrFeePlanNotFound
|
||||||
}
|
}
|
||||||
if s.plan.EffectiveTo != nil && s.plan.EffectiveTo.Before(at) {
|
|
||||||
|
if s.plan.EffectiveTo != nil && !s.plan.EffectiveTo.After(asOf) {
|
||||||
return nil, storage.ErrFeePlanNotFound
|
return nil, storage.ErrFeePlanNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.plan, nil
|
return s.plan, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(asOf) {
|
||||||
|
return nil, storage.ErrFeePlanNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.globalPlan.EffectiveTo != nil && !s.globalPlan.EffectiveTo.After(asOf) {
|
||||||
|
return nil, storage.ErrFeePlanNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.globalPlan, nil
|
||||||
|
}
|
||||||
|
|
||||||
type noopProducer struct{}
|
type noopProducer struct{}
|
||||||
|
|
||||||
func (noopProducer) SendMessage(me.Envelope) error {
|
func (noopProducer) SendMessage(me.Envelope) error {
|
||||||
@@ -458,19 +523,21 @@ func (f fixedClock) Now() time.Time {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type stubCalculator struct {
|
type stubCalculator struct {
|
||||||
result *CalculationResult
|
result *types.CalculationResult
|
||||||
err error
|
err error
|
||||||
called bool
|
called bool
|
||||||
gotPlan *model.FeePlan
|
gotPlan *model.FeePlan
|
||||||
bookedAt time.Time
|
bookedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *stubCalculator) Compute(_ context.Context, plan *model.FeePlan, _ *feesv1.Intent, bookedAt time.Time, _ *tracev1.TraceContext) (*CalculationResult, error) {
|
func (s *stubCalculator) Compute(_ context.Context, plan *model.FeePlan, _ *feesv1.Intent, bookedAt time.Time, _ *tracev1.TraceContext) (*types.CalculationResult, error) {
|
||||||
s.called = true
|
s.called = true
|
||||||
s.gotPlan = plan
|
s.gotPlan = plan
|
||||||
s.bookedAt = bookedAt
|
s.bookedAt = bookedAt
|
||||||
|
|
||||||
if s.err != nil {
|
if s.err != nil {
|
||||||
return nil, s.err
|
return nil, s.err
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.result, nil
|
return s.result, nil
|
||||||
}
|
}
|
||||||
|
|||||||
25
api/billing/fees/internal/service/fees/trigger.go
Normal file
25
api/billing/fees/internal/service/fees/trigger.go
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
package fees
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/tech/sendico/billing/fees/storage/model"
|
||||||
|
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
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:
|
||||||
|
return model.TriggerRefund
|
||||||
|
case feesv1.Trigger_TRIGGER_DISPUTE:
|
||||||
|
return model.TriggerDispute
|
||||||
|
case feesv1.Trigger_TRIGGER_PAYOUT:
|
||||||
|
return model.TriggerPayout
|
||||||
|
case feesv1.Trigger_TRIGGER_FX_CONVERSION:
|
||||||
|
return model.TriggerFXConversion
|
||||||
|
default:
|
||||||
|
return model.TriggerUnspecified
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package types
|
||||||
|
|
||||||
|
import (
|
||||||
|
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CalculationResult contains derived fee lines and audit metadata.
|
||||||
|
type CalculationResult struct {
|
||||||
|
Lines []*feesv1.DerivedPostingLine
|
||||||
|
Applied []*feesv1.AppliedRule
|
||||||
|
FxUsed *feesv1.FXUsed
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
|
|
||||||
"github.com/tech/sendico/pkg/db/storable"
|
"github.com/tech/sendico/pkg/db/storable"
|
||||||
"github.com/tech/sendico/pkg/model"
|
"github.com/tech/sendico/pkg/model"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -25,14 +26,15 @@ const (
|
|||||||
|
|
||||||
// FeePlan describes a collection of fee rules for an organisation.
|
// FeePlan describes a collection of fee rules for an organisation.
|
||||||
type FeePlan struct {
|
type FeePlan struct {
|
||||||
storable.Base `bson:",inline" json:",inline"`
|
storable.Base `bson:",inline" json:",inline"`
|
||||||
model.OrganizationBoundBase `bson:",inline" json:",inline"`
|
model.Describable `bson:",inline" json:",inline"`
|
||||||
model.Describable `bson:",inline" json:",inline"`
|
|
||||||
Active bool `bson:"active" json:"active"`
|
OrganizationRef *bson.ObjectID `bson:"organizationRef,omitempty" json:"organizationRef,omitempty"`
|
||||||
EffectiveFrom time.Time `bson:"effectiveFrom" json:"effectiveFrom"`
|
Active bool `bson:"active" json:"active"`
|
||||||
EffectiveTo *time.Time `bson:"effectiveTo,omitempty" json:"effectiveTo,omitempty"`
|
EffectiveFrom time.Time `bson:"effectiveFrom" json:"effectiveFrom"`
|
||||||
Rules []FeeRule `bson:"rules,omitempty" json:"rules,omitempty"`
|
EffectiveTo *time.Time `bson:"effectiveTo,omitempty" json:"effectiveTo,omitempty"`
|
||||||
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
|
Rules []FeeRule `bson:"rules,omitempty" json:"rules,omitempty"`
|
||||||
|
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collection implements storable.Storable.
|
// Collection implements storable.Storable.
|
||||||
@@ -42,21 +44,21 @@ func (*FeePlan) Collection() string {
|
|||||||
|
|
||||||
// FeeRule represents a single pricing rule within a plan.
|
// FeeRule represents a single pricing rule within a plan.
|
||||||
type FeeRule struct {
|
type FeeRule struct {
|
||||||
RuleID string `bson:"ruleId" json:"ruleId"`
|
RuleID string `bson:"ruleId" json:"ruleId"`
|
||||||
Trigger Trigger `bson:"trigger" json:"trigger"`
|
Trigger Trigger `bson:"trigger" json:"trigger"`
|
||||||
Priority int `bson:"priority" json:"priority"`
|
Priority int `bson:"priority" json:"priority"`
|
||||||
Percentage string `bson:"percentage,omitempty" json:"percentage,omitempty"`
|
Percentage string `bson:"percentage,omitempty" json:"percentage,omitempty"`
|
||||||
FixedAmount string `bson:"fixedAmount,omitempty" json:"fixedAmount,omitempty"`
|
FixedAmount string `bson:"fixedAmount,omitempty" json:"fixedAmount,omitempty"`
|
||||||
Currency string `bson:"currency,omitempty" json:"currency,omitempty"`
|
Currency string `bson:"currency,omitempty" json:"currency,omitempty"`
|
||||||
MinimumAmount string `bson:"minimumAmount,omitempty" json:"minimumAmount,omitempty"`
|
MinimumAmount string `bson:"minimumAmount,omitempty" json:"minimumAmount,omitempty"`
|
||||||
MaximumAmount string `bson:"maximumAmount,omitempty" json:"maximumAmount,omitempty"`
|
MaximumAmount string `bson:"maximumAmount,omitempty" json:"maximumAmount,omitempty"`
|
||||||
AppliesTo map[string]string `bson:"appliesTo,omitempty" json:"appliesTo,omitempty"`
|
AppliesTo map[string]string `bson:"appliesTo,omitempty" json:"appliesTo,omitempty"`
|
||||||
Formula string `bson:"formula,omitempty" json:"formula,omitempty"`
|
Formula string `bson:"formula,omitempty" json:"formula,omitempty"`
|
||||||
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
|
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
|
||||||
LedgerAccountRef string `bson:"ledgerAccountRef,omitempty" json:"ledgerAccountRef,omitempty"`
|
LedgerAccountRef string `bson:"ledgerAccountRef,omitempty" json:"ledgerAccountRef,omitempty"`
|
||||||
LineType string `bson:"lineType,omitempty" json:"lineType,omitempty"`
|
LineType string `bson:"lineType,omitempty" json:"lineType,omitempty"`
|
||||||
EntrySide string `bson:"entrySide,omitempty" json:"entrySide,omitempty"`
|
EntrySide string `bson:"entrySide,omitempty" json:"entrySide,omitempty"`
|
||||||
Rounding string `bson:"rounding,omitempty" json:"rounding,omitempty"`
|
Rounding string `bson:"rounding,omitempty" json:"rounding,omitempty"`
|
||||||
EffectiveFrom time.Time `bson:"effectiveFrom" json:"effectiveFrom"`
|
EffectiveFrom time.Time `bson:"effectiveFrom" json:"effectiveFrom"`
|
||||||
EffectiveTo *time.Time `bson:"effectiveTo,omitempty" json:"effectiveTo,omitempty"`
|
EffectiveTo *time.Time `bson:"effectiveTo,omitempty" json:"effectiveTo,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"github.com/tech/sendico/pkg/db"
|
"github.com/tech/sendico/pkg/db"
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
"github.com/tech/sendico/pkg/mlogger"
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
"go.mongodb.org/mongo-driver/mongo"
|
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -43,18 +43,22 @@ func New(logger mlogger.Logger, conn *db.MongoConnection) (*Store, error) {
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
if err := result.Ping(ctx); err != nil {
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
plansStore, err := store.NewPlans(result.logger, database)
|
plansStore, err := store.NewPlans(result.logger, database)
|
||||||
if err != nil {
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
result.plans = plansStore
|
result.plans = plansStore
|
||||||
|
|
||||||
result.logger.Info("Billing fees MongoDB storage initialised")
|
result.logger.Info("Billing fees MongoDB storage initialised")
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ package store
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/tech/sendico/billing/fees/storage"
|
"github.com/tech/sendico/billing/fees/storage"
|
||||||
@@ -10,12 +13,13 @@ import (
|
|||||||
"github.com/tech/sendico/pkg/db/repository"
|
"github.com/tech/sendico/pkg/db/repository"
|
||||||
"github.com/tech/sendico/pkg/db/repository/builder"
|
"github.com/tech/sendico/pkg/db/repository/builder"
|
||||||
ri "github.com/tech/sendico/pkg/db/repository/index"
|
ri "github.com/tech/sendico/pkg/db/repository/index"
|
||||||
|
dmath "github.com/tech/sendico/pkg/decimal"
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
"github.com/tech/sendico/pkg/mlogger"
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
m "github.com/tech/sendico/pkg/model"
|
m "github.com/tech/sendico/pkg/model"
|
||||||
"github.com/tech/sendico/pkg/mservice"
|
"github.com/tech/sendico/pkg/mservice"
|
||||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
"go.mongodb.org/mongo-driver/mongo"
|
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -24,6 +28,8 @@ type plansStore struct {
|
|||||||
repo repository.Repository
|
repo repository.Repository
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const maxActivePlanResults = 2
|
||||||
|
|
||||||
// NewPlans constructs a Mongo-backed PlansStore.
|
// NewPlans constructs a Mongo-backed PlansStore.
|
||||||
func NewPlans(logger mlogger.Logger, db *mongo.Database) (storage.PlansStore, error) {
|
func NewPlans(logger mlogger.Logger, db *mongo.Database) (storage.PlansStore, error) {
|
||||||
repo := repository.CreateMongoRepository(db, mservice.FeePlans)
|
repo := repository.CreateMongoRepository(db, mservice.FeePlans)
|
||||||
@@ -36,7 +42,8 @@ func NewPlans(logger mlogger.Logger, db *mongo.Database) (storage.PlansStore, er
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
if err := repo.CreateIndex(orgIndex); err != nil {
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,10 +56,24 @@ func NewPlans(logger mlogger.Logger, db *mongo.Database) (storage.PlansStore, er
|
|||||||
Unique: true,
|
Unique: true,
|
||||||
}
|
}
|
||||||
if err := repo.CreateIndex(uniqueIndex); err != nil {
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Recommended index to speed up active-plan lookups (org/global + active + dates).
|
||||||
|
activeIndex := &ri.Definition{
|
||||||
|
Keys: []ri.Key{
|
||||||
|
{Field: m.OrganizationRefField, Sort: ri.Asc},
|
||||||
|
{Field: "active", Sort: ri.Asc},
|
||||||
|
{Field: "effectiveFrom", Sort: ri.Asc},
|
||||||
|
{Field: "effectiveTo", Sort: ri.Asc},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := repo.CreateIndex(activeIndex); err != nil {
|
||||||
|
logger.Warn("Failed to ensure fee plan active index", zap.Error(err))
|
||||||
|
}
|
||||||
|
|
||||||
return &plansStore{
|
return &plansStore{
|
||||||
logger: logger.Named("plans"),
|
logger: logger.Named("plans"),
|
||||||
repo: repo,
|
repo: repo,
|
||||||
@@ -60,16 +81,24 @@ func NewPlans(logger mlogger.Logger, db *mongo.Database) (storage.PlansStore, er
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *plansStore) Create(ctx context.Context, plan *model.FeePlan) error {
|
func (p *plansStore) Create(ctx context.Context, plan *model.FeePlan) error {
|
||||||
if plan == nil {
|
if err := validatePlan(plan); err != nil {
|
||||||
return merrors.InvalidArgument("plansStore: nil fee plan")
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := p.ensureNoOverlap(ctx, plan); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if err := p.repo.Insert(ctx, plan, nil); err != nil {
|
if err := p.repo.Insert(ctx, plan, nil); err != nil {
|
||||||
if errors.Is(err, merrors.ErrDataConflict) {
|
if errors.Is(err, merrors.ErrDataConflict) {
|
||||||
return storage.ErrDuplicateFeePlan
|
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 err
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,54 +106,105 @@ func (p *plansStore) Update(ctx context.Context, plan *model.FeePlan) error {
|
|||||||
if plan == nil || plan.GetID() == nil || plan.GetID().IsZero() {
|
if plan == nil || plan.GetID() == nil || plan.GetID().IsZero() {
|
||||||
return merrors.InvalidArgument("plansStore: invalid fee plan reference")
|
return merrors.InvalidArgument("plansStore: invalid fee plan reference")
|
||||||
}
|
}
|
||||||
if err := p.repo.Update(ctx, plan); err != nil {
|
|
||||||
p.logger.Warn("failed to update fee plan", zap.Error(err))
|
if err := validatePlan(plan); err != nil {
|
||||||
return err
|
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))
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *plansStore) Get(ctx context.Context, planRef primitive.ObjectID) (*model.FeePlan, error) {
|
func (p *plansStore) Get(ctx context.Context, planRef bson.ObjectID) (*model.FeePlan, error) {
|
||||||
if planRef.IsZero() {
|
if planRef.IsZero() {
|
||||||
return nil, merrors.InvalidArgument("plansStore: zero plan reference")
|
return nil, merrors.InvalidArgument("plansStore: zero plan reference")
|
||||||
}
|
}
|
||||||
|
|
||||||
result := &model.FeePlan{}
|
result := &model.FeePlan{}
|
||||||
if err := p.repo.Get(ctx, planRef, result); err != nil {
|
if err := p.repo.Get(ctx, planRef, result); err != nil {
|
||||||
if errors.Is(err, merrors.ErrNoData) {
|
if errors.Is(err, merrors.ErrNoData) {
|
||||||
return nil, storage.ErrFeePlanNotFound
|
return nil, storage.ErrFeePlanNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *plansStore) GetActivePlan(ctx context.Context, orgRef primitive.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, asOf)
|
||||||
|
}
|
||||||
|
|
||||||
|
plan, err := p.FindActiveOrgPlan(ctx, orgRef, asOf)
|
||||||
|
if err == nil {
|
||||||
|
return plan, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if errors.Is(err, storage.ErrFeePlanNotFound) {
|
||||||
|
return p.FindActiveGlobalPlan(ctx, asOf)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *plansStore) FindActiveOrgPlan(ctx context.Context, orgRef bson.ObjectID, asOf time.Time) (*model.FeePlan, error) {
|
||||||
if orgRef.IsZero() {
|
if orgRef.IsZero() {
|
||||||
return nil, merrors.InvalidArgument("plansStore: zero organization reference")
|
return nil, merrors.InvalidArgument("plansStore: zero organization reference")
|
||||||
}
|
}
|
||||||
|
|
||||||
limit := int64(1)
|
query := repository.Query().Filter(repository.OrgField(), orgRef)
|
||||||
query := repository.Query().
|
|
||||||
Filter(repository.OrgField(), orgRef).
|
return p.findActivePlan(ctx, query, asOf)
|
||||||
|
}
|
||||||
|
|
||||||
|
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, asOf)
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ storage.PlansStore = (*plansStore)(nil)
|
||||||
|
|
||||||
|
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).
|
Filter(repository.Field("active"), true).
|
||||||
Comparison(repository.Field("effectiveFrom"), builder.Lte, at).
|
Comparison(repository.Field("effectiveFrom"), builder.Lte, asOf).
|
||||||
Sort(repository.Field("effectiveFrom"), false).
|
Sort(repository.Field("effectiveFrom"), false).
|
||||||
Limit(&limit)
|
Limit(&limit)
|
||||||
|
|
||||||
query = query.And(
|
query = query.And(
|
||||||
repository.Query().Or(
|
repository.Query().Or(
|
||||||
repository.Query().Filter(repository.Field("effectiveTo"), nil),
|
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 plan *model.FeePlan
|
var plans []*model.FeePlan
|
||||||
|
|
||||||
decoder := func(cursor *mongo.Cursor) error {
|
decoder := func(cursor *mongo.Cursor) error {
|
||||||
target := &model.FeePlan{}
|
target := &model.FeePlan{}
|
||||||
if err := cursor.Decode(target); err != nil {
|
if err := cursor.Decode(target); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
plan = target
|
|
||||||
|
plans = append(plans, target)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,13 +212,204 @@ func (p *plansStore) GetActivePlan(ctx context.Context, orgRef primitive.ObjectI
|
|||||||
if errors.Is(err, merrors.ErrNoData) {
|
if errors.Is(err, merrors.ErrNoData) {
|
||||||
return nil, storage.ErrFeePlanNotFound
|
return nil, storage.ErrFeePlanNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if plan == nil {
|
if len(plans) == 0 {
|
||||||
return nil, storage.ErrFeePlanNotFound
|
return nil, storage.ErrFeePlanNotFound
|
||||||
}
|
}
|
||||||
return plan, nil
|
|
||||||
|
if len(plans) > 1 {
|
||||||
|
return nil, storage.ErrConflictingFeePlans
|
||||||
|
}
|
||||||
|
|
||||||
|
return plans[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ storage.PlansStore = (*plansStore)(nil)
|
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 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
|
||||||
|
}
|
||||||
|
|
||||||
|
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, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeAppliesToValue(value string) string {
|
||||||
|
trimmed := strings.TrimSpace(value)
|
||||||
|
if trimmed == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
values := strings.Split(trimmed, ",")
|
||||||
|
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, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *plansStore) ensureNoOverlap(ctx context.Context, plan *model.FeePlan) error {
|
||||||
|
if plan == nil || !plan.Active {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var orgQuery builder.Query
|
||||||
|
if plan.OrganizationRef.IsZero() {
|
||||||
|
orgQuery = repository.Query().Or(
|
||||||
|
repository.Exists(repository.OrgField(), false),
|
||||||
|
repository.Query().Filter(repository.OrgField(), nil),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
orgQuery = repository.Query().Filter(repository.OrgField(), plan.OrganizationRef)
|
||||||
|
}
|
||||||
|
|
||||||
|
maxTime := time.Date(9999, 12, 31, 23, 59, 59, 0, time.UTC)
|
||||||
|
newFrom := plan.EffectiveFrom
|
||||||
|
|
||||||
|
newTo := maxTime
|
||||||
|
if plan.EffectiveTo != nil {
|
||||||
|
newTo = *plan.EffectiveTo
|
||||||
|
}
|
||||||
|
|
||||||
|
query := orgQuery.
|
||||||
|
Filter(repository.Field("active"), true).
|
||||||
|
Comparison(repository.Field("effectiveFrom"), builder.Lte, newTo).
|
||||||
|
And(repository.Query().Or(
|
||||||
|
repository.Query().Filter(repository.Field("effectiveTo"), nil),
|
||||||
|
repository.Query().Comparison(repository.Field("effectiveTo"), builder.Gte, newFrom),
|
||||||
|
))
|
||||||
|
|
||||||
|
if id := plan.GetID(); id != nil && !id.IsZero() {
|
||||||
|
query = query.And(repository.Query().Comparison(repository.IDField(), builder.Ne, *id))
|
||||||
|
}
|
||||||
|
|
||||||
|
limit := int64(1)
|
||||||
|
query = query.Limit(&limit)
|
||||||
|
|
||||||
|
var overlapFound bool
|
||||||
|
|
||||||
|
decoder := func(_ *mongo.Cursor) error {
|
||||||
|
overlapFound = true
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := p.repo.FindManyByFilter(ctx, query, decoder); err != nil {
|
||||||
|
if errors.Is(err, merrors.ErrNoData) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if overlapFound {
|
||||||
|
return storage.ErrConflictingFeePlans
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/tech/sendico/billing/fees/storage/model"
|
"github.com/tech/sendico/billing/fees/storage/model"
|
||||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
)
|
)
|
||||||
|
|
||||||
type storageError string
|
type storageError string
|
||||||
@@ -19,6 +19,8 @@ var (
|
|||||||
ErrFeePlanNotFound = storageError("billing.fees.storage: fee plan not found")
|
ErrFeePlanNotFound = storageError("billing.fees.storage: fee plan not found")
|
||||||
// ErrDuplicateFeePlan indicates that a unique plan constraint was violated.
|
// ErrDuplicateFeePlan indicates that a unique plan constraint was violated.
|
||||||
ErrDuplicateFeePlan = storageError("billing.fees.storage: duplicate fee plan")
|
ErrDuplicateFeePlan = storageError("billing.fees.storage: duplicate fee plan")
|
||||||
|
// ErrConflictingFeePlans indicates multiple active plans matched a query.
|
||||||
|
ErrConflictingFeePlans = storageError("billing.fees.storage: conflicting fee plans")
|
||||||
)
|
)
|
||||||
|
|
||||||
// Repository defines the root storage contract for the fees service.
|
// Repository defines the root storage contract for the fees service.
|
||||||
@@ -31,6 +33,7 @@ type Repository interface {
|
|||||||
type PlansStore interface {
|
type PlansStore interface {
|
||||||
Create(ctx context.Context, plan *model.FeePlan) error
|
Create(ctx context.Context, plan *model.FeePlan) error
|
||||||
Update(ctx context.Context, plan *model.FeePlan) error
|
Update(ctx context.Context, plan *model.FeePlan) error
|
||||||
Get(ctx context.Context, planRef primitive.ObjectID) (*model.FeePlan, error)
|
Get(ctx context.Context, planRef bson.ObjectID) (*model.FeePlan, error)
|
||||||
GetActivePlan(ctx context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error)
|
// Legacy helper that now prefers an org plan and falls back to a global plan.
|
||||||
|
GetActivePlan(ctx context.Context, orgRef bson.ObjectID, at time.Time) (*model.FeePlan, error)
|
||||||
}
|
}
|
||||||
|
|||||||
46
api/discovery/.air.toml
Normal file
46
api/discovery/.air.toml
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
root = "."
|
||||||
|
testdata_dir = "testdata"
|
||||||
|
tmp_dir = "tmp"
|
||||||
|
|
||||||
|
[build]
|
||||||
|
args_bin = []
|
||||||
|
entrypoint = "./tmp/main"
|
||||||
|
cmd = "go build -o ./tmp/main ."
|
||||||
|
delay = 1000
|
||||||
|
exclude_dir = ["assets", "tmp", "vendor", "testdata"]
|
||||||
|
exclude_file = []
|
||||||
|
exclude_regex = ["_test.go", "_templ.go"]
|
||||||
|
exclude_unchanged = false
|
||||||
|
follow_symlink = false
|
||||||
|
full_bin = ""
|
||||||
|
include_dir = []
|
||||||
|
include_ext = ["go", "tpl", "tmpl", "html"]
|
||||||
|
include_file = []
|
||||||
|
kill_delay = "0s"
|
||||||
|
log = "build-errors.log"
|
||||||
|
poll = false
|
||||||
|
poll_interval = 0
|
||||||
|
post_cmd = []
|
||||||
|
pre_cmd = []
|
||||||
|
rerun = false
|
||||||
|
rerun_delay = 500
|
||||||
|
send_interrupt = false
|
||||||
|
stop_on_error = false
|
||||||
|
|
||||||
|
[color]
|
||||||
|
app = ""
|
||||||
|
build = "yellow"
|
||||||
|
main = "magenta"
|
||||||
|
runner = "green"
|
||||||
|
watcher = "cyan"
|
||||||
|
|
||||||
|
[log]
|
||||||
|
main_only = false
|
||||||
|
time = false
|
||||||
|
|
||||||
|
[misc]
|
||||||
|
clean_on_exit = false
|
||||||
|
|
||||||
|
[screen]
|
||||||
|
clear_on_rebuild = false
|
||||||
|
keep_scroll = true
|
||||||
4
api/discovery/.gitignore
vendored
Normal file
4
api/discovery/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
internal/generated
|
||||||
|
.gocache
|
||||||
|
app
|
||||||
|
tmp
|
||||||
196
api/discovery/.golangci.yml
Normal file
196
api/discovery/.golangci.yml
Normal 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: []
|
||||||
21
api/discovery/config.dev.yml
Normal file
21
api/discovery/config.dev.yml
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
runtime:
|
||||||
|
shutdown_timeout_seconds: 15
|
||||||
|
|
||||||
|
metrics:
|
||||||
|
address: ":9405"
|
||||||
|
|
||||||
|
messaging:
|
||||||
|
driver: NATS
|
||||||
|
settings:
|
||||||
|
url_env: NATS_URL
|
||||||
|
host_env: NATS_HOST
|
||||||
|
port_env: NATS_PORT
|
||||||
|
username_env: NATS_USER
|
||||||
|
password_env: NATS_PASSWORD
|
||||||
|
broker_name: Discovery Service
|
||||||
|
max_reconnects: 10
|
||||||
|
reconnect_wait: 5
|
||||||
|
buffer_size: 1024
|
||||||
|
|
||||||
|
registry:
|
||||||
|
kv_ttl_seconds: 3600
|
||||||
21
api/discovery/config.yml
Normal file
21
api/discovery/config.yml
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
runtime:
|
||||||
|
shutdown_timeout_seconds: 15
|
||||||
|
|
||||||
|
metrics:
|
||||||
|
address: ":9405"
|
||||||
|
|
||||||
|
messaging:
|
||||||
|
driver: NATS
|
||||||
|
settings:
|
||||||
|
url_env: NATS_URL
|
||||||
|
host_env: NATS_HOST
|
||||||
|
port_env: NATS_PORT
|
||||||
|
username_env: NATS_USER
|
||||||
|
password_env: NATS_PASSWORD
|
||||||
|
broker_name: Discovery Service
|
||||||
|
max_reconnects: 10
|
||||||
|
reconnect_wait: 5
|
||||||
|
buffer_size: 1024
|
||||||
|
|
||||||
|
registry:
|
||||||
|
kv_ttl_seconds: 3600
|
||||||
49
api/discovery/go.mod
Normal file
49
api/discovery/go.mod
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
module github.com/tech/sendico/discovery
|
||||||
|
|
||||||
|
go 1.25.7
|
||||||
|
|
||||||
|
replace github.com/tech/sendico/pkg => ../pkg
|
||||||
|
|
||||||
|
require (
|
||||||
|
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
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
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/google/uuid v1.6.0 // 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.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.20.1 // indirect
|
||||||
|
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||||
|
github.com/xdg-go/scram v1.2.0 // indirect
|
||||||
|
github.com/xdg-go/stringprep v1.0.4 // indirect
|
||||||
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||||
|
go.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.48.0 // indirect
|
||||||
|
golang.org/x/net v0.51.0 // indirect
|
||||||
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
|
golang.org/x/text v0.34.0 // indirect
|
||||||
|
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
|
||||||
|
)
|
||||||
221
api/discovery/go.sum
Normal file
221
api/discovery/go.sum
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
|
||||||
|
dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
|
||||||
|
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
|
||||||
|
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||||
|
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||||
|
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||||
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
|
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||||
|
github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
|
||||||
|
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||||
|
github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk=
|
||||||
|
github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
|
||||||
|
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||||
|
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
|
||||||
|
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||||
|
github.com/casbin/mongodb-adapter/v4 v4.3.0 h1:yYXky9v1by6vj/0QK7OyHyd/xpz4vzh0lCi7JKrS4qQ=
|
||||||
|
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=
|
||||||
|
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
|
||||||
|
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
|
||||||
|
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||||
|
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||||
|
github.com/docker/docker v27.3.1+incompatible h1:KttF0XoteNTicmUtBO0L2tP+J7FGRFTjaEF4k6WdhfI=
|
||||||
|
github.com/docker/docker v27.3.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||||
|
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
|
||||||
|
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
||||||
|
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||||
|
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||||
|
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
|
github.com/go-chi/chi/v5 v5.2.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=
|
||||||
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
|
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||||
|
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||||
|
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||||
|
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||||
|
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
|
||||||
|
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
|
||||||
|
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
|
||||||
|
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
|
github.com/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.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=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||||
|
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||||
|
github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54 h1:mFWunSatvkQQDhpdyuFAYwyAan3hzCuma+Pz8sqvOfg=
|
||||||
|
github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||||
|
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
|
||||||
|
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||||
|
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||||
|
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||||
|
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||||
|
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||||
|
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
|
||||||
|
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
|
||||||
|
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
|
||||||
|
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
|
||||||
|
github.com/moby/sys/user v0.3.0 h1:9ni5DlcW5an3SvRSx4MouotOygvzaXbaSrc/wGDFWPo=
|
||||||
|
github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
|
||||||
|
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
|
||||||
|
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
|
||||||
|
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
||||||
|
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
|
||||||
|
github.com/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.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=
|
||||||
|
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||||
|
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||||
|
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||||
|
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
|
||||||
|
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
|
||||||
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||||
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||||
|
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||||
|
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||||
|
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||||
|
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||||
|
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
|
||||||
|
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
||||||
|
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
|
||||||
|
github.com/prometheus/procfs v0.20.1/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=
|
||||||
|
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
|
||||||
|
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
|
||||||
|
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
|
||||||
|
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||||
|
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||||
|
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||||
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/testcontainers/testcontainers-go v0.33.0 h1:zJS9PfXYT5O0ZFXM2xxXfk4J5UMw/kRiISng037Gxdw=
|
||||||
|
github.com/testcontainers/testcontainers-go v0.33.0/go.mod h1:W80YpTa8D5C3Yy16icheD01UTDu+LmXIA2Keo+jWtT8=
|
||||||
|
github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0 h1:iXVA84s5hKMS5gn01GWOYHE3ymy/2b+0YkpFeTxB2XY=
|
||||||
|
github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0/go.mod h1:R6tMjTojRiaoo89fh/hf7tOmfzohdqSU17R9DwSVSog=
|
||||||
|
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
|
||||||
|
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
|
||||||
|
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
|
||||||
|
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
|
||||||
|
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
||||||
|
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||||
|
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
|
||||||
|
github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8=
|
||||||
|
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
|
||||||
|
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
||||||
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
|
||||||
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
|
||||||
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
|
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||||
|
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||||
|
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||||
|
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 h1:UP6IpuHFkUgOQL9FFQFrZ+5LiwhhYRbi7VZSIx6Nj5s=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM=
|
||||||
|
go.opentelemetry.io/otel v1.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=
|
||||||
|
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||||
|
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
|
||||||
|
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||||
|
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||||
|
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
|
golang.org/x/crypto v0.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.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=
|
||||||
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.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.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=
|
||||||
|
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-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=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
30
api/discovery/internal/appversion/version.go
Normal file
30
api/discovery/internal/appversion/version.go
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
// Package appversion exposes build-time version information for the discovery service.
|
||||||
|
package appversion
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/tech/sendico/pkg/version"
|
||||||
|
vf "github.com/tech/sendico/pkg/version/factory"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Build information. Populated at build-time.
|
||||||
|
var (
|
||||||
|
Version string
|
||||||
|
Revision string
|
||||||
|
Branch string
|
||||||
|
BuildUser string
|
||||||
|
BuildDate string
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create 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,
|
||||||
|
BuildUser: BuildUser,
|
||||||
|
BuildDate: BuildDate,
|
||||||
|
Version: Version,
|
||||||
|
}
|
||||||
|
|
||||||
|
return vf.Create(&info)
|
||||||
|
}
|
||||||
60
api/discovery/internal/server/internal/config.go
Normal file
60
api/discovery/internal/server/internal/config.go
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
// Package serverimp contains the concrete discovery server implementation.
|
||||||
|
package serverimp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
msg "github.com/tech/sendico/pkg/messaging"
|
||||||
|
"github.com/tech/sendico/pkg/server/grpcapp"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultMetricsAddress = ":9405"
|
||||||
|
defaultShutdownTimeoutSeconds = 15
|
||||||
|
)
|
||||||
|
|
||||||
|
type config struct {
|
||||||
|
Runtime *grpcapp.RuntimeConfig `yaml:"runtime"`
|
||||||
|
Messaging *msg.Config `yaml:"messaging"`
|
||||||
|
Metrics *metricsConfig `yaml:"metrics"`
|
||||||
|
Registry *registryConfig `yaml:"registry"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type metricsConfig struct {
|
||||||
|
Address string `yaml:"address"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type registryConfig struct {
|
||||||
|
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 //nolint:wrapcheck
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := &config{}
|
||||||
|
|
||||||
|
err = yaml.Unmarshal(data, cfg)
|
||||||
|
if err != nil {
|
||||||
|
i.logger.Error("Failed to parse configuration", zap.Error(err))
|
||||||
|
|
||||||
|
return nil, err //nolint:wrapcheck
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Runtime == nil {
|
||||||
|
cfg.Runtime = &grpcapp.RuntimeConfig{ShutdownTimeoutSeconds: defaultShutdownTimeoutSeconds}
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Metrics != nil && strings.TrimSpace(cfg.Metrics.Address) == "" {
|
||||||
|
cfg.Metrics.Address = defaultMetricsAddress
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
79
api/discovery/internal/server/internal/discovery.go
Normal file
79
api/discovery/internal/server/internal/discovery.go
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
package serverimp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/discovery/internal/appversion"
|
||||||
|
"github.com/tech/sendico/pkg/discovery"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
msg "github.com/tech/sendico/pkg/messaging"
|
||||||
|
msgproducer "github.com/tech/sendico/pkg/messaging/producer"
|
||||||
|
"github.com/tech/sendico/pkg/mservice"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
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 //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, mservice.Discovery, registryOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return err //nolint:wrapcheck
|
||||||
|
}
|
||||||
|
|
||||||
|
svc.Start()
|
||||||
|
i.registrySvc = svc
|
||||||
|
|
||||||
|
announce := discovery.Announcement{
|
||||||
|
Service: mservice.Discovery,
|
||||||
|
InstanceID: discovery.InstanceID(),
|
||||||
|
Operations: []string{discovery.OperationDiscoveryLookup},
|
||||||
|
Version: appversion.Create().Short(),
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
106
api/discovery/internal/server/internal/metrics.go
Normal file
106
api/discovery/internal/server/internal/metrics.go
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
package serverimp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
|
"github.com/tech/sendico/pkg/api/routers"
|
||||||
|
"github.com/tech/sendico/pkg/api/routers/health"
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
router := chi.NewRouter()
|
||||||
|
router.Handle("/metrics", promhttp.Handler())
|
||||||
|
|
||||||
|
var healthRouter routers.Health
|
||||||
|
|
||||||
|
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)
|
||||||
|
healthRouter = hr
|
||||||
|
}
|
||||||
|
|
||||||
|
i.metricsHealth = healthRouter
|
||||||
|
i.metricsSrv = &http.Server{
|
||||||
|
Addr: address,
|
||||||
|
Handler: router,
|
||||||
|
ReadHeaderTimeout: readHeaderTimeout,
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
i.logger.Info("Prometheus endpoint listening", zap.String("address", address))
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Imp) shutdownMetrics(ctx context.Context) {
|
||||||
|
if i.metricsHealth != nil {
|
||||||
|
i.metricsHealth.SetStatus(health.SSTerminating)
|
||||||
|
i.metricsHealth.Finish()
|
||||||
|
i.metricsHealth = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if i.metricsSrv == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Imp) setMetricsStatus(status health.ServiceStatus) {
|
||||||
|
if i == nil || i.metricsHealth == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
i.metricsHealth.SetStatus(status)
|
||||||
|
}
|
||||||
129
api/discovery/internal/server/internal/serverimp.go
Normal file
129
api/discovery/internal/server/internal/serverimp.go
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
package serverimp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/pkg/api/routers/health"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"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"),
|
||||||
|
file: file,
|
||||||
|
debug: debug,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start loads configuration, starts metrics and the discovery registry, then blocks until stopped.
|
||||||
|
func (i *Imp) Start() error {
|
||||||
|
i.initStopChannels()
|
||||||
|
defer i.closeDone()
|
||||||
|
|
||||||
|
i.logger.Info("Starting discovery service", zap.String("config_file", i.file), zap.Bool("debug", i.debug))
|
||||||
|
|
||||||
|
cfg, err := i.loadConfig()
|
||||||
|
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.startMetrics(cfg.Metrics)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
i.setMetricsStatus(health.SSRunning)
|
||||||
|
i.logger.Info("Discovery service ready", zap.String("messaging_driver", messagingDriver))
|
||||||
|
|
||||||
|
<-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))
|
||||||
|
|
||||||
|
i.stopDiscovery()
|
||||||
|
i.signalStop()
|
||||||
|
|
||||||
|
if i.doneCh != nil {
|
||||||
|
<-i.doneCh
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
|
i.shutdownMetrics(ctx)
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
i.logger.Info("Discovery service stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Imp) initStopChannels() {
|
||||||
|
if i.stopCh == nil {
|
||||||
|
i.stopCh = make(chan struct{})
|
||||||
|
}
|
||||||
|
|
||||||
|
if i.doneCh == nil {
|
||||||
|
i.doneCh = make(chan struct{})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Imp) signalStop() {
|
||||||
|
i.stopOnce.Do(func() {
|
||||||
|
if i.stopCh != nil {
|
||||||
|
close(i.stopCh)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Imp) closeDone() {
|
||||||
|
i.doneOnce.Do(func() {
|
||||||
|
if i.doneCh != nil {
|
||||||
|
close(i.doneCh)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Imp) shutdownTimeout() time.Duration {
|
||||||
|
if i.config != nil && i.config.Runtime != nil {
|
||||||
|
return i.config.Runtime.ShutdownTimeout()
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultShutdownTimeout
|
||||||
|
}
|
||||||
29
api/discovery/internal/server/internal/types.go
Normal file
29
api/discovery/internal/server/internal/types.go
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
package serverimp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/pkg/api/routers"
|
||||||
|
"github.com/tech/sendico/pkg/discovery"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Imp is the concrete implementation of the discovery server application.
|
||||||
|
type Imp struct {
|
||||||
|
logger mlogger.Logger
|
||||||
|
file string
|
||||||
|
debug bool
|
||||||
|
|
||||||
|
config *config
|
||||||
|
registrySvc *discovery.RegistryService
|
||||||
|
announcer *discovery.Announcer
|
||||||
|
|
||||||
|
metricsSrv *http.Server
|
||||||
|
metricsHealth routers.Health
|
||||||
|
|
||||||
|
stopOnce sync.Once
|
||||||
|
doneOnce sync.Once
|
||||||
|
stopCh chan struct{}
|
||||||
|
doneCh chan struct{}
|
||||||
|
}
|
||||||
15
api/discovery/internal/server/server.go
Normal file
15
api/discovery/internal/server/server.go
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
// Package server provides the discovery service application factory.
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
serverimp "github.com/tech/sendico/discovery/internal/server/internal"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"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) //nolint:wrapcheck
|
||||||
|
}
|
||||||
19
api/discovery/main.go
Normal file
19
api/discovery/main.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
// Package main is the entry point for the discovery service.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/tech/sendico/discovery/internal/appversion"
|
||||||
|
si "github.com/tech/sendico/discovery/internal/server"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"github.com/tech/sendico/pkg/server"
|
||||||
|
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) //nolint:wrapcheck
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
smain.RunServer("main", appversion.Create(), factory)
|
||||||
|
}
|
||||||
46
api/edge/bff/.air.toml
Normal file
46
api/edge/bff/.air.toml
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
root = "."
|
||||||
|
testdata_dir = "testdata"
|
||||||
|
tmp_dir = "tmp"
|
||||||
|
|
||||||
|
[build]
|
||||||
|
args_bin = []
|
||||||
|
entrypoint = "./tmp/main"
|
||||||
|
cmd = "go build -o ./tmp/main ."
|
||||||
|
delay = 1000
|
||||||
|
exclude_dir = ["assets", "tmp", "vendor", "testdata"]
|
||||||
|
exclude_file = []
|
||||||
|
exclude_regex = ["_test.go", "_templ.go"]
|
||||||
|
exclude_unchanged = false
|
||||||
|
follow_symlink = false
|
||||||
|
full_bin = ""
|
||||||
|
include_dir = []
|
||||||
|
include_ext = ["go", "tpl", "tmpl", "html"]
|
||||||
|
include_file = []
|
||||||
|
kill_delay = "0s"
|
||||||
|
log = "build-errors.log"
|
||||||
|
poll = false
|
||||||
|
poll_interval = 0
|
||||||
|
post_cmd = []
|
||||||
|
pre_cmd = []
|
||||||
|
rerun = false
|
||||||
|
rerun_delay = 500
|
||||||
|
send_interrupt = false
|
||||||
|
stop_on_error = false
|
||||||
|
|
||||||
|
[color]
|
||||||
|
app = ""
|
||||||
|
build = "yellow"
|
||||||
|
main = "magenta"
|
||||||
|
runner = "green"
|
||||||
|
watcher = "cyan"
|
||||||
|
|
||||||
|
[log]
|
||||||
|
main_only = false
|
||||||
|
time = false
|
||||||
|
|
||||||
|
[misc]
|
||||||
|
clean_on_exit = false
|
||||||
|
|
||||||
|
[screen]
|
||||||
|
clear_on_rebuild = false
|
||||||
|
keep_scroll = true
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
/app
|
/app
|
||||||
/server
|
/server
|
||||||
/storage
|
/storage
|
||||||
.gocache
|
.gocache
|
||||||
|
tmp
|
||||||
BIN
api/edge/bff/assets/resources/logo.png
Normal file
BIN
api/edge/bff/assets/resources/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
@@ -1,5 +1,5 @@
|
|||||||
http_server:
|
http_server:
|
||||||
listen_address: :8081
|
listen_address: :8080
|
||||||
read_header_timeout: 60
|
read_header_timeout: 60
|
||||||
shutdown_timeout: 5
|
shutdown_timeout: 5
|
||||||
|
|
||||||
@@ -16,8 +16,7 @@ api:
|
|||||||
CORS:
|
CORS:
|
||||||
max_age: 300
|
max_age: 300
|
||||||
allowed_origins:
|
allowed_origins:
|
||||||
- "http://*"
|
- "*"
|
||||||
- "https://*"
|
|
||||||
allowed_methods:
|
allowed_methods:
|
||||||
- "GET"
|
- "GET"
|
||||||
- "POST"
|
- "POST"
|
||||||
@@ -38,6 +37,7 @@ api:
|
|||||||
message_broker:
|
message_broker:
|
||||||
driver: NATS
|
driver: NATS
|
||||||
settings:
|
settings:
|
||||||
|
url_env: NATS_URL
|
||||||
host_env: NATS_HOST
|
host_env: NATS_HOST
|
||||||
port_env: NATS_PORT
|
port_env: NATS_PORT
|
||||||
username_env: NATS_USER
|
username_env: NATS_USER
|
||||||
@@ -45,6 +45,7 @@ api:
|
|||||||
broker_name: Sendico Backend server
|
broker_name: Sendico Backend server
|
||||||
max_reconnects: 10
|
max_reconnects: 10
|
||||||
reconnect_wait: 5
|
reconnect_wait: 5
|
||||||
|
buffer_size: 1024
|
||||||
# type: in-process
|
# type: in-process
|
||||||
# settings:
|
# settings:
|
||||||
# buffer_size: 10
|
# buffer_size: 10
|
||||||
@@ -55,7 +56,7 @@ api:
|
|||||||
length: 32
|
length: 32
|
||||||
password:
|
password:
|
||||||
token_length: 32
|
token_length: 32
|
||||||
checks:
|
check:
|
||||||
min_length: 8
|
min_length: 8
|
||||||
digit: true
|
digit: true
|
||||||
upper: true
|
upper: true
|
||||||
@@ -75,21 +76,52 @@ api:
|
|||||||
root_path: ./storage
|
root_path: ./storage
|
||||||
|
|
||||||
chain_gateway:
|
chain_gateway:
|
||||||
address: sendico_chain_gateway:50070
|
address: dev-tron-gateway:50071
|
||||||
address_env: CHAIN_GATEWAY_ADDRESS
|
address_env: TRON_GATEWAY_ADDRESS
|
||||||
dial_timeout_seconds: 5
|
dial_timeout_seconds: 5
|
||||||
call_timeout_seconds: 5
|
call_timeout_seconds: 5
|
||||||
insecure: true
|
insecure: true
|
||||||
default_asset:
|
default_asset:
|
||||||
chain: ARBITRUM_ONE
|
chain: TRON_NILE
|
||||||
token_symbol: USDT
|
token_symbol: USDT
|
||||||
contract_address: ""
|
contract_address: ""
|
||||||
ledger:
|
ledger:
|
||||||
address: sendico_ledger:50052
|
address: dev-ledger:50052
|
||||||
address_env: LEDGER_ADDRESS
|
address_env: LEDGER_ADDRESS
|
||||||
dial_timeout_seconds: 5
|
dial_timeout_seconds: 5
|
||||||
call_timeout_seconds: 5
|
call_timeout_seconds: 5
|
||||||
insecure: true
|
insecure: true
|
||||||
|
payment_orchestrator:
|
||||||
|
address: dev-payments-orchestrator:50062
|
||||||
|
address_env: PAYMENTS_ADDRESS
|
||||||
|
dial_timeout_seconds: 5
|
||||||
|
call_timeout_seconds: 5
|
||||||
|
insecure: true
|
||||||
|
payment_quotation:
|
||||||
|
address: dev-payments-quotation:50064
|
||||||
|
address_env: PAYMENTS_QUOTE_ADDRESS
|
||||||
|
dial_timeout_seconds: 5
|
||||||
|
call_timeout_seconds: 5
|
||||||
|
insecure: true
|
||||||
|
payment_methods:
|
||||||
|
address: dev-payments-methods:50066
|
||||||
|
address_env: PAYMENTS_METHODS_ADDRESS
|
||||||
|
dial_timeout_seconds: 5
|
||||||
|
call_timeout_seconds: 5
|
||||||
|
insecure: true
|
||||||
|
callbacks:
|
||||||
|
default_event_types:
|
||||||
|
- payment.status.updated
|
||||||
|
default_status: active
|
||||||
|
secret_path_prefix: sendico/callbacks
|
||||||
|
secret_field: value
|
||||||
|
secret_length_bytes: 32
|
||||||
|
vault:
|
||||||
|
address: "http://dev-vault:8200"
|
||||||
|
token_env: VAULT_TOKEN
|
||||||
|
token_file_env: VAULT_TOKEN_FILE
|
||||||
|
namespace: ""
|
||||||
|
mount_path: kv
|
||||||
|
|
||||||
app:
|
app:
|
||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user