Compare commits
541 Commits
devKA
...
6a0289963b
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a0289963b | |||
|
|
853b855049 | ||
| e767436f33 | |||
|
|
57914c0754 | ||
| b894f92e50 | |||
|
|
76548032b3 | ||
| d121f4172e | |||
|
|
e717cabd0f | ||
| 8fead967d1 | |||
|
|
a09fd550ba | ||
| 8eae9270f2 | |||
|
|
dc6a4224a0 | ||
| a99a3c851c | |||
|
|
9bca523caa | ||
| 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
|
||||
generate_protos.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
|
||||
@@ -9,6 +9,12 @@ matrix:
|
||||
when:
|
||||
- event: push
|
||||
branch: main
|
||||
path:
|
||||
include:
|
||||
- api/server/**
|
||||
- api/proto/**
|
||||
- api/pkg/**
|
||||
ignore_message: '[rebuild]'
|
||||
|
||||
steps:
|
||||
- name: version
|
||||
|
||||
79
.woodpecker/billing_documents.yml
Normal file
79
.woodpecker/billing_documents.yml
Normal file
@@ -0,0 +1,79 @@
|
||||
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/**
|
||||
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: secrets
|
||||
image: alpine:latest
|
||||
depends_on: [ version ]
|
||||
environment:
|
||||
VAULT_ADDR: { from_secret: VAULT_ADDR }
|
||||
VAULT_ROLE_ID: { from_secret: VAULT_APP_ROLE }
|
||||
VAULT_SECRET_ID: { from_secret: VAULT_SECRET_ID }
|
||||
commands:
|
||||
- set -euo pipefail
|
||||
- apk add --no-cache bash coreutils openssh-keygen curl sed python3
|
||||
- mkdir -p secrets
|
||||
- ./ci/vlt kv_to_file kv ops/deploy/ssh_key private_b64 secrets/SSH_KEY.b64 600
|
||||
- base64 -d secrets/SSH_KEY.b64 > secrets/SSH_KEY
|
||||
- chmod 600 secrets/SSH_KEY
|
||||
- ssh-keygen -y -f secrets/SSH_KEY >/dev/null
|
||||
- ./ci/vlt kv_get kv registry user > secrets/REGISTRY_USER
|
||||
- ./ci/vlt kv_get kv registry password > secrets/REGISTRY_PASSWORD
|
||||
|
||||
- name: build-image
|
||||
image: gcr.io/kaniko-project/executor:debug
|
||||
depends_on: [ proto, secrets ]
|
||||
commands:
|
||||
- sh ci/scripts/billing_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,12 @@ matrix:
|
||||
when:
|
||||
- event: push
|
||||
branch: main
|
||||
path:
|
||||
include:
|
||||
- api/billing/fees/**
|
||||
- api/proto/**
|
||||
- api/pkg/**
|
||||
ignore_message: '[rebuild]'
|
||||
|
||||
steps:
|
||||
- name: version
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
when:
|
||||
- event: push
|
||||
branch: main
|
||||
path:
|
||||
exclude: ['**']
|
||||
ignore_message: '[infra]'
|
||||
|
||||
steps:
|
||||
- name: version
|
||||
|
||||
78
.woodpecker/discovery.yml
Normal file
78
.woodpecker/discovery.yml
Normal file
@@ -0,0 +1,78 @@
|
||||
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/**
|
||||
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: secrets
|
||||
image: alpine:latest
|
||||
depends_on: [ version ]
|
||||
environment:
|
||||
VAULT_ADDR: { from_secret: VAULT_ADDR }
|
||||
VAULT_ROLE_ID: { from_secret: VAULT_APP_ROLE }
|
||||
VAULT_SECRET_ID: { from_secret: VAULT_SECRET_ID }
|
||||
commands:
|
||||
- set -euo pipefail
|
||||
- apk add --no-cache bash coreutils openssh-keygen curl sed python3
|
||||
- mkdir -p secrets
|
||||
- ./ci/vlt kv_to_file kv ops/deploy/ssh_key private_b64 secrets/SSH_KEY.b64 600
|
||||
- base64 -d secrets/SSH_KEY.b64 > secrets/SSH_KEY
|
||||
- chmod 600 secrets/SSH_KEY
|
||||
- ssh-keygen -y -f secrets/SSH_KEY >/dev/null
|
||||
- ./ci/vlt kv_get kv registry user > secrets/REGISTRY_USER
|
||||
- ./ci/vlt kv_get kv registry password > secrets/REGISTRY_PASSWORD
|
||||
|
||||
- name: build-image
|
||||
image: gcr.io/kaniko-project/executor:debug
|
||||
depends_on: [ proto, secrets ]
|
||||
commands:
|
||||
- sh ci/scripts/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,13 @@ matrix:
|
||||
when:
|
||||
- event: push
|
||||
branch: main
|
||||
path:
|
||||
include:
|
||||
- api/server/**
|
||||
- api/pkg/**
|
||||
- api/proto/**
|
||||
- frontend/**
|
||||
ignore_message: '[rebuild]'
|
||||
|
||||
steps:
|
||||
- name: version
|
||||
|
||||
@@ -5,12 +5,19 @@ matrix:
|
||||
FX_DOCKERFILE: ci/prod/compose/fx_ingestor.dockerfile
|
||||
FX_DEPLOY_TARGET: ingestor
|
||||
FX_MONGO_SECRET_PATH: sendico/db
|
||||
FX_NEEDS_NATS: "false"
|
||||
FX_NEEDS_NATS: "true"
|
||||
FX_ENV: prod
|
||||
|
||||
when:
|
||||
- event: push
|
||||
branch: main
|
||||
path:
|
||||
include:
|
||||
- api/fx/ingestor/**
|
||||
- api/fx/storage/**
|
||||
- api/proto/**
|
||||
- api/pkg/**
|
||||
ignore_message: '[rebuild]'
|
||||
|
||||
steps:
|
||||
- name: version
|
||||
|
||||
@@ -11,6 +11,14 @@ matrix:
|
||||
when:
|
||||
- event: push
|
||||
branch: main
|
||||
path:
|
||||
include:
|
||||
- api/fx/oracle/**
|
||||
- api/fx/storage/**
|
||||
- api/proto/**
|
||||
- api/pkg/**
|
||||
ignore_message: '[rebuild]'
|
||||
|
||||
|
||||
steps:
|
||||
- name: version
|
||||
|
||||
@@ -11,6 +11,12 @@ matrix:
|
||||
when:
|
||||
- event: push
|
||||
branch: main
|
||||
path:
|
||||
include:
|
||||
- api/gateway/chain/**
|
||||
- api/proto/**
|
||||
- api/pkg/**
|
||||
ignore_message: '[rebuild]'
|
||||
|
||||
steps:
|
||||
- name: version
|
||||
81
.woodpecker/gateway_mntx.yml
Normal file
81
.woodpecker/gateway_mntx.yml
Normal file
@@ -0,0 +1,81 @@
|
||||
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/proto/**
|
||||
- api/pkg/**
|
||||
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: secrets
|
||||
image: alpine:latest
|
||||
depends_on: [ version ]
|
||||
environment:
|
||||
VAULT_ADDR: { from_secret: VAULT_ADDR }
|
||||
VAULT_ROLE_ID: { from_secret: VAULT_APP_ROLE }
|
||||
VAULT_SECRET_ID: { from_secret: VAULT_SECRET_ID }
|
||||
commands:
|
||||
- set -euo pipefail
|
||||
- apk add --no-cache bash coreutils openssh-keygen curl sed python3
|
||||
- mkdir -p secrets
|
||||
- ./ci/vlt kv_to_file kv ops/deploy/ssh_key private_b64 secrets/SSH_KEY.b64 600
|
||||
- base64 -d secrets/SSH_KEY.b64 > secrets/SSH_KEY
|
||||
- chmod 600 secrets/SSH_KEY
|
||||
- ssh-keygen -y -f secrets/SSH_KEY >/dev/null
|
||||
- ./ci/vlt kv_get kv registry user > secrets/REGISTRY_USER
|
||||
- ./ci/vlt kv_get kv registry password > secrets/REGISTRY_PASSWORD
|
||||
|
||||
- name: build-image
|
||||
image: gcr.io/kaniko-project/executor:debug
|
||||
depends_on: [ proto, secrets ]
|
||||
commands:
|
||||
- sh ci/scripts/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
|
||||
79
.woodpecker/gateway_tgsettle.yml
Normal file
79
.woodpecker/gateway_tgsettle.yml
Normal file
@@ -0,0 +1,79 @@
|
||||
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/proto/**
|
||||
- api/pkg/**
|
||||
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: secrets
|
||||
image: alpine:latest
|
||||
depends_on: [ version ]
|
||||
environment:
|
||||
VAULT_ADDR: { from_secret: VAULT_ADDR }
|
||||
VAULT_ROLE_ID: { from_secret: VAULT_APP_ROLE }
|
||||
VAULT_SECRET_ID: { from_secret: VAULT_SECRET_ID }
|
||||
commands:
|
||||
- set -euo pipefail
|
||||
- apk add --no-cache bash coreutils openssh-keygen curl sed python3
|
||||
- mkdir -p secrets
|
||||
- ./ci/vlt kv_to_file kv ops/deploy/ssh_key private_b64 secrets/SSH_KEY.b64 600
|
||||
- base64 -d secrets/SSH_KEY.b64 > secrets/SSH_KEY
|
||||
- chmod 600 secrets/SSH_KEY
|
||||
- ssh-keygen -y -f secrets/SSH_KEY >/dev/null
|
||||
- ./ci/vlt kv_get kv registry user > secrets/REGISTRY_USER
|
||||
- ./ci/vlt kv_get kv registry password > secrets/REGISTRY_PASSWORD
|
||||
|
||||
- name: build-image
|
||||
image: gcr.io/kaniko-project/executor:debug
|
||||
depends_on: [ proto, secrets ]
|
||||
commands:
|
||||
- sh ci/scripts/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
|
||||
82
.woodpecker/gateway_tron.yml
Normal file
82
.woodpecker/gateway_tron.yml
Normal file
@@ -0,0 +1,82 @@
|
||||
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/proto/**
|
||||
- api/pkg/**
|
||||
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: secrets
|
||||
image: alpine:latest
|
||||
depends_on: [ version ]
|
||||
environment:
|
||||
VAULT_ADDR: { from_secret: VAULT_ADDR }
|
||||
VAULT_ROLE_ID: { from_secret: VAULT_APP_ROLE }
|
||||
VAULT_SECRET_ID: { from_secret: VAULT_SECRET_ID }
|
||||
commands:
|
||||
- set -euo pipefail
|
||||
- apk add --no-cache bash coreutils openssh-keygen curl sed python3
|
||||
- mkdir -p secrets
|
||||
- ./ci/vlt kv_to_file kv ops/deploy/ssh_key private_b64 secrets/SSH_KEY.b64 600
|
||||
- base64 -d secrets/SSH_KEY.b64 > secrets/SSH_KEY
|
||||
- chmod 600 secrets/SSH_KEY
|
||||
- ssh-keygen -y -f secrets/SSH_KEY >/dev/null
|
||||
- ./ci/vlt kv_get kv registry user > secrets/REGISTRY_USER
|
||||
- ./ci/vlt kv_get kv registry password > secrets/REGISTRY_PASSWORD
|
||||
|
||||
- name: build-image
|
||||
image: gcr.io/kaniko-project/executor:debug
|
||||
depends_on: [ proto, secrets ]
|
||||
commands:
|
||||
- sh ci/scripts/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,12 @@ matrix:
|
||||
when:
|
||||
- event: push
|
||||
branch: main
|
||||
path:
|
||||
include:
|
||||
- api/ledger/**
|
||||
- api/proto/**
|
||||
- api/pkg/**
|
||||
ignore_message: '[rebuild]'
|
||||
|
||||
steps:
|
||||
- name: version
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
when:
|
||||
- event: push
|
||||
branch: main
|
||||
path:
|
||||
exclude: ['**']
|
||||
ignore_message: '[infra]'
|
||||
|
||||
|
||||
steps:
|
||||
- name: version
|
||||
|
||||
@@ -11,6 +11,12 @@ matrix:
|
||||
when:
|
||||
- event: push
|
||||
branch: main
|
||||
path:
|
||||
include:
|
||||
- api/notification/**
|
||||
- api/proto/**
|
||||
- api/pkg/**
|
||||
ignore_message: '[rebuild]'
|
||||
|
||||
steps:
|
||||
- name: version
|
||||
|
||||
@@ -8,6 +8,12 @@ matrix:
|
||||
when:
|
||||
- event: push
|
||||
branch: main
|
||||
path:
|
||||
include:
|
||||
- api/payments/orchestrator/**
|
||||
- api/proto/**
|
||||
- api/pkg/**
|
||||
ignore_message: '[rebuild]'
|
||||
|
||||
steps:
|
||||
- name: version
|
||||
|
||||
81
.woodpecker/payments_quotation.yml
Normal file
81
.woodpecker/payments_quotation.yml
Normal file
@@ -0,0 +1,81 @@
|
||||
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/payments/orchestrator/**
|
||||
- api/proto/**
|
||||
- api/pkg/**
|
||||
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: secrets
|
||||
image: alpine:latest
|
||||
depends_on: [ version ]
|
||||
environment:
|
||||
VAULT_ADDR: { from_secret: VAULT_ADDR }
|
||||
VAULT_ROLE_ID: { from_secret: VAULT_APP_ROLE }
|
||||
VAULT_SECRET_ID: { from_secret: VAULT_SECRET_ID }
|
||||
commands:
|
||||
- set -euo pipefail
|
||||
- apk add --no-cache bash coreutils openssh-keygen curl sed python3
|
||||
- mkdir -p secrets
|
||||
- ./ci/vlt kv_to_file kv ops/deploy/ssh_key private_b64 secrets/SSH_KEY.b64 600
|
||||
- base64 -d secrets/SSH_KEY.b64 > secrets/SSH_KEY
|
||||
- chmod 600 secrets/SSH_KEY
|
||||
- ssh-keygen -y -f secrets/SSH_KEY >/dev/null
|
||||
- ./ci/vlt kv_get kv registry user > secrets/REGISTRY_USER
|
||||
- ./ci/vlt kv_get kv registry password > secrets/REGISTRY_PASSWORD
|
||||
|
||||
- name: build-image
|
||||
image: gcr.io/kaniko-project/executor:debug
|
||||
depends_on: [ proto, secrets ]
|
||||
commands:
|
||||
- sh ci/scripts/payments_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
|
||||
331
Makefile
Normal file
331
Makefile
Normal file
@@ -0,0 +1,331 @@
|
||||
# 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
|
||||
|
||||
COMPOSE := docker compose -f docker-compose.dev.yml --env-file .env.dev
|
||||
SERVICE ?=
|
||||
|
||||
# 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 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, mntx, tgsettle)"
|
||||
@echo " make build-api Build API services (notification, 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)"
|
||||
@./generate_protos.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)"
|
||||
@./generate_protos.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)"
|
||||
@./generate_protos.sh
|
||||
@echo "$(GREEN)✅ Protobuf generation complete$(NC)"
|
||||
|
||||
# Generate Flutter code (json_serializable, etc.)
|
||||
generate-frontend:
|
||||
@echo "$(GREEN)Generating Flutter code...$(NC)"
|
||||
@cd frontend/pshared && flutter pub run build_runner build --delete-conflicting-outputs
|
||||
@cd frontend/pweb && flutter pub 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-chain-gateway \
|
||||
dev-tron-gateway \
|
||||
dev-mntx-gateway \
|
||||
dev-tgsettle-gateway \
|
||||
dev-notification \
|
||||
dev-bff \
|
||||
dev-frontend
|
||||
|
||||
# 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-chain-gateway :50070, :9404 (EVM Blockchain Gateway)"
|
||||
@echo " - dev-tron-gateway :50071, :9408 (TRON Blockchain Gateway)"
|
||||
@echo " - dev-mntx-gateway :50075, :9405, :8084 (Card Payouts)"
|
||||
@echo " - dev-tgsettle-gateway :50080, :9406 (Telegram Settlements)"
|
||||
@echo " - dev-notification :8081 (Notifications)"
|
||||
@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
|
||||
|
||||
build-gateways:
|
||||
@echo "$(GREEN)Building gateway services...$(NC)"
|
||||
@$(COMPOSE) build dev-chain-gateway dev-tron-gateway dev-mntx-gateway dev-tgsettle-gateway
|
||||
|
||||
build-api:
|
||||
@echo "$(GREEN)Building API services...$(NC)"
|
||||
@$(COMPOSE) build dev-notification 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)"
|
||||
99
README.md
Normal file
99
README.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# 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 |
|
||||
| Billing Fees | `api/billing/fees/` | Fee calculation |
|
||||
| Billing Documents | `api/billing/documents/` | Billing documents |
|
||||
| FX Oracle | `api/fx/oracle/` | FX quote provider |
|
||||
| FX Ingestor | `api/fx/ingestor/` | FX rate ingestion |
|
||||
| Gateway Chain | `api/gateway/chain/` | EVM blockchain gateway |
|
||||
| Gateway TRON | `api/gateway/tron/` | TRON blockchain gateway |
|
||||
| Gateway MNTX | `api/gateway/mntx/` | Card payouts |
|
||||
| Gateway TGSettle | `api/gateway/tgsettle/` | Settlements |
|
||||
| Notification | `api/notification/` | Notifications |
|
||||
| BFF | `api/server/` | Backend for frontend |
|
||||
| Frontend | `frontend/pweb/` | Flutter web UI |
|
||||
|
||||
## Development
|
||||
|
||||
Development uses Docker Compose via the Makefile. Run `make help` for all available commands.
|
||||
|
||||
### Quick Start
|
||||
|
||||
```bash
|
||||
make init # First-time setup (generates keys, .env.dev, builds images)
|
||||
make up # Start all services
|
||||
make vault-init # Initialize Vault (if needed)
|
||||
```
|
||||
|
||||
### Common Commands
|
||||
|
||||
```bash
|
||||
make build # Build all service images
|
||||
make up # Start all services
|
||||
make down # Stop all services
|
||||
make restart # Restart all services
|
||||
make status # Show service status
|
||||
make logs # View all logs
|
||||
make logs SERVICE=dev-ledger # View logs for a specific service
|
||||
make rebuild SERVICE=dev-ledger # Rebuild and restart a specific service
|
||||
make clean # Remove all containers and volumes
|
||||
```
|
||||
|
||||
### Selective Start
|
||||
|
||||
```bash
|
||||
make infra-up # Start infrastructure only (MongoDB, NATS, Vault)
|
||||
make services-up # Start application services only (assumes infra is running)
|
||||
```
|
||||
|
||||
### Build Groups
|
||||
|
||||
```bash
|
||||
make build-core # discovery, ledger, fees, documents
|
||||
make build-fx # oracle, ingestor
|
||||
make build-payments # orchestrator
|
||||
make build-gateways # chain, tron, mntx, tgsettle
|
||||
make build-api # notification, bff
|
||||
make build-frontend # Flutter web UI
|
||||
```
|
||||
|
||||
### Code Generation
|
||||
|
||||
```bash
|
||||
make generate # Generate all code (protobuf + Flutter)
|
||||
make generate-api # Generate protobuf code only
|
||||
make generate-frontend # Generate Flutter code only (build_runner)
|
||||
make proto # Alias for generate-api
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
make test # Run all tests (API + frontend)
|
||||
make test-api # Run Go API tests only
|
||||
make test-frontend # Run Flutter tests only
|
||||
```
|
||||
|
||||
### Update Dependencies
|
||||
|
||||
```bash
|
||||
make update # Update all Go and Flutter dependencies
|
||||
make update-api # Update Go dependencies only
|
||||
make update-frontend # Update Flutter dependencies only
|
||||
```
|
||||
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
|
||||
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: "/assets/logo.png"
|
||||
templates:
|
||||
acceptance_path: "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.1
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.7
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.7
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0
|
||||
github.com/jung-kurt/gofpdf v1.16.2
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/shopspring/decimal v1.4.0
|
||||
github.com/tech/sendico/pkg v0.1.0
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0
|
||||
go.uber.org/zap v1.27.1
|
||||
google.golang.org/grpc v1.78.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect
|
||||
github.com/aws/smithy-go v1.24.0 // indirect
|
||||
github.com/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.48.0 // indirect
|
||||
github.com/nats-io/nkeys v0.4.15 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.67.5 // indirect
|
||||
github.com/prometheus/procfs v0.19.2 // indirect
|
||||
github.com/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.50.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-20260209200024-4cfbd4190f57 // 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.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.7 h1:vxUyWGUwmkQ2g19n7JY/9YL8MfAIl7bTesIUykECXmY=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.7/go.mod h1:2/Qm5vKUU/r7Y+zUk/Ptt2MDAEKAfUtKc1+3U1Mo3oY=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.7 h1:tHK47VqqtJxOymRrNtUXN5SP/zUTvZKeLx4tH6PGQc8=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.7/go.mod h1:qOZk8sPDrxhf+4Wf4oT2urYJrYt3RejHSzgAquYeppw=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 h1:I0GyV8wiYrP8XpA70g1HBcQO1JlQxCMTW9npl5UbDHY=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17/go.mod h1:tyw7BOl5bBe/oqvoIeECFJjMdzXoa/dfVz3QQ5lgHGA=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17/go.mod h1:5M5CI3D12dNOtH3/mk6minaRwI2/37ifCURZISxA/IQ=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 h1:WWLqlh79iO48yLkj1v3ISRNiv+3KdQoZ6JWyfcsyQik=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17/go.mod h1:EhG22vHRrvF8oXSTYStZhJc1aUgKtnJe+aOiFEV90cM=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 h1:JqcdRG//czea7Ppjb+g/n4o8i/R50aTBHkA7vu0lK+k=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17/go.mod h1:CO+WeGmIdj/MlPel2KwID9Gt7CNq4M65HUfBW97liM0=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 h1:Z5EiPIzXKewUQK0QTMkutjiaPVeVYXX7KIqhXu/0fXs=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8/go.mod h1:FsTpJtvC4U1fyDXk7c71XoDv3HlRm8V3NiYLeYLh5YE=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 h1:RuNSMoozM8oXlgLG/n6WLaFGoea7/CddrCfIiSA+xdY=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17/go.mod h1:F2xxQ9TZz5gDWsclCtPQscGpP0VUOc8RqgFM3vDENmU=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 h1:bGeHBsGZx0Dvu/eJC0Lh9adJa3M1xREcndxLNZlve2U=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17/go.mod h1:dcW24lbU0CzHusTE8LLHhRLI42ejmINN8Lcr22bwh/g=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0 h1:oeu8VPlOre74lBA/PMhxa5vewaMIMmILM+RraSyB8KA=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0/go.mod h1:5jggDlZ2CLQhwJBiZJb4vfk4f0GxWdEDruWKEJ1xOdo=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 h1:VrhDvQib/i0lxvr3zqlUwLwJP4fpmpyD9wYG1vfSu+Y=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5/go.mod h1:k029+U8SY30/3/ras4G/Fnv/b88N4mAfliNn08Dem4M=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 h1:v6EiMvhEYBoHABfbGB4alOYmCIrcgyPPiBE1wZAEbqk=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.9/go.mod h1:yifAsgBxgJWn3ggx70A3urX2AN49Y5sJTD1UQFlfqBw=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 h1:gd84Omyu9JLriJVCbGApcLzVR3XtmC4ZDPcAI6Ftvds=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13/go.mod h1:sTGThjphYE4Ohw8vJiRStAcu3rbjtXRsdNB0TvZ5wwo=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/pJ1jOWYlFDJTjRQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ=
|
||||
github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
|
||||
github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
|
||||
github.com/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.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
|
||||
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
||||
github.com/nats-io/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.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/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.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
|
||||
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
|
||||
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
|
||||
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
|
||||
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
|
||||
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
|
||||
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
|
||||
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
|
||||
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.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.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
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-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
||||
google.golang.org/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=
|
||||
28
api/billing/documents/internal/appversion/version.go
Normal file
28
api/billing/documents/internal/appversion/version.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package appversion
|
||||
|
||||
import (
|
||||
"github.com/tech/sendico/pkg/version"
|
||||
vf "github.com/tech/sendico/pkg/version/factory"
|
||||
)
|
||||
|
||||
// Build information populated at build time.
|
||||
var (
|
||||
Version string
|
||||
Revision string
|
||||
Branch string
|
||||
BuildUser string
|
||||
BuildDate string
|
||||
)
|
||||
|
||||
// Create initialises a version.Printer with the build details for this service.
|
||||
func Create() version.Printer {
|
||||
info := version.Info{
|
||||
Program: "Sendico Billing Documents Service",
|
||||
Revision: Revision,
|
||||
Branch: Branch,
|
||||
BuildUser: BuildUser,
|
||||
BuildDate: BuildDate,
|
||||
Version: Version,
|
||||
}
|
||||
return vf.Create(&info)
|
||||
}
|
||||
61
api/billing/documents/internal/docstore/local.go
Normal file
61
api/billing/documents/internal/docstore/local.go
Normal file
@@ -0,0 +1,61 @@
|
||||
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)
|
||||
125
api/billing/documents/internal/docstore/s3.go
Normal file
125
api/billing/documents/internal/docstore/s3.go
Normal file
@@ -0,0 +1,125 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
resolver := aws.EndpointResolverWithOptionsFunc(func(service, region string, _ ...interface{}) (aws.Endpoint, error) {
|
||||
if service == s3.ServiceID {
|
||||
return aws.Endpoint{URL: endpoint, SigningRegion: region, HostnameImmutable: true}, nil
|
||||
}
|
||||
return aws.Endpoint{}, &aws.EndpointNotFoundError{}
|
||||
})
|
||||
loadOpts = append(loadOpts, config.WithEndpointResolverWithOptions(resolver))
|
||||
}
|
||||
|
||||
awsCfg, err := config.LoadDefaultConfig(context.Background(), loadOpts...)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to create AWS config", zap.Error(err), zap.String("bucket", bucket))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := s3.NewFromConfig(awsCfg, func(opts *s3.Options) {
|
||||
opts.UsePathStyle = cfg.ForcePathStyle
|
||||
})
|
||||
|
||||
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)
|
||||
67
api/billing/documents/internal/docstore/store.go
Normal file
67
api/billing/documents/internal/docstore/store.go
Normal file
@@ -0,0 +1,67 @@
|
||||
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"`
|
||||
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")
|
||||
}
|
||||
}
|
||||
144
api/billing/documents/internal/server/internal/serverimp.go
Normal file
144
api/billing/documents/internal/server/internal/serverimp.go
Normal file
@@ -0,0 +1,144 @@
|
||||
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) {
|
||||
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)
|
||||
}
|
||||
33
api/billing/documents/internal/service/documents/config.go
Normal file
33
api/billing/documents/internal/service/documents/config.go
Normal file
@@ -0,0 +1,33 @@
|
||||
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
|
||||
}
|
||||
105
api/billing/documents/internal/service/documents/metrics.go
Normal file
105
api/billing/documents/internal/service/documents/metrics.go
Normal file
@@ -0,0 +1,105 @@
|
||||
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
|
||||
}
|
||||
418
api/billing/documents/internal/service/documents/service.go
Normal file
418
api/billing/documents/internal/service/documents/service.go
Normal file
@@ -0,0 +1,418 @@
|
||||
package documents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"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 {
|
||||
logger mlogger.Logger
|
||||
storage storage.Repository
|
||||
docStore docstore.Store
|
||||
producer msg.Producer
|
||||
announcer *discovery.Announcer
|
||||
invokeURI string
|
||||
config Config
|
||||
template TemplateRenderer
|
||||
documentsv1.UnimplementedDocumentServiceServer
|
||||
}
|
||||
|
||||
// NewService constructs a documents service with optional configuration.
|
||||
func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Producer, opts ...Option) *Service {
|
||||
initMetrics()
|
||||
svc := &Service{
|
||||
logger: logger.Named("documents"),
|
||||
storage: repo,
|
||||
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) startDiscoveryAnnouncer() {
|
||||
if s == nil || s.producer == nil {
|
||||
return
|
||||
}
|
||||
announce := discovery.Announcement{
|
||||
Service: "BILLING_DOCUMENTS",
|
||||
Operations: []string{"documents.batch_resolve", "documents.get"},
|
||||
InvokeURI: s.invokeURI,
|
||||
Version: appversion.Create().Short(),
|
||||
}
|
||||
s.announcer = discovery.NewAnnouncer(s.logger, s.producer, string(mservice.BillingDocuments), announce)
|
||||
s.announcer.Start()
|
||||
}
|
||||
|
||||
func (s *Service) BatchResolveDocuments(ctx context.Context, req *documentsv1.BatchResolveDocumentsRequest) (resp *documentsv1.BatchResolveDocumentsResponse, err error) {
|
||||
start := time.Now()
|
||||
var paymentRefs []string
|
||||
if req != nil {
|
||||
paymentRefs = req.GetPaymentRefs()
|
||||
}
|
||||
logger := s.logger.With(zap.Int("payment_refs", len(paymentRefs)))
|
||||
defer func() {
|
||||
statusLabel := statusFromError(err)
|
||||
observeRequest("batch_resolve", documentsv1.DocumentType_DOCUMENT_TYPE_UNSPECIFIED, statusLabel, time.Since(start))
|
||||
observeBatchSize(len(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...)
|
||||
}()
|
||||
|
||||
if len(paymentRefs) == 0 {
|
||||
resp = &documentsv1.BatchResolveDocumentsResponse{}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
if s.storage == nil {
|
||||
err = status.Error(codes.Unavailable, errStorageUnavailable.Error())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
refs := make([]string, 0, len(paymentRefs))
|
||||
for _, ref := range paymentRefs {
|
||||
clean := strings.TrimSpace(ref)
|
||||
if clean == "" {
|
||||
continue
|
||||
}
|
||||
refs = append(refs, clean)
|
||||
}
|
||||
if len(refs) == 0 {
|
||||
resp = &documentsv1.BatchResolveDocumentsResponse{}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
records, err := s.storage.Documents().ListByPaymentRefs(ctx, refs)
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.Internal, err.Error())
|
||||
}
|
||||
|
||||
recordByRef := map[string]*model.DocumentRecord{}
|
||||
for _, record := range records {
|
||||
if record == nil {
|
||||
continue
|
||||
}
|
||||
recordByRef[record.PaymentRef] = record
|
||||
}
|
||||
|
||||
items := make([]*documentsv1.DocumentMeta, 0, len(refs))
|
||||
for _, ref := range refs {
|
||||
meta := &documentsv1.DocumentMeta{PaymentRef: ref}
|
||||
if record := recordByRef[ref]; record != nil {
|
||||
record.Normalize()
|
||||
available := []model.DocumentType{model.DocumentTypeAct}
|
||||
ready := make([]model.DocumentType, 0, 1)
|
||||
if path, ok := record.StoragePaths[model.DocumentTypeAct]; ok && path != "" {
|
||||
ready = append(ready, model.DocumentTypeAct)
|
||||
}
|
||||
meta.AvailableTypes = toProtoTypes(available)
|
||||
meta.ReadyTypes = toProtoTypes(ready)
|
||||
}
|
||||
items = append(items, meta)
|
||||
}
|
||||
|
||||
resp = &documentsv1.BatchResolveDocumentsResponse{Items: items}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
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...)
|
||||
}()
|
||||
|
||||
if paymentRef == "" {
|
||||
err = status.Error(codes.InvalidArgument, "payment_ref is required")
|
||||
return nil, err
|
||||
}
|
||||
if docType == documentsv1.DocumentType_DOCUMENT_TYPE_UNSPECIFIED {
|
||||
err = status.Error(codes.InvalidArgument, "document type is required")
|
||||
return nil, err
|
||||
}
|
||||
if s.storage == nil {
|
||||
err = status.Error(codes.Unavailable, errStorageUnavailable.Error())
|
||||
return nil, err
|
||||
}
|
||||
if s.docStore == nil {
|
||||
err = status.Error(codes.Unavailable, errDocStoreUnavailable.Error())
|
||||
return nil, err
|
||||
}
|
||||
if s.template == nil {
|
||||
err = status.Error(codes.FailedPrecondition, errTemplateUnavailable.Error())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
record, err := s.storage.Documents().GetByPaymentRef(ctx, paymentRef)
|
||||
if err != nil {
|
||||
if errors.Is(err, storage.ErrDocumentNotFound) {
|
||||
return nil, status.Error(codes.NotFound, "document record not found")
|
||||
}
|
||||
return nil, status.Error(codes.Internal, err.Error())
|
||||
}
|
||||
record.Normalize()
|
||||
|
||||
targetType := model.DocumentTypeFromProto(docType)
|
||||
|
||||
if docType != documentsv1.DocumentType_DOCUMENT_TYPE_ACT {
|
||||
return nil, status.Error(codes.Unimplemented, "document type not implemented")
|
||||
}
|
||||
|
||||
if path, ok := record.StoragePaths[targetType]; ok && path != "" {
|
||||
content, loadErr := s.docStore.Load(ctx, path)
|
||||
if loadErr != nil {
|
||||
return nil, status.Error(codes.Internal, loadErr.Error())
|
||||
}
|
||||
return &documentsv1.GetDocumentResponse{
|
||||
Content: content,
|
||||
Filename: documentFilename(docType, paymentRef),
|
||||
MimeType: "application/pdf",
|
||||
}, nil
|
||||
}
|
||||
|
||||
content, hash, genErr := s.generateActPDF(record.Snapshot)
|
||||
if genErr != nil {
|
||||
logger.Warn("Failed to generate document", zap.Error(genErr))
|
||||
return nil, status.Error(codes.Internal, genErr.Error())
|
||||
}
|
||||
|
||||
path := documentStoragePath(paymentRef, docType)
|
||||
if saveErr := s.docStore.Save(ctx, path, content); saveErr != nil {
|
||||
logger.Warn("Failed to store document", zap.Error(saveErr))
|
||||
return nil, status.Error(codes.Internal, saveErr.Error())
|
||||
}
|
||||
|
||||
record.StoragePaths[targetType] = path
|
||||
record.Hashes[targetType] = hash
|
||||
if updateErr := s.storage.Documents().Update(ctx, record); updateErr != nil {
|
||||
logger.Warn("Failed to update document record", zap.Error(updateErr))
|
||||
return nil, status.Error(codes.Internal, updateErr.Error())
|
||||
}
|
||||
|
||||
resp = &documentsv1.GetDocumentResponse{
|
||||
Content: content,
|
||||
Filename: documentFilename(docType, paymentRef),
|
||||
MimeType: "application/pdf",
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
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"
|
||||
}
|
||||
return fmt.Sprintf("%s_%s.pdf", name, paymentRef)
|
||||
}
|
||||
195
api/billing/documents/internal/service/documents/service_test.go
Normal file
195
api/billing/documents/internal/service/documents/service_test.go
Normal file
@@ -0,0 +1,195 @@
|
||||
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"
|
||||
)
|
||||
|
||||
type stubRepo struct {
|
||||
store storage.DocumentsStore
|
||||
}
|
||||
|
||||
func (s *stubRepo) Ping(ctx 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(ctx context.Context, record *model.DocumentRecord) error {
|
||||
s.record = record
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubDocumentsStore) Update(ctx context.Context, record *model.DocumentRecord) error {
|
||||
s.record = record
|
||||
s.updateCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubDocumentsStore) GetByPaymentRef(ctx context.Context, paymentRef string) (*model.DocumentRecord, error) {
|
||||
return s.record, nil
|
||||
}
|
||||
|
||||
func (s *stubDocumentsStore) ListByPaymentRefs(ctx context.Context, paymentRefs []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(ctx context.Context, key string, data []byte) error {
|
||||
m.saveCount++
|
||||
copyData := make([]byte, len(data))
|
||||
copy(copyData, data)
|
||||
m.data[key] = copyData
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memDocStore) Load(ctx context.Context, key string) ([]byte, error) {
|
||||
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(snapshot model.ActSnapshot) ([]renderer.Block, error) {
|
||||
s.calls++
|
||||
return s.blocks, nil
|
||||
}
|
||||
|
||||
func TestGetDocument_IdempotentAndHashed(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
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",
|
||||
}
|
||||
|
||||
record := &model.DocumentRecord{
|
||||
PaymentRef: "PAY-123",
|
||||
Snapshot: snapshot,
|
||||
}
|
||||
|
||||
documentsStore := &stubDocumentsStore{record: record}
|
||||
repo := &stubRepo{store: documentsStore}
|
||||
store := newMemDocStore()
|
||||
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(), repo, nil,
|
||||
WithConfig(cfg),
|
||||
WithDocumentStore(store),
|
||||
WithTemplateRenderer(tmpl),
|
||||
)
|
||||
|
||||
resp1, err := svc.GetDocument(ctx, &documentsv1.GetDocumentRequest{
|
||||
PaymentRef: "PAY-123",
|
||||
Type: documentsv1.DocumentType_DOCUMENT_TYPE_ACT,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GetDocument first call: %v", err)
|
||||
}
|
||||
if len(resp1.Content) == 0 {
|
||||
t.Fatalf("expected content on first call")
|
||||
}
|
||||
|
||||
stored := record.Hashes[model.DocumentTypeAct]
|
||||
if stored == "" {
|
||||
t.Fatalf("expected stored hash")
|
||||
}
|
||||
footerHash := extractFooterHash(resp1.Content)
|
||||
if footerHash == "" {
|
||||
t.Fatalf("expected footer hash in PDF")
|
||||
}
|
||||
if stored != footerHash {
|
||||
t.Fatalf("stored hash mismatch: got %s", stored)
|
||||
}
|
||||
|
||||
resp2, err := svc.GetDocument(ctx, &documentsv1.GetDocumentRequest{
|
||||
PaymentRef: "PAY-123",
|
||||
Type: documentsv1.DocumentType_DOCUMENT_TYPE_ACT,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GetDocument second call: %v", err)
|
||||
}
|
||||
if !bytes.Equal(resp1.Content, resp2.Content) {
|
||||
t.Fatalf("expected identical PDF bytes on second call")
|
||||
}
|
||||
|
||||
if tmpl.calls != 1 {
|
||||
t.Fatalf("expected template to be rendered once, got %d", tmpl.calls)
|
||||
}
|
||||
if store.saveCount != 1 {
|
||||
t.Fatalf("expected document save once, got %d", store.saveCount)
|
||||
}
|
||||
if store.loadCount == 0 {
|
||||
t.Fatalf("expected document load on second call")
|
||||
}
|
||||
}
|
||||
|
||||
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')
|
||||
}
|
||||
60
api/billing/documents/internal/service/documents/template.go
Normal file
60
api/billing/documents/internal/service/documents/template.go
Normal file
@@ -0,0 +1,60 @@
|
||||
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,89 @@
|
||||
package documents
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"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")
|
||||
}
|
||||
foundTitle := false
|
||||
for _, line := range title.Lines {
|
||||
if line == "ACT OF ACCEPTANCE OF SERVICES" {
|
||||
foundTitle = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundTitle {
|
||||
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)
|
||||
}
|
||||
50
api/billing/documents/renderer/header.go
Normal file
50
api/billing/documents/renderer/header.go
Normal file
@@ -0,0 +1,50 @@
|
||||
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
|
||||
}
|
||||
221
api/billing/documents/renderer/layout.go
Normal file
221
api/billing/documents/renderer/layout.go
Normal file
@@ -0,0 +1,221 @@
|
||||
package renderer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"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 := fmt.Sprintf("Document integrity hash: %s", 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
|
||||
}
|
||||
90
api/billing/documents/renderer/renderer_test.go
Normal file
90
api/billing/documents/renderer/renderer_test.go
Normal file
@@ -0,0 +1,90 @@
|
||||
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
|
||||
}
|
||||
87
api/billing/documents/renderer/tags.go
Normal file
87
api/billing/documents/renderer/tags.go
Normal file
@@ -0,0 +1,87 @@
|
||||
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 {
|
||||
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
|
||||
}
|
||||
86
api/billing/documents/storage/model/document.go
Normal file
86
api/billing/documents/storage/model/document.go
Normal file
@@ -0,0 +1,86 @@
|
||||
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
|
||||
}
|
||||
68
api/billing/documents/storage/mongo/repository.go
Normal file
68
api/billing/documents/storage/mongo/repository.go
Normal file
@@ -0,0 +1,68 @@
|
||||
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)
|
||||
139
api/billing/documents/storage/mongo/store/documents.go
Normal file
139
api/billing/documents/storage/mongo/store/documents.go
Normal file
@@ -0,0 +1,139 @@
|
||||
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 = "./../.."
|
||||
root = "."
|
||||
testdata_dir = "testdata"
|
||||
tmp_dir = "tmp"
|
||||
|
||||
[build]
|
||||
cmd = "go build -o app -ldflags \"-X 'github.com/tech/sendico/billing/fees/internal/appversion.BuildUser=$(whoami)' -X 'github.com/tech/sendico/billing/fees/internal/appversion.Version=$APP_V' -X 'github.com/tech/sendico/billing/fees/internal/appversion.Branch=$BUILD_BRANCH' -X 'github.com/tech/sendico/billing/fees/internal/appversion.Revision=$GIT_REV' -X 'github.com/tech/sendico/billing/fees/internal/appversion.BuildDate=$(date)'\""
|
||||
bin = "./app"
|
||||
full_bin = "./app --debug --config.file=config.yml"
|
||||
include_ext = ["go", "yaml", "yml"]
|
||||
exclude_dir = ["billing/fees/tmp", "pkg/.git", "billing/fees/env"]
|
||||
exclude_regex = ["_test\\.go"]
|
||||
exclude_unchanged = true
|
||||
follow_symlink = true
|
||||
log = "air.log"
|
||||
delay = 0
|
||||
stop_on_error = true
|
||||
send_interrupt = true
|
||||
kill_delay = 500
|
||||
args_bin = []
|
||||
|
||||
[log]
|
||||
time = false
|
||||
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]
|
||||
main = "magenta"
|
||||
watcher = "cyan"
|
||||
build = "yellow"
|
||||
runner = "green"
|
||||
app = ""
|
||||
build = "yellow"
|
||||
main = "magenta"
|
||||
runner = "green"
|
||||
watcher = "cyan"
|
||||
|
||||
[log]
|
||||
main_only = false
|
||||
time = false
|
||||
|
||||
[misc]
|
||||
clean_on_exit = true
|
||||
clean_on_exit = false
|
||||
|
||||
[screen]
|
||||
clear_on_rebuild = false
|
||||
keep_scroll = true
|
||||
|
||||
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:
|
||||
network: tcp
|
||||
address: ":50060"
|
||||
advertise_host: "sendico_billing_fees"
|
||||
enable_reflection: true
|
||||
enable_health: true
|
||||
|
||||
@@ -32,6 +33,7 @@ messaging:
|
||||
broker_name: Billing Fees Service
|
||||
max_reconnects: 10
|
||||
reconnect_wait: 5
|
||||
buffer_size: 1024
|
||||
|
||||
oracle:
|
||||
address: "sendico_fx_oracle:50051"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module github.com/tech/sendico/billing/fees
|
||||
|
||||
go 1.25.3
|
||||
go 1.25.7
|
||||
|
||||
replace github.com/tech/sendico/pkg => ../../pkg
|
||||
|
||||
@@ -9,34 +9,35 @@ replace github.com/tech/sendico/fx/oracle => ../../fx/oracle
|
||||
require (
|
||||
github.com/tech/sendico/fx/oracle v0.0.0
|
||||
github.com/tech/sendico/pkg v0.1.0
|
||||
go.mongodb.org/mongo-driver v1.17.6
|
||||
go.uber.org/zap v1.27.1
|
||||
google.golang.org/grpc v1.77.0
|
||||
google.golang.org/grpc v1.78.0
|
||||
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 (
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
|
||||
github.com/casbin/casbin/v2 v2.134.0 // indirect
|
||||
github.com/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/v3 v3.7.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/go-chi/chi/v5 v5.2.3 // indirect
|
||||
github.com/golang/snappy v1.0.0 // indirect
|
||||
github.com/go-chi/chi/v5 v5.2.5 // 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-isatty v0.0.20 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/montanaflynn/stats v0.7.1 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/nats-io/nats.go v1.47.0 // indirect
|
||||
github.com/nats-io/nkeys v0.4.12 // indirect
|
||||
github.com/nats-io/nats.go v1.48.0 // indirect
|
||||
github.com/nats-io/nkeys v0.4.15 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.67.4 // indirect
|
||||
github.com/prometheus/common v0.67.5 // indirect
|
||||
github.com/prometheus/procfs v0.19.2 // indirect
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||
github.com/xdg-go/scram v1.2.0 // indirect
|
||||
@@ -44,11 +45,11 @@ require (
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
golang.org/x/crypto v0.45.0 // indirect
|
||||
golang.org/x/net v0.47.0 // indirect
|
||||
golang.org/x/sync v0.18.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/text v0.31.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
||||
google.golang.org/protobuf v1.36.10
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/net v0.50.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-20260209200024-4cfbd4190f57 // indirect
|
||||
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/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
|
||||
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||
github.com/casbin/casbin/v2 v2.134.0 h1:wyO3hZb487GzlGVAI2hUoHQT0ehFD+9B5P+HVG9BVTM=
|
||||
github.com/casbin/casbin/v2 v2.134.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
|
||||
github.com/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/v3 v3.7.0 h1:w9c3bea1BGK4eZTAmk17JkY52yv/xSZDSHKji8q+z6E=
|
||||
github.com/casbin/mongodb-adapter/v3 v3.7.0/go.mod h1:F1mu4ojoJVE/8VhIMxMedhjfwRDdIXgANYs6Sd0MgVA=
|
||||
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=
|
||||
@@ -38,8 +38,8 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
|
||||
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 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=
|
||||
@@ -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/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
|
||||
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
|
||||
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/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=
|
||||
@@ -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/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
||||
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
|
||||
github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE=
|
||||
github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
|
||||
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/nats-io/nats.go v1.47.0 h1:YQdADw6J/UfGUd2Oy6tn4Hq6YHxCaJrVKayxxFqYrgM=
|
||||
github.com/nats-io/nats.go v1.47.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
||||
github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
|
||||
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
|
||||
github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
|
||||
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
||||
github.com/nats-io/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=
|
||||
@@ -115,8 +111,8 @@ 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_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc=
|
||||
github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI=
|
||||
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
|
||||
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
||||
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
@@ -150,8 +146,8 @@ 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/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
go.mongodb.org/mongo-driver v1.17.6 h1:87JUG1wZfWsr6rIz3ZmpH90rL5tea7O3IHuSwHUpsss=
|
||||
go.mongodb.org/mongo-driver v1.17.6/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
|
||||
go.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=
|
||||
@@ -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=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
golang.org/x/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.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/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.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
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=
|
||||
@@ -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=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
||||
google.golang.org/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=
|
||||
|
||||
@@ -26,6 +26,7 @@ type Imp struct {
|
||||
config *config
|
||||
app *grpcapp.App[storage.Repository]
|
||||
oracleClient oracleclient.Client
|
||||
service *fees.Service
|
||||
}
|
||||
|
||||
type config struct {
|
||||
@@ -65,6 +66,9 @@ func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) {
|
||||
|
||||
func (i *Imp) Shutdown() {
|
||||
if i.app == nil {
|
||||
if i.service != nil {
|
||||
i.service.Shutdown()
|
||||
}
|
||||
if i.oracleClient != nil {
|
||||
_ = i.oracleClient.Close()
|
||||
}
|
||||
@@ -76,6 +80,10 @@ func (i *Imp) Shutdown() {
|
||||
timeout = i.config.Runtime.ShutdownTimeout()
|
||||
}
|
||||
|
||||
if i.service != nil {
|
||||
i.service.Shutdown()
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
i.app.Shutdown(ctx)
|
||||
cancel()
|
||||
@@ -121,7 +129,14 @@ func (i *Imp) Start() error {
|
||||
if oracleClient != nil {
|
||||
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)
|
||||
|
||||
@@ -2,448 +2,16 @@ package fees
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"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"
|
||||
)
|
||||
|
||||
// Calculator isolates fee rule evaluation logic so it can be reused and tested.
|
||||
// Implementation lives under internal/service/fees/internal/calculator.
|
||||
type Calculator interface {
|
||||
Compute(ctx context.Context, plan *model.FeePlan, intent *feesv1.Intent, bookedAt time.Time, trace *tracev1.TraceContext) (*CalculationResult, error)
|
||||
}
|
||||
|
||||
// CalculationResult contains derived fee lines and audit metadata.
|
||||
type CalculationResult struct {
|
||||
Lines []*feesv1.DerivedPostingLine
|
||||
Applied []*feesv1.AppliedRule
|
||||
FxUsed *feesv1.FXUsed
|
||||
}
|
||||
|
||||
// quoteCalculator is the default Calculator implementation.
|
||||
type fxOracle interface {
|
||||
LatestRate(ctx context.Context, req oracleclient.LatestRateParams) (*oracleclient.RateSnapshot, error)
|
||||
}
|
||||
|
||||
type quoteCalculator struct {
|
||||
logger mlogger.Logger
|
||||
oracle fxOracle
|
||||
}
|
||||
|
||||
func newQuoteCalculator(logger mlogger.Logger, oracle fxOracle) Calculator {
|
||||
return "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
|
||||
}
|
||||
Compute(ctx context.Context, plan *model.FeePlan, intent *feesv1.Intent, bookedAt time.Time, trace *tracev1.TraceContext) (*types.CalculationResult, error)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,462 @@
|
||||
package calculator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"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) {
|
||||
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)
|
||||
|
||||
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 {
|
||||
// Default fees to debit (i.e. charge the customer) when entry side is not specified.
|
||||
entrySide = accountingv1.EntrySide_ENTRY_SIDE_DEBIT
|
||||
}
|
||||
|
||||
meta := map[string]string{
|
||||
"fee_rule_id": rule.RuleID,
|
||||
}
|
||||
if planID != "" {
|
||||
meta["fee_plan_id"] = planID
|
||||
}
|
||||
if rule.Metadata != nil {
|
||||
if taxCode := strings.TrimSpace(rule.Metadata["tax_code"]); taxCode != "" {
|
||||
meta["tax_code"] = taxCode
|
||||
}
|
||||
if taxRate := strings.TrimSpace(rule.Metadata["tax_rate"]); taxRate != "" {
|
||||
meta["tax_rate"] = taxRate
|
||||
}
|
||||
}
|
||||
|
||||
lines = append(lines, &feesv1.DerivedPostingLine{
|
||||
LedgerAccountRef: ledgerAccountRef,
|
||||
Money: &moneyv1.Money{
|
||||
Amount: dmath.FormatRat(amount, scale),
|
||||
Currency: currency,
|
||||
},
|
||||
LineType: mapLineType(rule.LineType),
|
||||
Side: entrySide,
|
||||
Meta: meta,
|
||||
})
|
||||
|
||||
applied = append(applied, &feesv1.AppliedRule{
|
||||
RuleId: rule.RuleID,
|
||||
RuleVersion: planID,
|
||||
Formula: rule.Formula,
|
||||
Rounding: mapRoundingMode(rule.Rounding),
|
||||
TaxCode: metadataValue(rule.Metadata, "tax_code"),
|
||||
TaxRate: metadataValue(rule.Metadata, "tax_rate"),
|
||||
Parameters: cloneStringMap(rule.Metadata),
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
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
|
||||
}
|
||||
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 == ""
|
||||
}
|
||||
|
||||
values := strings.Split(trimmed, ",")
|
||||
for _, value := range values {
|
||||
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_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
|
||||
)
|
||||
235
api/billing/fees/internal/service/fees/internal/resolver/impl.go
Normal file
235
api/billing/fees/internal/service/fees/internal/resolver/impl.go
Normal file
@@ -0,0 +1,235 @@
|
||||
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/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 *zap.Logger
|
||||
}
|
||||
|
||||
func New(plans storage.PlansStore, logger *zap.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, at time.Time, attrs map[string]string) (*model.FeePlan, *model.FeeRule, error) {
|
||||
if r.plans == nil {
|
||||
return nil, nil, merrors.InvalidArgument("fees: plans store is required")
|
||||
}
|
||||
|
||||
// Try org-specific first if provided.
|
||||
if orgRef != nil && !orgRef.IsZero() {
|
||||
if plan, err := r.getOrgPlan(ctx, *orgRef, at); err == nil {
|
||||
if rule, selErr := selectRule(plan, trigger, at, attrs); selErr == nil {
|
||||
return plan, rule, nil
|
||||
} else if !errors.Is(selErr, ErrNoFeeRuleFound) {
|
||||
r.logger.Warn("Failed selecting rule for org plan", zap.Error(selErr), mzap.ObjRef("org_ref", *orgRef))
|
||||
return nil, nil, selErr
|
||||
}
|
||||
orgFields := []zap.Field{
|
||||
mzap.ObjRef("org_ref", *orgRef),
|
||||
zap.String("trigger", string(trigger)),
|
||||
zap.Time("booked_at", at),
|
||||
zap.Any("attributes", attrs),
|
||||
}
|
||||
orgFields = append(orgFields, zapFieldsForPlan(plan)...)
|
||||
r.logger.Debug("No matching rule in org plan; falling back to global", orgFields...)
|
||||
} else if !errors.Is(err, storage.ErrFeePlanNotFound) {
|
||||
r.logger.Warn("Failed resolving org fee plan", zap.Error(err), mzap.ObjRef("org_ref", *orgRef))
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
plan, err := r.getGlobalPlan(ctx, at)
|
||||
if err != nil {
|
||||
if errors.Is(err, storage.ErrFeePlanNotFound) {
|
||||
r.logger.Debug("No applicable global fee plan found", zap.String("trigger", string(trigger)),
|
||||
zap.Time("booked_at", at), zap.Any("attributes", attrs),
|
||||
)
|
||||
return nil, nil, merrors.NoData("fees: no applicable fee rule found")
|
||||
}
|
||||
r.logger.Warn("Failed resolving global fee plan", zap.Error(err))
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
rule, err := selectRule(plan, trigger, at, attrs)
|
||||
if err != nil {
|
||||
if !errors.Is(err, ErrNoFeeRuleFound) {
|
||||
r.logger.Warn("Failed selecting rule in global plan", zap.Error(err))
|
||||
} else {
|
||||
globalFields := []zap.Field{
|
||||
zap.String("trigger", string(trigger)),
|
||||
zap.Time("booked_at", at),
|
||||
zap.Any("attributes", attrs),
|
||||
}
|
||||
globalFields = append(globalFields, zapFieldsForPlan(plan)...)
|
||||
r.logger.Debug("No matching rule in global plan", globalFields...)
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
selectedFields := []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 {
|
||||
selectedFields = append(selectedFields, zap.Time("rule_effective_to", *rule.EffectiveTo))
|
||||
}
|
||||
if orgRef != nil && !orgRef.IsZero() {
|
||||
selectedFields = append(selectedFields, mzap.ObjRef("org_ref", *orgRef))
|
||||
}
|
||||
selectedFields = append(selectedFields, zapFieldsForPlan(plan)...)
|
||||
r.logger.Debug("Selected fee rule", selectedFields...)
|
||||
|
||||
return plan, rule, nil
|
||||
}
|
||||
|
||||
func (r *feeResolver) getOrgPlan(ctx context.Context, orgRef bson.ObjectID, at time.Time) (*model.FeePlan, error) {
|
||||
if r.finder != nil {
|
||||
return r.finder.FindActiveOrgPlan(ctx, orgRef, at)
|
||||
}
|
||||
return r.plans.GetActivePlan(ctx, orgRef, at)
|
||||
}
|
||||
|
||||
func (r *feeResolver) getGlobalPlan(ctx context.Context, at time.Time) (*model.FeePlan, error) {
|
||||
if r.finder != nil {
|
||||
return r.finder.FindActiveGlobalPlan(ctx, at)
|
||||
}
|
||||
// Treat zero ObjectID as global in legacy path.
|
||||
return r.plans.GetActivePlan(ctx, bson.NilObjectID, at)
|
||||
}
|
||||
|
||||
func selectRule(plan *model.FeePlan, trigger model.Trigger, at time.Time, attrs map[string]string) (*model.FeeRule, error) {
|
||||
if plan == nil {
|
||||
return nil, merrors.NoData("fees: no applicable fee rule found")
|
||||
}
|
||||
|
||||
var selected *model.FeeRule
|
||||
var highestPriority int
|
||||
for _, rule := range plan.Rules {
|
||||
if rule.Trigger != trigger {
|
||||
continue
|
||||
}
|
||||
if rule.EffectiveFrom.After(at) {
|
||||
continue
|
||||
}
|
||||
if rule.EffectiveTo != nil && !rule.EffectiveTo.After(at) {
|
||||
continue
|
||||
}
|
||||
if !matchesAppliesTo(rule.AppliesTo, attrs) {
|
||||
continue
|
||||
}
|
||||
|
||||
if selected == nil || rule.Priority > highestPriority {
|
||||
copy := rule
|
||||
selected = ©
|
||||
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 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 == ""
|
||||
}
|
||||
|
||||
values := strings.Split(trimmed, ",")
|
||||
for _, value := range values {
|
||||
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,355 @@
|
||||
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.Helper()
|
||||
|
||||
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.Helper()
|
||||
|
||||
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.Helper()
|
||||
|
||||
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.Helper()
|
||||
|
||||
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.Helper()
|
||||
|
||||
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.Helper()
|
||||
|
||||
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.Helper()
|
||||
|
||||
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.Helper()
|
||||
|
||||
now := time.Now()
|
||||
org := bson.NewObjectID()
|
||||
p1 := &model.FeePlan{
|
||||
Active: true,
|
||||
EffectiveFrom: now.Add(-time.Hour),
|
||||
Rules: []model.FeeRule{
|
||||
{RuleID: "r1", Trigger: model.TriggerCapture, Priority: 10, Percentage: "0.05", EffectiveFrom: now.Add(-time.Hour)},
|
||||
},
|
||||
}
|
||||
p1.OrganizationRef = &org
|
||||
p2 := &model.FeePlan{
|
||||
Active: true,
|
||||
EffectiveFrom: now.Add(-30 * time.Minute),
|
||||
Rules: []model.FeeRule{
|
||||
{RuleID: "r2", Trigger: model.TriggerCapture, Priority: 20, Percentage: "0.03", EffectiveFrom: now.Add(-time.Hour)},
|
||||
},
|
||||
}
|
||||
p2.OrganizationRef = &org
|
||||
|
||||
store := &memoryPlansStore{plans: []*model.FeePlan{p1, p2}}
|
||||
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, at time.Time) (*model.FeePlan, error) {
|
||||
if !orgRef.IsZero() {
|
||||
if plan, err := m.FindActiveOrgPlan(ctx, orgRef, at); err == nil {
|
||||
return plan, nil
|
||||
} else if !errors.Is(err, storage.ErrFeePlanNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return m.FindActiveGlobalPlan(ctx, at)
|
||||
}
|
||||
|
||||
func (m *memoryPlansStore) FindActiveOrgPlan(_ context.Context, orgRef bson.ObjectID, at time.Time) (*model.FeePlan, error) {
|
||||
var matches []*model.FeePlan
|
||||
for _, plan := range m.plans {
|
||||
if plan == nil || plan.OrganizationRef == nil || plan.OrganizationRef.IsZero() || (*plan.OrganizationRef != orgRef) {
|
||||
continue
|
||||
}
|
||||
if !plan.Active {
|
||||
continue
|
||||
}
|
||||
if plan.EffectiveFrom.After(at) {
|
||||
continue
|
||||
}
|
||||
if plan.EffectiveTo != nil && !plan.EffectiveTo.After(at) {
|
||||
continue
|
||||
}
|
||||
matches = append(matches, plan)
|
||||
}
|
||||
if len(matches) == 0 {
|
||||
return nil, storage.ErrFeePlanNotFound
|
||||
}
|
||||
if len(matches) > 1 {
|
||||
return nil, storage.ErrConflictingFeePlans
|
||||
}
|
||||
return matches[0], nil
|
||||
}
|
||||
|
||||
func (m *memoryPlansStore) FindActiveGlobalPlan(_ context.Context, at time.Time) (*model.FeePlan, error) {
|
||||
var matches []*model.FeePlan
|
||||
for _, plan := range m.plans {
|
||||
if plan == nil || ((plan.OrganizationRef != nil) && !plan.OrganizationRef.IsZero()) {
|
||||
continue
|
||||
}
|
||||
if !plan.Active {
|
||||
continue
|
||||
}
|
||||
if plan.EffectiveFrom.After(at) {
|
||||
continue
|
||||
}
|
||||
if plan.EffectiveTo != nil && !plan.EffectiveTo.After(at) {
|
||||
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)
|
||||
88
api/billing/fees/internal/service/fees/logging.go
Normal file
88
api/billing/fees/internal/service/fees/logging.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package fees
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func requestLogFields(meta *feesv1.RequestMeta, intent *feesv1.Intent) []zap.Field {
|
||||
fields := logFieldsFromRequestMeta(meta)
|
||||
fields = append(fields, logFieldsFromIntent(intent)...)
|
||||
return fields
|
||||
}
|
||||
|
||||
func logFieldsFromRequestMeta(meta *feesv1.RequestMeta) []zap.Field {
|
||||
if meta == nil {
|
||||
return nil
|
||||
}
|
||||
fields := make([]zap.Field, 0, 4)
|
||||
if org := strings.TrimSpace(meta.GetOrganizationRef()); org != "" {
|
||||
fields = append(fields, zap.String("organization_ref", org))
|
||||
}
|
||||
fields = append(fields, logFieldsFromTrace(meta.GetTrace())...)
|
||||
return fields
|
||||
}
|
||||
|
||||
func logFieldsFromIntent(intent *feesv1.Intent) []zap.Field {
|
||||
if intent == nil {
|
||||
return nil
|
||||
}
|
||||
fields := make([]zap.Field, 0, 5)
|
||||
if trigger := intent.GetTrigger(); trigger != feesv1.Trigger_TRIGGER_UNSPECIFIED {
|
||||
fields = append(fields, zap.String("trigger", trigger.String()))
|
||||
}
|
||||
if base := intent.GetBaseAmount(); base != nil {
|
||||
if amount := strings.TrimSpace(base.GetAmount()); amount != "" {
|
||||
fields = append(fields, zap.String("base_amount", amount))
|
||||
}
|
||||
if currency := strings.TrimSpace(base.GetCurrency()); currency != "" {
|
||||
fields = append(fields, zap.String("base_currency", currency))
|
||||
}
|
||||
}
|
||||
if booked := intent.GetBookedAt(); booked != nil && booked.IsValid() {
|
||||
fields = append(fields, zap.Time("booked_at", booked.AsTime()))
|
||||
}
|
||||
if attrs := intent.GetAttributes(); len(attrs) > 0 {
|
||||
fields = append(fields, zap.Int("attributes_count", len(attrs)))
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
func logFieldsFromTrace(trace *tracev1.TraceContext) []zap.Field {
|
||||
if trace == nil {
|
||||
return nil
|
||||
}
|
||||
fields := make([]zap.Field, 0, 3)
|
||||
if reqRef := strings.TrimSpace(trace.GetRequestRef()); reqRef != "" {
|
||||
fields = append(fields, zap.String("request_ref", reqRef))
|
||||
}
|
||||
if idem := strings.TrimSpace(trace.GetIdempotencyKey()); idem != "" {
|
||||
fields = append(fields, zap.String("idempotency_key", idem))
|
||||
}
|
||||
if traceRef := strings.TrimSpace(trace.GetTraceRef()); traceRef != "" {
|
||||
fields = append(fields, zap.String("trace_ref", traceRef))
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
func logFieldsFromTokenPayload(payload *feeQuoteTokenPayload) []zap.Field {
|
||||
if payload == nil {
|
||||
return nil
|
||||
}
|
||||
fields := make([]zap.Field, 0, 6)
|
||||
if org := strings.TrimSpace(payload.OrganizationRef); org != "" {
|
||||
fields = append(fields, zap.String("organization_ref", org))
|
||||
}
|
||||
if payload.ExpiresAtUnixMs > 0 {
|
||||
fields = append(fields,
|
||||
zap.Int64("expires_at_unix_ms", payload.ExpiresAtUnixMs),
|
||||
zap.Time("expires_at", time.UnixMilli(payload.ExpiresAtUnixMs)))
|
||||
}
|
||||
fields = append(fields, logFieldsFromIntent(payload.Intent)...)
|
||||
fields = append(fields, logFieldsFromTrace(payload.Trace)...)
|
||||
return fields
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
package fees
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
internalcalculator "github.com/tech/sendico/billing/fees/internal/service/fees/internal/calculator"
|
||||
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
||||
clockpkg "github.com/tech/sendico/pkg/clock"
|
||||
)
|
||||
@@ -30,8 +33,25 @@ func WithCalculator(calculator Calculator) Option {
|
||||
func WithOracleClient(oracle oracleclient.Client) Option {
|
||||
return func(s *Service) {
|
||||
s.oracle = oracle
|
||||
if qc, ok := s.calculator.(*quoteCalculator); ok {
|
||||
qc.oracle = oracle
|
||||
// Rebuild default calculator if none was injected.
|
||||
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)
|
||||
}
|
||||
@@ -5,19 +5,26 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"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/model"
|
||||
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
||||
"github.com/tech/sendico/pkg/api/routers"
|
||||
clockpkg "github.com/tech/sendico/pkg/clock"
|
||||
"github.com/tech/sendico/pkg/discovery"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
msg "github.com/tech/sendico/pkg/messaging"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
@@ -32,6 +39,9 @@ type Service struct {
|
||||
clock clockpkg.Clock
|
||||
calculator Calculator
|
||||
oracle oracleclient.Client
|
||||
resolver FeeResolver
|
||||
announcer *discovery.Announcer
|
||||
invokeURI string
|
||||
feesv1.UnimplementedFeeEngineServer
|
||||
}
|
||||
|
||||
@@ -52,8 +62,13 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro
|
||||
svc.clock = clockpkg.NewSystem()
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -64,27 +79,81 @@ func (s *Service) Register(router routers.GRPC) error {
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) Shutdown() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
if s.announcer != nil {
|
||||
s.announcer.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) startDiscoveryAnnouncer() {
|
||||
if s == nil || s.producer == nil {
|
||||
return
|
||||
}
|
||||
announce := discovery.Announcement{
|
||||
Service: "BILLING_FEES",
|
||||
Operations: []string{"fee.calc"},
|
||||
InvokeURI: s.invokeURI,
|
||||
Version: appversion.Create().Short(),
|
||||
}
|
||||
s.announcer = discovery.NewAnnouncer(s.logger, s.producer, string(mservice.FeePlans), announce)
|
||||
s.announcer.Start()
|
||||
}
|
||||
|
||||
func (s *Service) QuoteFees(ctx context.Context, req *feesv1.QuoteFeesRequest) (resp *feesv1.QuoteFeesResponse, err error) {
|
||||
var (
|
||||
meta *feesv1.RequestMeta
|
||||
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 req != nil && req.GetIntent() != nil {
|
||||
trigger = req.GetIntent().GetTrigger()
|
||||
if intent != nil {
|
||||
trigger = intent.GetTrigger()
|
||||
}
|
||||
var fxUsed bool
|
||||
defer func() {
|
||||
statusLabel := statusFromError(err)
|
||||
linesCount := 0
|
||||
appliedCount := 0
|
||||
if err == nil && resp != nil {
|
||||
fxUsed = resp.GetFxUsed() != nil
|
||||
linesCount = len(resp.GetLines())
|
||||
appliedCount = len(resp.GetApplied())
|
||||
}
|
||||
observeMetrics("quote", trigger, statusLabel, fxUsed, time.Since(start))
|
||||
|
||||
logFields := []zap.Field{
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
orgRef, parseErr := primitive.ObjectIDFromHex(req.GetMeta().GetOrganizationRef())
|
||||
orgRef, parseErr := bson.ObjectIDFromHex(req.GetMeta().GetOrganizationRef())
|
||||
if parseErr != nil {
|
||||
logger.Warn("QuoteFees invalid organization_ref", zap.Error(parseErr))
|
||||
err = status.Error(codes.InvalidArgument, "invalid organization_ref")
|
||||
return nil, err
|
||||
}
|
||||
@@ -105,28 +174,68 @@ func (s *Service) QuoteFees(ctx context.Context, req *feesv1.QuoteFeesRequest) (
|
||||
}
|
||||
|
||||
func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFeesRequest) (resp *feesv1.PrecomputeFeesResponse, err error) {
|
||||
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 req != nil && req.GetIntent() != nil {
|
||||
trigger = req.GetIntent().GetTrigger()
|
||||
if intent != nil {
|
||||
trigger = intent.GetTrigger()
|
||||
}
|
||||
var fxUsed bool
|
||||
var (
|
||||
fxUsed bool
|
||||
expiresAt time.Time
|
||||
)
|
||||
defer func() {
|
||||
statusLabel := statusFromError(err)
|
||||
linesCount := 0
|
||||
appliedCount := 0
|
||||
if err == nil && resp != nil {
|
||||
fxUsed = resp.GetFxUsed() != nil
|
||||
linesCount = len(resp.GetLines())
|
||||
appliedCount = len(resp.GetApplied())
|
||||
if ts := resp.GetExpiresAt(); ts != nil {
|
||||
expiresAt = ts.AsTime()
|
||||
}
|
||||
}
|
||||
observeMetrics("precompute", trigger, statusLabel, fxUsed, time.Since(start))
|
||||
|
||||
logFields := []zap.Field{
|
||||
zap.String("status", statusLabel),
|
||||
zap.Duration("duration", time.Since(start)),
|
||||
zap.Bool("fx_used", fxUsed),
|
||||
zap.String("trigger", trigger.String()),
|
||||
zap.Int("lines", linesCount),
|
||||
zap.Int("applied_rules", appliedCount),
|
||||
}
|
||||
if !expiresAt.IsZero() {
|
||||
logFields = append(logFields, zap.Time("expires_at", expiresAt))
|
||||
}
|
||||
if err != nil {
|
||||
logger.Warn("PrecomputeFees finished", append(logFields, zap.Error(err))...)
|
||||
return
|
||||
}
|
||||
logger.Info("PrecomputeFees finished", logFields...)
|
||||
}()
|
||||
|
||||
logger.Debug("PrecomputeFees request received")
|
||||
|
||||
if err = s.validatePrecomputeRequest(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := s.clock.Now()
|
||||
|
||||
orgRef, parseErr := primitive.ObjectIDFromHex(req.GetMeta().GetOrganizationRef())
|
||||
orgRef, parseErr := bson.ObjectIDFromHex(req.GetMeta().GetOrganizationRef())
|
||||
if parseErr != nil {
|
||||
logger.Warn("PrecomputeFees invalid organization_ref", zap.Error(parseErr))
|
||||
err = status.Error(codes.InvalidArgument, "invalid organization_ref")
|
||||
return nil, err
|
||||
}
|
||||
@@ -141,7 +250,7 @@ func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFees
|
||||
if ttl <= 0 {
|
||||
ttl = 60000
|
||||
}
|
||||
expiresAt := now.Add(time.Duration(ttl) * time.Millisecond)
|
||||
expiresAt = now.Add(time.Duration(ttl) * time.Millisecond)
|
||||
|
||||
payload := feeQuoteTokenPayload{
|
||||
OrganizationRef: req.GetMeta().GetOrganizationRef(),
|
||||
@@ -152,7 +261,7 @@ func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFees
|
||||
|
||||
var token string
|
||||
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")
|
||||
return nil, err
|
||||
}
|
||||
@@ -169,9 +278,18 @@ func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFees
|
||||
}
|
||||
|
||||
func (s *Service) ValidateFeeToken(ctx context.Context, req *feesv1.ValidateFeeTokenRequest) (resp *feesv1.ValidateFeeTokenResponse, err error) {
|
||||
tokenLen := 0
|
||||
if req != nil {
|
||||
tokenLen = len(strings.TrimSpace(req.GetFeeQuoteToken()))
|
||||
}
|
||||
logger := s.logger.With(zap.Int("token_length", tokenLen))
|
||||
|
||||
start := s.clock.Now()
|
||||
trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED
|
||||
var fxUsed bool
|
||||
var (
|
||||
fxUsed bool
|
||||
resultReason string
|
||||
)
|
||||
defer func() {
|
||||
statusLabel := statusFromError(err)
|
||||
if err == nil && resp != nil {
|
||||
@@ -184,9 +302,28 @@ func (s *Service) ValidateFeeToken(ctx context.Context, req *feesv1.ValidateFeeT
|
||||
}
|
||||
}
|
||||
observeMetrics("validate", trigger, statusLabel, fxUsed, time.Since(start))
|
||||
|
||||
logFields := []zap.Field{
|
||||
zap.String("status", statusLabel),
|
||||
zap.Duration("duration", time.Since(start)),
|
||||
zap.Bool("fx_used", fxUsed),
|
||||
zap.String("trigger", trigger.String()),
|
||||
zap.Bool("valid", resp != nil && resp.GetValid()),
|
||||
}
|
||||
if resultReason != "" {
|
||||
logFields = append(logFields, zap.String("reason", resultReason))
|
||||
}
|
||||
if err != nil {
|
||||
logger.Warn("ValidateFeeToken finished", append(logFields, zap.Error(err))...)
|
||||
return
|
||||
}
|
||||
logger.Info("ValidateFeeToken finished", logFields...)
|
||||
}()
|
||||
|
||||
logger.Debug("ValidateFeeToken request received")
|
||||
|
||||
if req == nil || strings.TrimSpace(req.GetFeeQuoteToken()) == "" {
|
||||
resultReason = "missing_token"
|
||||
err = status.Error(codes.InvalidArgument, "fee_quote_token is required")
|
||||
return nil, err
|
||||
}
|
||||
@@ -195,21 +332,29 @@ func (s *Service) ValidateFeeToken(ctx context.Context, req *feesv1.ValidateFeeT
|
||||
|
||||
payload, decodeErr := decodeTokenPayload(req.GetFeeQuoteToken())
|
||||
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"}
|
||||
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 {
|
||||
resultReason = "expired"
|
||||
logger.Info("fee quote token expired")
|
||||
resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "expired"}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
orgRef, parseErr := primitive.ObjectIDFromHex(payload.OrganizationRef)
|
||||
orgRef, parseErr := bson.ObjectIDFromHex(payload.OrganizationRef)
|
||||
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"}
|
||||
return resp, nil
|
||||
}
|
||||
@@ -263,31 +408,60 @@ func (s *Service) validatePrecomputeRequest(req *feesv1.PrecomputeFeesRequest) e
|
||||
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())
|
||||
}
|
||||
|
||||
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
|
||||
if intent.GetBookedAt() != nil && intent.GetBookedAt().IsValid() {
|
||||
bookedAt = intent.GetBookedAt().AsTime()
|
||||
}
|
||||
|
||||
plan, err := s.storage.Plans().GetActivePlan(ctx, orgRef, bookedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, storage.ErrFeePlanNotFound) {
|
||||
return nil, nil, nil, status.Error(codes.NotFound, "fee plan not found")
|
||||
}
|
||||
s.logger.Warn("failed to load active fee plan", zap.Error(err))
|
||||
return nil, nil, nil, status.Error(codes.Internal, "failed to load fee plan")
|
||||
logFields := []zap.Field{
|
||||
zap.Time("booked_at_used", bookedAt),
|
||||
}
|
||||
if !orgRef.IsZero() {
|
||||
logFields = append(logFields, zap.String("organization_ref", orgRef.Hex()))
|
||||
}
|
||||
logFields = append(logFields, logFieldsFromIntent(intent)...)
|
||||
logFields = append(logFields, logFieldsFromTrace(trace)...)
|
||||
logger := s.logger.With(logFields...)
|
||||
|
||||
var orgPtr *bson.ObjectID
|
||||
if !orgRef.IsZero() {
|
||||
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))
|
||||
switch {
|
||||
case errors.Is(err, merrors.ErrNoData):
|
||||
return nil, nil, nil, status.Error(codes.NotFound, fmt.Sprintf("fee rule not found: %s", err.Error()))
|
||||
case errors.Is(err, merrors.ErrDataConflict):
|
||||
return nil, nil, nil, status.Error(codes.FailedPrecondition, fmt.Sprintf("conflicting fee rules: %s", err.Error()))
|
||||
case errors.Is(err, storage.ErrConflictingFeePlans):
|
||||
return nil, nil, nil, status.Error(codes.FailedPrecondition, fmt.Sprintf("conflicting fee plans: %s", err.Error()))
|
||||
case errors.Is(err, storage.ErrFeePlanNotFound):
|
||||
return nil, nil, nil, status.Error(codes.NotFound, fmt.Sprintf("fee plan not found: %s", err.Error()))
|
||||
default:
|
||||
return nil, nil, nil, status.Error(codes.Internal, fmt.Sprintf("failed to resolve fee rule: %s", err.Error()))
|
||||
}
|
||||
}
|
||||
|
||||
originalRules := plan.Rules
|
||||
plan.Rules = []model.FeeRule{*rule}
|
||||
defer func() {
|
||||
plan.Rules = originalRules
|
||||
}()
|
||||
|
||||
result, calcErr := s.calculator.Compute(ctx, plan, intent, bookedAt, trace)
|
||||
if calcErr != nil {
|
||||
if errors.Is(calcErr, merrors.ErrInvalidArg) {
|
||||
return nil, nil, nil, status.Error(codes.InvalidArgument, calcErr.Error())
|
||||
}
|
||||
s.logger.Warn("failed to compute fee quote", zap.Error(calcErr))
|
||||
logger.Warn("failed to compute fee quote", zap.Error(calcErr))
|
||||
return nil, nil, nil, status.Error(codes.Internal, "failed to compute fee quote")
|
||||
}
|
||||
|
||||
|
||||
@@ -2,9 +2,11 @@ package fees
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"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/model"
|
||||
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
||||
@@ -12,7 +14,7 @@ import (
|
||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
@@ -21,7 +23,7 @@ func TestQuoteFees_ComputesDerivedLines(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
now := time.Date(2024, 1, 10, 16, 0, 0, 0, time.UTC)
|
||||
orgRef := primitive.NewObjectID()
|
||||
orgRef := bson.NewObjectID()
|
||||
|
||||
plan := &model.FeePlan{
|
||||
Active: true,
|
||||
@@ -46,8 +48,8 @@ func TestQuoteFees_ComputesDerivedLines(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}
|
||||
plan.SetID(primitive.NewObjectID())
|
||||
plan.SetOrganizationRef(orgRef)
|
||||
plan.SetID(bson.NewObjectID())
|
||||
plan.OrganizationRef = &orgRef
|
||||
|
||||
service := NewService(
|
||||
zap.NewNop(),
|
||||
@@ -121,7 +123,7 @@ func TestQuoteFees_FiltersByAttributesAndDates(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
now := time.Date(2024, 5, 20, 9, 30, 0, 0, time.UTC)
|
||||
orgRef := primitive.NewObjectID()
|
||||
orgRef := bson.NewObjectID()
|
||||
|
||||
plan := &model.FeePlan{
|
||||
Active: true,
|
||||
@@ -160,8 +162,8 @@ func TestQuoteFees_FiltersByAttributesAndDates(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}
|
||||
plan.SetID(primitive.NewObjectID())
|
||||
plan.SetOrganizationRef(orgRef)
|
||||
plan.SetID(bson.NewObjectID())
|
||||
plan.OrganizationRef = &orgRef
|
||||
|
||||
service := NewService(
|
||||
zap.NewNop(),
|
||||
@@ -203,7 +205,7 @@ func TestQuoteFees_RoundingDown(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
now := time.Date(2024, 3, 15, 12, 0, 0, 0, time.UTC)
|
||||
orgRef := primitive.NewObjectID()
|
||||
orgRef := bson.NewObjectID()
|
||||
|
||||
plan := &model.FeePlan{
|
||||
Active: true,
|
||||
@@ -221,8 +223,8 @@ func TestQuoteFees_RoundingDown(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}
|
||||
plan.SetID(primitive.NewObjectID())
|
||||
plan.SetOrganizationRef(orgRef)
|
||||
plan.SetID(bson.NewObjectID())
|
||||
plan.OrganizationRef = &orgRef
|
||||
|
||||
service := NewService(
|
||||
zap.NewNop(),
|
||||
@@ -259,15 +261,25 @@ func TestQuoteFees_UsesInjectedCalculator(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
now := time.Date(2024, 6, 1, 8, 0, 0, 0, time.UTC)
|
||||
orgRef := primitive.NewObjectID()
|
||||
orgRef := bson.NewObjectID()
|
||||
plan := &model.FeePlan{
|
||||
Active: true,
|
||||
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.SetOrganizationRef(orgRef)
|
||||
plan.SetID(bson.NewObjectID())
|
||||
plan.OrganizationRef = &orgRef
|
||||
|
||||
result := &CalculationResult{
|
||||
result := &types.CalculationResult{
|
||||
Lines: []*feesv1.DerivedPostingLine{
|
||||
{
|
||||
LedgerAccountRef: "acct:stub",
|
||||
@@ -322,7 +334,7 @@ func TestQuoteFees_PopulatesFxUsed(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
now := time.Date(2024, 7, 1, 9, 30, 0, 0, time.UTC)
|
||||
orgRef := primitive.NewObjectID()
|
||||
orgRef := bson.NewObjectID()
|
||||
|
||||
plan := &model.FeePlan{
|
||||
Active: true,
|
||||
@@ -340,8 +352,8 @@ func TestQuoteFees_PopulatesFxUsed(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}
|
||||
plan.SetID(primitive.NewObjectID())
|
||||
plan.SetOrganizationRef(orgRef)
|
||||
plan.SetID(bson.NewObjectID())
|
||||
plan.OrganizationRef = &orgRef
|
||||
|
||||
fakeOracle := &oracleclient.Fake{
|
||||
LatestRateFn: func(ctx context.Context, req oracleclient.LatestRateParams) (*oracleclient.RateSnapshot, error) {
|
||||
@@ -409,7 +421,8 @@ func (s *stubRepository) Plans() storage.PlansStore {
|
||||
}
|
||||
|
||||
type stubPlansStore struct {
|
||||
plan *model.FeePlan
|
||||
plan *model.FeePlan
|
||||
globalPlan *model.FeePlan
|
||||
}
|
||||
|
||||
func (s *stubPlansStore) Create(context.Context, *model.FeePlan) error {
|
||||
@@ -420,29 +433,56 @@ func (s *stubPlansStore) Update(context.Context, *model.FeePlan) error {
|
||||
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
|
||||
}
|
||||
|
||||
func (s *stubPlansStore) GetActivePlan(_ context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error) {
|
||||
func (s *stubPlansStore) GetActivePlan(_ context.Context, orgRef bson.ObjectID, at time.Time) (*model.FeePlan, error) {
|
||||
if !orgRef.IsZero() {
|
||||
if plan, err := s.FindActiveOrgPlan(context.Background(), orgRef, at); err == nil {
|
||||
return plan, nil
|
||||
} else if !errors.Is(err, storage.ErrFeePlanNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return s.FindActiveGlobalPlan(context.Background(), at)
|
||||
}
|
||||
|
||||
func (s *stubPlansStore) FindActiveOrgPlan(_ context.Context, orgRef bson.ObjectID, at time.Time) (*model.FeePlan, error) {
|
||||
if s.plan == nil {
|
||||
return nil, storage.ErrFeePlanNotFound
|
||||
}
|
||||
if s.plan.GetOrganizationRef() != orgRef {
|
||||
if (s.plan.OrganizationRef != nil) && (*s.plan.OrganizationRef != orgRef) {
|
||||
return nil, storage.ErrFeePlanNotFound
|
||||
}
|
||||
if !s.plan.Active {
|
||||
return nil, storage.ErrFeePlanNotFound
|
||||
}
|
||||
if !s.plan.EffectiveFrom.Before(at) && !s.plan.EffectiveFrom.Equal(at) {
|
||||
if s.plan.EffectiveFrom.After(at) {
|
||||
return nil, storage.ErrFeePlanNotFound
|
||||
}
|
||||
if s.plan.EffectiveTo != nil && s.plan.EffectiveTo.Before(at) {
|
||||
if s.plan.EffectiveTo != nil && !s.plan.EffectiveTo.After(at) {
|
||||
return nil, storage.ErrFeePlanNotFound
|
||||
}
|
||||
return s.plan, nil
|
||||
}
|
||||
|
||||
func (s *stubPlansStore) FindActiveGlobalPlan(_ context.Context, at time.Time) (*model.FeePlan, error) {
|
||||
if s.globalPlan == nil {
|
||||
return nil, storage.ErrFeePlanNotFound
|
||||
}
|
||||
if !s.globalPlan.Active {
|
||||
return nil, storage.ErrFeePlanNotFound
|
||||
}
|
||||
if s.globalPlan.EffectiveFrom.After(at) {
|
||||
return nil, storage.ErrFeePlanNotFound
|
||||
}
|
||||
if s.globalPlan.EffectiveTo != nil && !s.globalPlan.EffectiveTo.After(at) {
|
||||
return nil, storage.ErrFeePlanNotFound
|
||||
}
|
||||
return s.globalPlan, nil
|
||||
}
|
||||
|
||||
type noopProducer struct{}
|
||||
|
||||
func (noopProducer) SendMessage(me.Envelope) error {
|
||||
@@ -458,14 +498,14 @@ func (f fixedClock) Now() time.Time {
|
||||
}
|
||||
|
||||
type stubCalculator struct {
|
||||
result *CalculationResult
|
||||
result *types.CalculationResult
|
||||
err error
|
||||
called bool
|
||||
gotPlan *model.FeePlan
|
||||
bookedAt time.Time
|
||||
}
|
||||
|
||||
func (s *stubCalculator) Compute(_ context.Context, plan *model.FeePlan, _ *feesv1.Intent, bookedAt time.Time, _ *tracev1.TraceContext) (*CalculationResult, error) {
|
||||
func (s *stubCalculator) Compute(_ context.Context, plan *model.FeePlan, _ *feesv1.Intent, bookedAt time.Time, _ *tracev1.TraceContext) (*types.CalculationResult, error) {
|
||||
s.called = true
|
||||
s.gotPlan = plan
|
||||
s.bookedAt = bookedAt
|
||||
|
||||
23
api/billing/fees/internal/service/fees/trigger.go
Normal file
23
api/billing/fees/internal/service/fees/trigger.go
Normal file
@@ -0,0 +1,23 @@
|
||||
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_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/model"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -25,14 +26,14 @@ const (
|
||||
|
||||
// FeePlan describes a collection of fee rules for an organisation.
|
||||
type FeePlan struct {
|
||||
storable.Base `bson:",inline" json:",inline"`
|
||||
model.OrganizationBoundBase `bson:",inline" json:",inline"`
|
||||
model.Describable `bson:",inline" json:",inline"`
|
||||
Active bool `bson:"active" json:"active"`
|
||||
EffectiveFrom time.Time `bson:"effectiveFrom" json:"effectiveFrom"`
|
||||
EffectiveTo *time.Time `bson:"effectiveTo,omitempty" json:"effectiveTo,omitempty"`
|
||||
Rules []FeeRule `bson:"rules,omitempty" json:"rules,omitempty"`
|
||||
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
|
||||
storable.Base `bson:",inline" json:",inline"`
|
||||
model.Describable `bson:",inline" json:",inline"`
|
||||
OrganizationRef *bson.ObjectID `bson:"organizationRef,omitempty" json:"organizationRef,omitempty"`
|
||||
Active bool `bson:"active" json:"active"`
|
||||
EffectiveFrom time.Time `bson:"effectiveFrom" json:"effectiveFrom"`
|
||||
EffectiveTo *time.Time `bson:"effectiveTo,omitempty" json:"effectiveTo,omitempty"`
|
||||
Rules []FeeRule `bson:"rules,omitempty" json:"rules,omitempty"`
|
||||
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// Collection implements storable.Storable.
|
||||
@@ -42,21 +43,21 @@ func (*FeePlan) Collection() string {
|
||||
|
||||
// FeeRule represents a single pricing rule within a plan.
|
||||
type FeeRule struct {
|
||||
RuleID string `bson:"ruleId" json:"ruleId"`
|
||||
Trigger Trigger `bson:"trigger" json:"trigger"`
|
||||
Priority int `bson:"priority" json:"priority"`
|
||||
Percentage string `bson:"percentage,omitempty" json:"percentage,omitempty"`
|
||||
FixedAmount string `bson:"fixedAmount,omitempty" json:"fixedAmount,omitempty"`
|
||||
Currency string `bson:"currency,omitempty" json:"currency,omitempty"`
|
||||
MinimumAmount string `bson:"minimumAmount,omitempty" json:"minimumAmount,omitempty"`
|
||||
MaximumAmount string `bson:"maximumAmount,omitempty" json:"maximumAmount,omitempty"`
|
||||
AppliesTo map[string]string `bson:"appliesTo,omitempty" json:"appliesTo,omitempty"`
|
||||
Formula string `bson:"formula,omitempty" json:"formula,omitempty"`
|
||||
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
|
||||
LedgerAccountRef string `bson:"ledgerAccountRef,omitempty" json:"ledgerAccountRef,omitempty"`
|
||||
LineType string `bson:"lineType,omitempty" json:"lineType,omitempty"`
|
||||
EntrySide string `bson:"entrySide,omitempty" json:"entrySide,omitempty"`
|
||||
Rounding string `bson:"rounding,omitempty" json:"rounding,omitempty"`
|
||||
EffectiveFrom time.Time `bson:"effectiveFrom" json:"effectiveFrom"`
|
||||
EffectiveTo *time.Time `bson:"effectiveTo,omitempty" json:"effectiveTo,omitempty"`
|
||||
RuleID string `bson:"ruleId" json:"ruleId"`
|
||||
Trigger Trigger `bson:"trigger" json:"trigger"`
|
||||
Priority int `bson:"priority" json:"priority"`
|
||||
Percentage string `bson:"percentage,omitempty" json:"percentage,omitempty"`
|
||||
FixedAmount string `bson:"fixedAmount,omitempty" json:"fixedAmount,omitempty"`
|
||||
Currency string `bson:"currency,omitempty" json:"currency,omitempty"`
|
||||
MinimumAmount string `bson:"minimumAmount,omitempty" json:"minimumAmount,omitempty"`
|
||||
MaximumAmount string `bson:"maximumAmount,omitempty" json:"maximumAmount,omitempty"`
|
||||
AppliesTo map[string]string `bson:"appliesTo,omitempty" json:"appliesTo,omitempty"`
|
||||
Formula string `bson:"formula,omitempty" json:"formula,omitempty"`
|
||||
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
|
||||
LedgerAccountRef string `bson:"ledgerAccountRef,omitempty" json:"ledgerAccountRef,omitempty"`
|
||||
LineType string `bson:"lineType,omitempty" json:"lineType,omitempty"`
|
||||
EntrySide string `bson:"entrySide,omitempty" json:"entrySide,omitempty"`
|
||||
Rounding string `bson:"rounding,omitempty" json:"rounding,omitempty"`
|
||||
EffectiveFrom time.Time `bson:"effectiveFrom" json:"effectiveFrom"`
|
||||
EffectiveTo *time.Time `bson:"effectiveTo,omitempty" json:"effectiveTo,omitempty"`
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"github.com/tech/sendico/pkg/db"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
|
||||
@@ -3,6 +3,9 @@ package store
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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/builder"
|
||||
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/mlogger"
|
||||
m "github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
@@ -53,6 +57,19 @@ func NewPlans(logger mlogger.Logger, db *mongo.Database) (storage.PlansStore, er
|
||||
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{
|
||||
logger: logger.Named("plans"),
|
||||
repo: repo,
|
||||
@@ -60,9 +77,13 @@ func NewPlans(logger mlogger.Logger, db *mongo.Database) (storage.PlansStore, er
|
||||
}
|
||||
|
||||
func (p *plansStore) Create(ctx context.Context, plan *model.FeePlan) error {
|
||||
if plan == nil {
|
||||
return merrors.InvalidArgument("plansStore: nil fee plan")
|
||||
if err := validatePlan(plan); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := p.ensureNoOverlap(ctx, plan); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := p.repo.Insert(ctx, plan, nil); err != nil {
|
||||
if errors.Is(err, merrors.ErrDataConflict) {
|
||||
return storage.ErrDuplicateFeePlan
|
||||
@@ -77,6 +98,13 @@ func (p *plansStore) Update(ctx context.Context, plan *model.FeePlan) error {
|
||||
if plan == nil || plan.GetID() == nil || plan.GetID().IsZero() {
|
||||
return merrors.InvalidArgument("plansStore: invalid fee plan reference")
|
||||
}
|
||||
if err := validatePlan(plan); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := p.ensureNoOverlap(ctx, plan); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := p.repo.Update(ctx, plan); err != nil {
|
||||
p.logger.Warn("failed to update fee plan", zap.Error(err))
|
||||
return err
|
||||
@@ -84,7 +112,7 @@ func (p *plansStore) Update(ctx context.Context, plan *model.FeePlan) error {
|
||||
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() {
|
||||
return nil, merrors.InvalidArgument("plansStore: zero plan reference")
|
||||
}
|
||||
@@ -98,14 +126,43 @@ func (p *plansStore) Get(ctx context.Context, planRef primitive.ObjectID) (*mode
|
||||
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, at time.Time) (*model.FeePlan, error) {
|
||||
// Compatibility shim: prefer org plan, fall back to global; allow zero org to mean global.
|
||||
if orgRef.IsZero() {
|
||||
return p.FindActiveGlobalPlan(ctx, at)
|
||||
}
|
||||
|
||||
plan, err := p.FindActiveOrgPlan(ctx, orgRef, at)
|
||||
if err == nil {
|
||||
return plan, nil
|
||||
}
|
||||
if errors.Is(err, storage.ErrFeePlanNotFound) {
|
||||
return p.FindActiveGlobalPlan(ctx, at)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
func (p *plansStore) FindActiveOrgPlan(ctx context.Context, orgRef bson.ObjectID, at time.Time) (*model.FeePlan, error) {
|
||||
if orgRef.IsZero() {
|
||||
return nil, merrors.InvalidArgument("plansStore: zero organization reference")
|
||||
}
|
||||
query := repository.Query().Filter(repository.OrgField(), orgRef)
|
||||
return p.findActivePlan(ctx, query, at)
|
||||
}
|
||||
|
||||
limit := int64(1)
|
||||
query := repository.Query().
|
||||
Filter(repository.OrgField(), orgRef).
|
||||
func (p *plansStore) FindActiveGlobalPlan(ctx context.Context, at time.Time) (*model.FeePlan, error) {
|
||||
globalQuery := repository.Query().Or(
|
||||
repository.Exists(repository.OrgField(), false),
|
||||
repository.Query().Filter(repository.OrgField(), nil),
|
||||
)
|
||||
return p.findActivePlan(ctx, globalQuery, at)
|
||||
}
|
||||
|
||||
var _ storage.PlansStore = (*plansStore)(nil)
|
||||
|
||||
func (p *plansStore) findActivePlan(ctx context.Context, orgQuery builder.Query, at time.Time) (*model.FeePlan, error) {
|
||||
limit := int64(2)
|
||||
query := orgQuery.
|
||||
Filter(repository.Field("active"), true).
|
||||
Comparison(repository.Field("effectiveFrom"), builder.Lte, at).
|
||||
Sort(repository.Field("effectiveFrom"), false).
|
||||
@@ -118,13 +175,13 @@ func (p *plansStore) GetActivePlan(ctx context.Context, orgRef primitive.ObjectI
|
||||
),
|
||||
)
|
||||
|
||||
var plan *model.FeePlan
|
||||
var plans []*model.FeePlan
|
||||
decoder := func(cursor *mongo.Cursor) error {
|
||||
target := &model.FeePlan{}
|
||||
if err := cursor.Decode(target); err != nil {
|
||||
return err
|
||||
}
|
||||
plan = target
|
||||
plans = append(plans, target)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -135,10 +192,162 @@ func (p *plansStore) GetActivePlan(ctx context.Context, orgRef primitive.ObjectI
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if plan == nil {
|
||||
if len(plans) == 0 {
|
||||
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 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")
|
||||
}
|
||||
}
|
||||
|
||||
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 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
|
||||
}
|
||||
|
||||
orgQuery := repository.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(cursor *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"
|
||||
|
||||
"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
|
||||
@@ -19,6 +19,8 @@ var (
|
||||
ErrFeePlanNotFound = storageError("billing.fees.storage: fee plan not found")
|
||||
// ErrDuplicateFeePlan indicates that a unique plan constraint was violated.
|
||||
ErrDuplicateFeePlan = storageError("billing.fees.storage: duplicate fee plan")
|
||||
// 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.
|
||||
@@ -31,6 +33,7 @@ type Repository interface {
|
||||
type PlansStore interface {
|
||||
Create(ctx context.Context, plan *model.FeePlan) error
|
||||
Update(ctx context.Context, plan *model.FeePlan) error
|
||||
Get(ctx context.Context, planRef primitive.ObjectID) (*model.FeePlan, error)
|
||||
GetActivePlan(ctx context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error)
|
||||
Get(ctx context.Context, planRef bson.ObjectID) (*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
|
||||
195
api/discovery/.golangci.yml
Normal file
195
api/discovery/.golangci.yml
Normal file
@@ -0,0 +1,195 @@
|
||||
# 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:
|
||||
- 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.48.0 // indirect
|
||||
github.com/nats-io/nkeys v0.4.15 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.67.5 // indirect
|
||||
github.com/prometheus/procfs v0.19.2 // indirect
|
||||
github.com/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.50.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-20260209200024-4cfbd4190f57 // indirect
|
||||
google.golang.org/grpc v1.78.0 // 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.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
|
||||
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
||||
github.com/nats-io/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.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
|
||||
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
|
||||
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
|
||||
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/testcontainers/testcontainers-go v0.33.0 h1:zJS9PfXYT5O0ZFXM2xxXfk4J5UMw/kRiISng037Gxdw=
|
||||
github.com/testcontainers/testcontainers-go v0.33.0/go.mod h1:W80YpTa8D5C3Yy16icheD01UTDu+LmXIA2Keo+jWtT8=
|
||||
github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0 h1:iXVA84s5hKMS5gn01GWOYHE3ymy/2b+0YkpFeTxB2XY=
|
||||
github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0/go.mod h1:R6tMjTojRiaoo89fh/hf7tOmfzohdqSU17R9DwSVSog=
|
||||
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
|
||||
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
|
||||
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
|
||||
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
|
||||
github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8=
|
||||
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
|
||||
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
go.mongodb.org/mongo-driver/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.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
|
||||
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
|
||||
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
|
||||
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
|
||||
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
|
||||
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
|
||||
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
|
||||
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
|
||||
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.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.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
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-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
||||
google.golang.org/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=
|
||||
28
api/discovery/internal/appversion/version.go
Normal file
28
api/discovery/internal/appversion/version.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package appversion
|
||||
|
||||
import (
|
||||
"github.com/tech/sendico/pkg/version"
|
||||
vf "github.com/tech/sendico/pkg/version/factory"
|
||||
)
|
||||
|
||||
// Build information. Populated at build-time.
|
||||
var (
|
||||
Version string
|
||||
Revision string
|
||||
Branch string
|
||||
BuildUser string
|
||||
BuildDate string
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
59
api/discovery/internal/server/internal/config.go
Normal file
59
api/discovery/internal/server/internal/config.go
Normal file
@@ -0,0 +1,59 @@
|
||||
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: "DISCOVERY",
|
||||
InstanceID: discovery.InstanceID(),
|
||||
Operations: []string{"discovery.lookup"},
|
||||
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)
|
||||
}
|
||||
126
api/discovery/internal/server/internal/serverimp.go
Normal file
126
api/discovery/internal/server/internal/serverimp.go
Normal file
@@ -0,0 +1,126 @@
|
||||
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
|
||||
|
||||
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) 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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
28
api/discovery/internal/server/internal/types.go
Normal file
28
api/discovery/internal/server/internal/types.go
Normal file
@@ -0,0 +1,28 @@
|
||||
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"
|
||||
)
|
||||
|
||||
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{}
|
||||
}
|
||||
12
api/discovery/internal/server/server.go
Normal file
12
api/discovery/internal/server/server.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
serverimp "github.com/tech/sendico/discovery/internal/server/internal"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/server"
|
||||
)
|
||||
|
||||
//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
|
||||
}
|
||||
18
api/discovery/main.go
Normal file
18
api/discovery/main.go
Normal file
@@ -0,0 +1,18 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,32 +1,46 @@
|
||||
# Config file for Air in TOML format
|
||||
|
||||
root = "./../.."
|
||||
root = "."
|
||||
testdata_dir = "testdata"
|
||||
tmp_dir = "tmp"
|
||||
|
||||
[build]
|
||||
cmd = "go build -o app -ldflags \"-X 'github.com/tech/sendico/fx/ingestor/internal/appversion.BuildUser=$(whoami)' -X 'github.com/tech/sendico/fx/ingestor/internal/appversion.Version=$APP_V' -X 'github.com/tech/sendico/fx/ingestor/internal/appversion.Branch=$BUILD_BRANCH' -X 'github.com/tech/sendico/fx/ingestor/internal/appversion.Revision=$GIT_REV' -X 'github.com/tech/sendico/fx/ingestor/internal/appversion.BuildDate=$(date)'\""
|
||||
bin = "./app"
|
||||
full_bin = "./app --debug --config.file=config.yml"
|
||||
include_ext = ["go", "yaml", "yml"]
|
||||
exclude_dir = ["fx/ingestor/tmp", "pkg/.git", "fx/ingestor/env"]
|
||||
exclude_regex = ["_test\\.go"]
|
||||
exclude_unchanged = true
|
||||
follow_symlink = true
|
||||
log = "air.log"
|
||||
delay = 0
|
||||
stop_on_error = true
|
||||
send_interrupt = true
|
||||
kill_delay = 500
|
||||
args_bin = []
|
||||
|
||||
[log]
|
||||
time = false
|
||||
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]
|
||||
main = "magenta"
|
||||
watcher = "cyan"
|
||||
build = "yellow"
|
||||
runner = "green"
|
||||
app = ""
|
||||
build = "yellow"
|
||||
main = "magenta"
|
||||
runner = "green"
|
||||
watcher = "cyan"
|
||||
|
||||
[log]
|
||||
main_only = false
|
||||
time = false
|
||||
|
||||
[misc]
|
||||
clean_on_exit = true
|
||||
clean_on_exit = false
|
||||
|
||||
[screen]
|
||||
clear_on_rebuild = false
|
||||
keep_scroll = true
|
||||
|
||||
1
api/fx/ingestor/.gitignore
vendored
1
api/fx/ingestor/.gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
internal/generated
|
||||
.gocache
|
||||
/app
|
||||
tmp
|
||||
195
api/fx/ingestor/.golangci.yml
Normal file
195
api/fx/ingestor/.golangci.yml
Normal file
@@ -0,0 +1,195 @@
|
||||
# See the dedicated "version" documentation section.
|
||||
version: "2"
|
||||
linters:
|
||||
# Default set of linters.
|
||||
# The value can be:
|
||||
# - `standard`: https://golangci-lint.run/docs/linters/#enabled-by-default
|
||||
# - `all`: enables all linters by default.
|
||||
# - `none`: disables all linters by default.
|
||||
# - `fast`: enables only linters considered as "fast" (`golangci-lint help linters --json | jq '[ .[] | select(.fast==true) ] | map(.name)'`).
|
||||
# Default: standard
|
||||
default: all
|
||||
# Enable specific linter.
|
||||
enable:
|
||||
- arangolint
|
||||
- asasalint
|
||||
- asciicheck
|
||||
- bidichk
|
||||
- bodyclose
|
||||
- canonicalheader
|
||||
- containedctx
|
||||
- contextcheck
|
||||
- copyloopvar
|
||||
- cyclop
|
||||
- decorder
|
||||
- dogsled
|
||||
- dupl
|
||||
- dupword
|
||||
- durationcheck
|
||||
- embeddedstructfieldcheck
|
||||
- err113
|
||||
- errcheck
|
||||
- errchkjson
|
||||
- errname
|
||||
- errorlint
|
||||
- exhaustive
|
||||
- exptostd
|
||||
- fatcontext
|
||||
- forbidigo
|
||||
- forcetypeassert
|
||||
- funcorder
|
||||
- funlen
|
||||
- ginkgolinter
|
||||
- gocheckcompilerdirectives
|
||||
- gochecknoglobals
|
||||
- gochecknoinits
|
||||
- gochecksumtype
|
||||
- gocognit
|
||||
- goconst
|
||||
- gocritic
|
||||
- gocyclo
|
||||
- godoclint
|
||||
- godot
|
||||
- godox
|
||||
- goheader
|
||||
- gomodguard
|
||||
- goprintffuncname
|
||||
- gosec
|
||||
- gosmopolitan
|
||||
- govet
|
||||
- grouper
|
||||
- iface
|
||||
- importas
|
||||
- inamedparam
|
||||
- ineffassign
|
||||
- interfacebloat
|
||||
- intrange
|
||||
- iotamixing
|
||||
- ireturn
|
||||
- lll
|
||||
- loggercheck
|
||||
- maintidx
|
||||
- makezero
|
||||
- mirror
|
||||
- misspell
|
||||
- mnd
|
||||
- modernize
|
||||
- musttag
|
||||
- nakedret
|
||||
- nestif
|
||||
- nilerr
|
||||
- nilnesserr
|
||||
- nilnil
|
||||
- nlreturn
|
||||
- noctx
|
||||
- noinlineerr
|
||||
- nolintlint
|
||||
- nonamedreturns
|
||||
- nosprintfhostport
|
||||
- paralleltest
|
||||
- perfsprint
|
||||
- prealloc
|
||||
- predeclared
|
||||
- promlinter
|
||||
- protogetter
|
||||
- reassign
|
||||
- recvcheck
|
||||
- revive
|
||||
- rowserrcheck
|
||||
- sloglint
|
||||
- spancheck
|
||||
- sqlclosecheck
|
||||
- staticcheck
|
||||
- tagalign
|
||||
- tagliatelle
|
||||
- testableexamples
|
||||
- testifylint
|
||||
- testpackage
|
||||
- thelper
|
||||
- tparallel
|
||||
- unconvert
|
||||
- unparam
|
||||
- unqueryvet
|
||||
- unused
|
||||
- usestdlibvars
|
||||
- usetesting
|
||||
- varnamelen
|
||||
- wastedassign
|
||||
- whitespace
|
||||
- wsl_v5
|
||||
- zerologlint
|
||||
# Disable specific linters.
|
||||
disable:
|
||||
- depguard
|
||||
- exhaustruct
|
||||
- gochecknoglobals
|
||||
- gomoddirectives
|
||||
- wrapcheck
|
||||
- wsl
|
||||
# All available settings of specific linters.
|
||||
# See the dedicated "linters.settings" documentation section.
|
||||
settings:
|
||||
wsl_v5:
|
||||
allow-first-in-block: true
|
||||
allow-whole-block: false
|
||||
branch-max-lines: 2
|
||||
|
||||
# Defines a set of rules to ignore issues.
|
||||
# It does not skip the analysis, and so does not ignore "typecheck" errors.
|
||||
exclusions:
|
||||
# Mode of the generated files analysis.
|
||||
#
|
||||
# - `strict`: sources are excluded by strictly following the Go generated file convention.
|
||||
# Source files that have lines matching only the following regular expression will be excluded: `^// Code generated .* DO NOT EDIT\.$`
|
||||
# This line must appear before the first non-comment, non-blank text in the file.
|
||||
# https://go.dev/s/generatedcode
|
||||
# - `lax`: sources are excluded if they contain lines like `autogenerated file`, `code generated`, `do not edit`, etc.
|
||||
# - `disable`: disable the generated files exclusion.
|
||||
#
|
||||
# Default: strict
|
||||
generated: lax
|
||||
# Log a warning if an exclusion rule is unused.
|
||||
# Default: false
|
||||
warn-unused: true
|
||||
# Predefined exclusion rules.
|
||||
# Default: []
|
||||
presets:
|
||||
- comments
|
||||
- std-error-handling
|
||||
- common-false-positives
|
||||
- legacy
|
||||
# Excluding configuration per-path, per-linter, per-text and per-source.
|
||||
rules:
|
||||
# Exclude some linters from running on tests files.
|
||||
- path: _test\.go
|
||||
linters:
|
||||
- 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: []
|
||||
74
api/fx/ingestor/config.dev.yml
Normal file
74
api/fx/ingestor/config.dev.yml
Normal file
@@ -0,0 +1,74 @@
|
||||
poll_interval_seconds: 30
|
||||
|
||||
market:
|
||||
sources:
|
||||
- driver: BINANCE
|
||||
settings:
|
||||
base_url: "https://api.binance.com"
|
||||
- driver: COINGECKO
|
||||
settings:
|
||||
base_url: "https://api.coingecko.com/api/v3"
|
||||
- driver: CBR
|
||||
settings:
|
||||
base_url: "https://www.cbr.ru"
|
||||
user_agent: "Mozilla/5.0 (compatible; SendicoFX/1.0; +https://app.sendico.io)"
|
||||
accept_header: "application/xml,text/xml;q=0.9,*/*;q=0.8"
|
||||
pairs:
|
||||
BINANCE:
|
||||
- base: "USDT"
|
||||
quote: "EUR"
|
||||
symbol: "EURUSDT"
|
||||
invert: true
|
||||
- base: "USD"
|
||||
quote: "USDT"
|
||||
symbol: "USDTUSD"
|
||||
invert: true
|
||||
- base: "UAH"
|
||||
quote: "USDT"
|
||||
symbol: "USDTUAH"
|
||||
invert: true
|
||||
- base: "USDC"
|
||||
quote: "EUR"
|
||||
symbol: "EURUSDC"
|
||||
invert: true
|
||||
COINGECKO:
|
||||
- base: "USDT"
|
||||
quote: "RUB"
|
||||
symbol: "tether:rub"
|
||||
CBR:
|
||||
- base: "USD"
|
||||
quote: "RUB"
|
||||
symbol: "USD"
|
||||
provider: "cbr"
|
||||
- base: "EUR"
|
||||
quote: "RUB"
|
||||
symbol: "EUR"
|
||||
provider: "cbr"
|
||||
|
||||
metrics:
|
||||
enabled: true
|
||||
address: ":9102"
|
||||
|
||||
messaging:
|
||||
driver: NATS
|
||||
settings:
|
||||
url_env: NATS_URL
|
||||
host_env: NATS_HOST
|
||||
port_env: NATS_PORT
|
||||
username_env: NATS_USER
|
||||
password_env: NATS_PASSWORD
|
||||
broker_name: FX Ingestor
|
||||
max_reconnects: 10
|
||||
reconnect_wait: 5
|
||||
buffer_size: 1024
|
||||
|
||||
database:
|
||||
driver: mongodb
|
||||
settings:
|
||||
host_env: FX_MONGO_HOST
|
||||
port_env: FX_MONGO_PORT
|
||||
database_env: FX_MONGO_DATABASE
|
||||
user_env: FX_MONGO_USER
|
||||
password_env: FX_MONGO_PASSWORD
|
||||
auth_source_env: FX_MONGO_AUTH_SOURCE
|
||||
replica_set_env: FX_MONGO_REPLICA_SET
|
||||
@@ -8,12 +8,21 @@ market:
|
||||
- driver: COINGECKO
|
||||
settings:
|
||||
base_url: "https://api.coingecko.com/api/v3"
|
||||
- driver: CBR
|
||||
settings:
|
||||
base_url: "https://www.cbr.ru"
|
||||
user_agent: "Mozilla/5.0 (compatible; SendicoFX/1.0; +https://app.sendico.io)"
|
||||
accept_header: "application/xml,text/xml;q=0.9,*/*;q=0.8"
|
||||
pairs:
|
||||
BINANCE:
|
||||
- base: "USDT"
|
||||
quote: "EUR"
|
||||
symbol: "EURUSDT"
|
||||
invert: true
|
||||
- base: "USD"
|
||||
quote: "USDT"
|
||||
symbol: "USDTUSD"
|
||||
invert: true
|
||||
- base: "UAH"
|
||||
quote: "USDT"
|
||||
symbol: "USDTUAH"
|
||||
@@ -26,11 +35,33 @@ market:
|
||||
- base: "USDT"
|
||||
quote: "RUB"
|
||||
symbol: "tether:rub"
|
||||
CBR:
|
||||
- base: "USD"
|
||||
quote: "RUB"
|
||||
symbol: "USD"
|
||||
provider: "cbr"
|
||||
- base: "EUR"
|
||||
quote: "RUB"
|
||||
symbol: "EUR"
|
||||
provider: "cbr"
|
||||
|
||||
metrics:
|
||||
enabled: true
|
||||
address: ":9102"
|
||||
|
||||
messaging:
|
||||
driver: NATS
|
||||
settings:
|
||||
url_env: NATS_URL
|
||||
host_env: NATS_HOST
|
||||
port_env: NATS_PORT
|
||||
username_env: NATS_USER
|
||||
password_env: NATS_PASSWORD
|
||||
broker_name: FX Ingestor
|
||||
max_reconnects: 10
|
||||
reconnect_wait: 5
|
||||
buffer_size: 1024
|
||||
|
||||
database:
|
||||
driver: mongodb
|
||||
settings:
|
||||
|
||||
@@ -1,55 +1,53 @@
|
||||
module github.com/tech/sendico/fx/ingestor
|
||||
|
||||
go 1.25.3
|
||||
go 1.25.7
|
||||
|
||||
replace github.com/tech/sendico/pkg => ../../pkg
|
||||
|
||||
replace github.com/tech/sendico/fx/storage => ../storage
|
||||
|
||||
require (
|
||||
github.com/go-chi/chi/v5 v5.2.3
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
github.com/google/go-cmp v0.7.0
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/tech/sendico/fx/storage v0.0.0
|
||||
github.com/tech/sendico/pkg v0.1.0
|
||||
go.uber.org/zap v1.27.1
|
||||
golang.org/x/net v0.50.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
|
||||
github.com/casbin/casbin/v2 v2.134.0 // indirect
|
||||
github.com/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/v3 v3.7.0 // indirect
|
||||
github.com/casbin/mongodb-adapter/v4 v4.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/golang/snappy v1.0.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/klauspost/compress v1.18.2 // indirect
|
||||
github.com/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/montanaflynn/stats v0.7.1 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/nats-io/nats.go v1.47.0 // indirect
|
||||
github.com/nats-io/nkeys v0.4.12 // indirect
|
||||
github.com/nats-io/nats.go v1.48.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.4 // indirect
|
||||
github.com/prometheus/common v0.67.5 // indirect
|
||||
github.com/prometheus/procfs v0.19.2 // indirect
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||
github.com/xdg-go/scram v1.2.0 // indirect
|
||||
github.com/xdg-go/stringprep v1.0.4 // indirect
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||
go.mongodb.org/mongo-driver v1.17.6 // indirect
|
||||
go.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.45.0 // indirect
|
||||
golang.org/x/net v0.47.0 // indirect
|
||||
golang.org/x/sync v0.18.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/text v0.31.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
||||
google.golang.org/grpc v1.77.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect
|
||||
google.golang.org/grpc v1.78.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
)
|
||||
|
||||
@@ -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/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
|
||||
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||
github.com/casbin/casbin/v2 v2.134.0 h1:wyO3hZb487GzlGVAI2hUoHQT0ehFD+9B5P+HVG9BVTM=
|
||||
github.com/casbin/casbin/v2 v2.134.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
|
||||
github.com/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/v3 v3.7.0 h1:w9c3bea1BGK4eZTAmk17JkY52yv/xSZDSHKji8q+z6E=
|
||||
github.com/casbin/mongodb-adapter/v3 v3.7.0/go.mod h1:F1mu4ojoJVE/8VhIMxMedhjfwRDdIXgANYs6Sd0MgVA=
|
||||
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=
|
||||
@@ -38,8 +38,8 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
|
||||
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 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=
|
||||
@@ -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/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
|
||||
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
|
||||
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/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=
|
||||
@@ -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/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
||||
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
|
||||
github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE=
|
||||
github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
|
||||
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/nats-io/nats.go v1.47.0 h1:YQdADw6J/UfGUd2Oy6tn4Hq6YHxCaJrVKayxxFqYrgM=
|
||||
github.com/nats-io/nats.go v1.47.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
||||
github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
|
||||
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
|
||||
github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
|
||||
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
||||
github.com/nats-io/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=
|
||||
@@ -115,8 +111,8 @@ 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_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc=
|
||||
github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI=
|
||||
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
|
||||
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
||||
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
@@ -150,8 +146,8 @@ 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/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
go.mongodb.org/mongo-driver v1.17.6 h1:87JUG1wZfWsr6rIz3ZmpH90rL5tea7O3IHuSwHUpsss=
|
||||
go.mongodb.org/mongo-driver v1.17.6/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
|
||||
go.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=
|
||||
@@ -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=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
golang.org/x/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.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/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.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
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=
|
||||
@@ -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=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
||||
google.golang.org/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=
|
||||
|
||||
@@ -7,12 +7,15 @@ import (
|
||||
|
||||
"github.com/tech/sendico/fx/ingestor/internal/appversion"
|
||||
"github.com/tech/sendico/fx/ingestor/internal/config"
|
||||
"github.com/tech/sendico/fx/ingestor/internal/fmerrors"
|
||||
"github.com/tech/sendico/fx/ingestor/internal/ingestor"
|
||||
"github.com/tech/sendico/fx/ingestor/internal/metrics"
|
||||
mongostorage "github.com/tech/sendico/fx/storage/mongo"
|
||||
"github.com/tech/sendico/pkg/api/routers/health"
|
||||
"github.com/tech/sendico/pkg/db"
|
||||
"github.com/tech/sendico/pkg/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/mlogger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
@@ -26,7 +29,7 @@ type App struct {
|
||||
|
||||
func New(logger mlogger.Logger, cfgPath string) (*App, error) {
|
||||
if logger == nil {
|
||||
return nil, fmerrors.New("app: logger is nil")
|
||||
return nil, merrors.InvalidArgument("app: logger is nil")
|
||||
}
|
||||
path := strings.TrimSpace(cfgPath)
|
||||
if path == "" {
|
||||
@@ -48,16 +51,17 @@ func (a *App) Run(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
a.logger.Debug("Metrics server initialised")
|
||||
defer metricsSrv.Close(context.Background())
|
||||
defer metricsSrv.Close(context.Background()) //nolint:contextcheck
|
||||
|
||||
conn, err := db.ConnectMongo(a.logger, a.cfg.Database)
|
||||
conn, err := db.ConnectMongo(a.logger, a.cfg.Database) //nolint:contextcheck
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Disconnect(context.Background())
|
||||
defer conn.Disconnect(context.Background()) //nolint:errcheck,contextcheck
|
||||
|
||||
a.logger.Debug("MongoDB connection established")
|
||||
|
||||
repo, err := mongostorage.New(a.logger, conn)
|
||||
repo, err := mongostorage.New(a.logger, conn) //nolint:contextcheck
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -68,6 +72,26 @@ func (a *App) Run(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
var announcer *discovery.Announcer
|
||||
|
||||
if cfg := a.cfg.Messaging; cfg != nil && cfg.Driver != "" {
|
||||
broker, err := msg.CreateMessagingBroker(a.logger.Named("discovery_bus"), cfg)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to initialize discovery broker", zap.Error(err))
|
||||
} else {
|
||||
producer := msgproducer.NewProducer(a.logger.Named("discovery_producer"), broker)
|
||||
announce := discovery.Announcement{
|
||||
Service: "FX_INGESTOR",
|
||||
Operations: []string{"fx.ingest"},
|
||||
Version: appversion.Create().Short(),
|
||||
}
|
||||
announcer = discovery.NewAnnouncer(a.logger, producer, "fx_ingestor", announce)
|
||||
|
||||
announcer.Start()
|
||||
defer announcer.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
a.logger.Info("Starting FX ingestor service", zap.String("version", appversion.Create().Info()))
|
||||
metricsSrv.SetStatus(health.SSRunning)
|
||||
|
||||
@@ -77,6 +101,8 @@ func (a *App) Run(ctx context.Context) error {
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
a.logger.Info("Ingestor service stopped")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -14,8 +14,9 @@ var (
|
||||
BuildDate string
|
||||
)
|
||||
|
||||
//nolint:ireturn
|
||||
func Create() version.Printer {
|
||||
vi := version.Info{
|
||||
info := version.Info{
|
||||
Program: "Sendico FX Ingestor Service",
|
||||
Revision: Revision,
|
||||
Branch: Branch,
|
||||
@@ -23,5 +24,6 @@ func Create() version.Printer {
|
||||
BuildDate: BuildDate,
|
||||
Version: Version,
|
||||
}
|
||||
return vf.Create(&vi)
|
||||
|
||||
return vf.Create(&info)
|
||||
}
|
||||
|
||||
@@ -5,53 +5,60 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/fx/ingestor/internal/fmerrors"
|
||||
mmodel "github.com/tech/sendico/fx/ingestor/internal/model"
|
||||
"github.com/tech/sendico/pkg/db"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/messaging"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const defaultPollInterval = 30 * time.Second
|
||||
|
||||
type Config struct {
|
||||
PollIntervalSeconds int `yaml:"poll_interval_seconds"`
|
||||
Market MarketConfig `yaml:"market"`
|
||||
Database *db.Config `yaml:"database"`
|
||||
Metrics *MetricsConfig `yaml:"metrics"`
|
||||
PollIntervalSeconds int `yaml:"poll_interval_seconds"`
|
||||
Market MarketConfig `yaml:"market"`
|
||||
Database *db.Config `yaml:"database"`
|
||||
Metrics *MetricsConfig `yaml:"metrics"`
|
||||
Messaging *messaging.Config `yaml:"messaging"`
|
||||
|
||||
pairs []Pair
|
||||
pairsBySource map[mmodel.Driver][]PairConfig
|
||||
}
|
||||
|
||||
//nolint:cyclop
|
||||
func Load(path string) (*Config, error) {
|
||||
if path == "" {
|
||||
return nil, fmerrors.New("config: path is empty")
|
||||
return nil, merrors.InvalidArgument("config: path is empty")
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmerrors.Wrap("config: failed to read file", err)
|
||||
return nil, merrors.InternalWrap(err, "config: failed to read file")
|
||||
}
|
||||
|
||||
cfg := &Config{}
|
||||
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||
return nil, fmerrors.Wrap("config: failed to parse yaml", err)
|
||||
|
||||
err = yaml.Unmarshal(data, cfg)
|
||||
if err != nil {
|
||||
return nil, merrors.InternalWrap(err, "config: failed to parse yaml")
|
||||
}
|
||||
|
||||
if len(cfg.Market.Sources) == 0 {
|
||||
return nil, fmerrors.New("config: no market sources configured")
|
||||
return nil, merrors.InvalidArgument("config: no market sources configured")
|
||||
}
|
||||
|
||||
sourceSet := make(map[mmodel.Driver]struct{}, len(cfg.Market.Sources))
|
||||
for idx := range cfg.Market.Sources {
|
||||
src := &cfg.Market.Sources[idx]
|
||||
if src.Driver.IsEmpty() {
|
||||
return nil, fmerrors.New("config: market source driver is empty")
|
||||
return nil, merrors.InvalidArgument("config: market source driver is empty")
|
||||
}
|
||||
|
||||
sourceSet[src.Driver] = struct{}{}
|
||||
}
|
||||
|
||||
if len(cfg.Market.Pairs) == 0 {
|
||||
return nil, fmerrors.New("config: no pairs configured")
|
||||
return nil, merrors.InvalidArgument("config: no pairs configured")
|
||||
}
|
||||
|
||||
normalizedPairs := make(map[string][]PairConfig, len(cfg.Market.Pairs))
|
||||
@@ -61,10 +68,11 @@ func Load(path string) (*Config, error) {
|
||||
for rawSource, pairList := range cfg.Market.Pairs {
|
||||
driver := mmodel.Driver(rawSource)
|
||||
if driver.IsEmpty() {
|
||||
return nil, fmerrors.New("config: pair source is empty")
|
||||
return nil, merrors.InvalidArgument("config: pair source is empty")
|
||||
}
|
||||
|
||||
if _, ok := sourceSet[driver]; !ok {
|
||||
return nil, fmerrors.New("config: pair references unknown source: " + driver.String())
|
||||
return nil, merrors.InvalidArgument("config: pair references unknown source: "+driver.String(), "pairs."+driver.String())
|
||||
}
|
||||
|
||||
processed := make([]PairConfig, len(pairList))
|
||||
@@ -72,10 +80,12 @@ func Load(path string) (*Config, error) {
|
||||
pair := pairList[idx]
|
||||
pair.Base = strings.ToUpper(strings.TrimSpace(pair.Base))
|
||||
pair.Quote = strings.ToUpper(strings.TrimSpace(pair.Quote))
|
||||
|
||||
pair.Symbol = strings.TrimSpace(pair.Symbol)
|
||||
if pair.Base == "" || pair.Quote == "" || pair.Symbol == "" {
|
||||
return nil, fmerrors.New("config: pair entries must define base, quote, and symbol")
|
||||
return nil, merrors.InvalidArgument("config: pair entries must define base, quote, and symbol", "pairs."+driver.String())
|
||||
}
|
||||
|
||||
if strings.TrimSpace(pair.Provider) == "" {
|
||||
pair.Provider = strings.ToLower(driver.String())
|
||||
}
|
||||
@@ -85,6 +95,7 @@ func Load(path string) (*Config, error) {
|
||||
Source: driver,
|
||||
})
|
||||
}
|
||||
|
||||
pairsBySource[driver] = processed
|
||||
normalizedPairs[driver.String()] = processed
|
||||
}
|
||||
@@ -92,14 +103,15 @@ func Load(path string) (*Config, error) {
|
||||
cfg.Market.Pairs = normalizedPairs
|
||||
cfg.pairsBySource = pairsBySource
|
||||
cfg.pairs = flattened
|
||||
|
||||
if cfg.Database == nil {
|
||||
return nil, fmerrors.New("config: database configuration is required")
|
||||
return nil, merrors.InvalidArgument("config: database configuration is required")
|
||||
}
|
||||
|
||||
if cfg.Metrics != nil && cfg.Metrics.Enabled {
|
||||
cfg.Metrics.Address = strings.TrimSpace(cfg.Metrics.Address)
|
||||
if cfg.Metrics.Address == "" {
|
||||
cfg.Metrics.Address = ":9102"
|
||||
cfg.Metrics.Address = ":9102" //nolint:mnd
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,9 +122,11 @@ func (c *Config) PollInterval() time.Duration {
|
||||
if c == nil {
|
||||
return defaultPollInterval
|
||||
}
|
||||
|
||||
if c.PollIntervalSeconds <= 0 {
|
||||
return defaultPollInterval
|
||||
}
|
||||
|
||||
return time.Duration(c.PollIntervalSeconds) * time.Second
|
||||
}
|
||||
|
||||
@@ -120,8 +134,10 @@ func (c *Config) Pairs() []Pair {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
out := make([]Pair, len(c.pairs))
|
||||
copy(out, c.pairs)
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -129,12 +145,14 @@ func (c *Config) PairsBySource() map[mmodel.Driver][]PairConfig {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
out := make(map[mmodel.Driver][]PairConfig, len(c.pairsBySource))
|
||||
for driver, pairs := range c.pairsBySource {
|
||||
cp := make([]PairConfig, len(pairs))
|
||||
copy(cp, pairs)
|
||||
out[driver] = cp
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -142,6 +160,8 @@ func (c *Config) MetricsConfig() *MetricsConfig {
|
||||
if c == nil || c.Metrics == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
cp := *c.Metrics
|
||||
|
||||
return &cp
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@ type PairConfig struct {
|
||||
|
||||
type Pair struct {
|
||||
PairConfig `yaml:",inline"`
|
||||
Source mmodel.Driver `yaml:"-"`
|
||||
|
||||
Source mmodel.Driver `yaml:"-"`
|
||||
}
|
||||
|
||||
type MarketConfig struct {
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
package fmerrors
|
||||
|
||||
type Error struct {
|
||||
message string
|
||||
cause error
|
||||
}
|
||||
|
||||
func (e *Error) Error() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
if e.cause == nil {
|
||||
return e.message
|
||||
}
|
||||
return e.message + ": " + e.cause.Error()
|
||||
}
|
||||
|
||||
func (e *Error) Unwrap() error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return e.cause
|
||||
}
|
||||
|
||||
func New(message string) error {
|
||||
return &Error{message: message}
|
||||
}
|
||||
|
||||
func Wrap(message string, cause error) error {
|
||||
return &Error{message: message, cause: cause}
|
||||
}
|
||||
|
||||
func NewDecimal(value string) error {
|
||||
return &Error{message: "invalid decimal \"" + value + "\""}
|
||||
}
|
||||
@@ -6,11 +6,11 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/fx/ingestor/internal/config"
|
||||
"github.com/tech/sendico/fx/ingestor/internal/fmerrors"
|
||||
"github.com/tech/sendico/fx/ingestor/internal/market"
|
||||
mmodel "github.com/tech/sendico/fx/ingestor/internal/model"
|
||||
"github.com/tech/sendico/fx/storage"
|
||||
"github.com/tech/sendico/fx/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
@@ -26,18 +26,20 @@ type Service struct {
|
||||
|
||||
func New(logger mlogger.Logger, cfg *config.Config, repo storage.Repository) (*Service, error) {
|
||||
if logger == nil {
|
||||
return nil, fmerrors.New("ingestor: nil logger")
|
||||
return nil, merrors.InvalidArgument("ingestor: nil logger")
|
||||
}
|
||||
|
||||
if cfg == nil {
|
||||
return nil, fmerrors.New("ingestor: nil config")
|
||||
return nil, merrors.InvalidArgument("ingestor: nil config")
|
||||
}
|
||||
|
||||
if repo == nil {
|
||||
return nil, fmerrors.New("ingestor: nil repository")
|
||||
return nil, merrors.InvalidArgument("ingestor: nil repository")
|
||||
}
|
||||
|
||||
connectors, err := market.BuildConnectors(logger, cfg.Market.Sources)
|
||||
if err != nil {
|
||||
return nil, fmerrors.Wrap("build connectors", err)
|
||||
return nil, merrors.InternalWrap(err, "build connectors")
|
||||
}
|
||||
|
||||
return &Service{
|
||||
@@ -52,6 +54,7 @@ func New(logger mlogger.Logger, cfg *config.Config, repo storage.Repository) (*S
|
||||
|
||||
func (s *Service) Run(ctx context.Context) error {
|
||||
interval := s.cfg.PollInterval()
|
||||
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
@@ -65,6 +68,7 @@ func (s *Service) Run(ctx context.Context) error {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
s.logger.Info("Context cancelled, stopping ingestor")
|
||||
|
||||
return ctx.Err()
|
||||
case <-ticker.C:
|
||||
if err := s.executePoll(ctx); err != nil {
|
||||
@@ -77,54 +81,76 @@ func (s *Service) Run(ctx context.Context) error {
|
||||
func (s *Service) executePoll(ctx context.Context) error {
|
||||
start := time.Now()
|
||||
err := s.pollOnce(ctx)
|
||||
|
||||
if s.metrics != nil {
|
||||
s.metrics.observePoll(time.Since(start), err)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) pollOnce(ctx context.Context) error {
|
||||
var firstErr error
|
||||
failures := 0
|
||||
|
||||
for _, pair := range s.pairs {
|
||||
start := time.Now()
|
||||
err := s.upsertPair(ctx, pair)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if s.metrics != nil {
|
||||
s.metrics.observePair(pair, elapsed, err)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
|
||||
failures++
|
||||
|
||||
s.logger.Warn("Failed to ingest pair",
|
||||
zap.String("symbol", pair.Symbol),
|
||||
zap.String("source", pair.Source.String()),
|
||||
zap.String("provider", pair.Provider),
|
||||
zap.String("base", pair.Base),
|
||||
zap.String("quote", pair.Quote),
|
||||
zap.Bool("invert", pair.Invert),
|
||||
zap.Duration("elapsed", elapsed),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if failures > 0 {
|
||||
s.logger.Warn("Ingestion poll completed with failures", zap.Int("failures", failures), zap.Int("total", len(s.pairs)))
|
||||
} else {
|
||||
s.logger.Debug("Ingestion poll completed", zap.Int("total", len(s.pairs)))
|
||||
}
|
||||
|
||||
return firstErr
|
||||
}
|
||||
|
||||
//nolint:funlen
|
||||
func (s *Service) upsertPair(ctx context.Context, pair config.Pair) error {
|
||||
connector, ok := s.connectors[pair.Source]
|
||||
if !ok {
|
||||
return fmerrors.Wrap("connector not configured for source "+pair.Source.String(), nil)
|
||||
return merrors.InvalidArgument("connector not configured for source "+pair.Source.String(), "source")
|
||||
}
|
||||
|
||||
ticker, err := connector.FetchTicker(ctx, pair.Symbol)
|
||||
if err != nil {
|
||||
return fmerrors.Wrap("fetch ticker", err)
|
||||
return merrors.InternalWrap(err, "fetch ticker: "+pair.Symbol)
|
||||
}
|
||||
|
||||
bid, err := parseDecimal(ticker.BidPrice)
|
||||
if err != nil {
|
||||
return fmerrors.Wrap("parse bid price", err)
|
||||
return merrors.InvalidArgumentWrap(err, "parse bid price", "bid")
|
||||
}
|
||||
|
||||
ask, err := parseDecimal(ticker.AskPrice)
|
||||
if err != nil {
|
||||
return fmerrors.Wrap("parse ask price", err)
|
||||
return merrors.InvalidArgumentWrap(err, "parse ask price", "ask")
|
||||
}
|
||||
|
||||
if pair.Invert {
|
||||
@@ -137,16 +163,18 @@ func (s *Service) upsertPair(ctx context.Context, pair config.Pair) error {
|
||||
}
|
||||
|
||||
mid := new(big.Rat).Add(bid, ask)
|
||||
mid.Quo(mid, big.NewRat(2, 1))
|
||||
mid.Quo(mid, big.NewRat(2, 1)) //nolint:mnd
|
||||
|
||||
spread := big.NewRat(0, 1)
|
||||
if mid.Sign() != 0 {
|
||||
spread.Sub(ask, bid)
|
||||
|
||||
if spread.Sign() < 0 {
|
||||
spread.Neg(spread)
|
||||
}
|
||||
|
||||
spread.Quo(spread, mid)
|
||||
spread.Mul(spread, big.NewRat(10000, 1)) // basis points
|
||||
spread.Mul(spread, big.NewRat(10000, 1)) //nolint:mnd // basis points
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
@@ -166,15 +194,20 @@ func (s *Service) upsertPair(ctx context.Context, pair config.Pair) error {
|
||||
}
|
||||
|
||||
if err := s.rates.UpsertSnapshot(ctx, snapshot); err != nil {
|
||||
return fmerrors.Wrap("upsert snapshot", err)
|
||||
return merrors.InternalWrap(err, "upsert snapshot")
|
||||
}
|
||||
|
||||
s.logger.Debug("Snapshot ingested",
|
||||
zap.String("pair", pair.Base+"/"+pair.Quote),
|
||||
zap.String("provider", pair.Provider),
|
||||
zap.String("source", pair.Source.String()),
|
||||
zap.String("provider_ref", snapshot.ProviderRef),
|
||||
zap.String("bid", snapshot.Bid),
|
||||
zap.String("ask", snapshot.Ask),
|
||||
zap.String("mid", snapshot.Mid),
|
||||
zap.String("spread_bps", snapshot.SpreadBps),
|
||||
zap.Int64("asof_unix_ms", snapshot.AsOfUnixMs),
|
||||
zap.String("rate_ref", snapshot.RateRef),
|
||||
)
|
||||
|
||||
return nil
|
||||
@@ -183,8 +216,9 @@ func (s *Service) upsertPair(ctx context.Context, pair config.Pair) error {
|
||||
func parseDecimal(value string) (*big.Rat, error) {
|
||||
r := new(big.Rat)
|
||||
if _, ok := r.SetString(value); !ok {
|
||||
return nil, fmerrors.NewDecimal(value)
|
||||
return nil, merrors.InvalidArgument("invalid decimal \""+value+"\"", "value")
|
||||
}
|
||||
|
||||
return r, nil
|
||||
}
|
||||
|
||||
@@ -192,9 +226,11 @@ func invertPrices(bid, ask *big.Rat) (*big.Rat, *big.Rat) {
|
||||
if bid.Sign() == 0 || ask.Sign() == 0 {
|
||||
return bid, ask
|
||||
}
|
||||
|
||||
one := big.NewRat(1, 1)
|
||||
invBid := new(big.Rat).Quo(one, ask) // invert ask to get bid
|
||||
invAsk := new(big.Rat).Quo(one, bid) // invert bid to get ask
|
||||
|
||||
return invBid, invAsk
|
||||
}
|
||||
|
||||
@@ -202,6 +238,7 @@ func formatDecimal(r *big.Rat) string {
|
||||
if r == nil {
|
||||
return "0"
|
||||
}
|
||||
|
||||
// Format with 8 decimal places, trimming trailing zeros.
|
||||
return r.FloatString(8)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user