From 7fbd88b6efdd657724db00de39c64415d7732f75 Mon Sep 17 00:00:00 2001 From: Stephan D Date: Fri, 30 Jan 2026 15:16:20 +0100 Subject: [PATCH] billing docs service --- api/billing/documents/.air.toml | 46 ++ api/billing/documents/.gitignore | 4 + api/billing/documents/config.dev.yml | 50 ++ api/billing/documents/config.yml | 56 +++ api/billing/documents/go.mod | 72 +++ api/billing/documents/go.sum | 275 +++++++++++ .../documents/internal/appversion/version.go | 28 ++ .../documents/internal/docstore/local.go | 61 +++ api/billing/documents/internal/docstore/s3.go | 125 +++++ .../documents/internal/docstore/store.go | 67 +++ .../internal/server/internal/serverimp.go | 144 ++++++ .../documents/internal/server/server.go | 12 + .../internal/service/documents/config.go | 33 ++ .../internal/service/documents/metrics.go | 105 +++++ .../internal/service/documents/service.go | 433 ++++++++++++++++++ .../service/documents/service_test.go | 176 +++++++ .../internal/service/documents/template.go | 60 +++ .../service/documents/template_test.go | 91 ++++ api/billing/documents/main.go | 17 + api/billing/documents/renderer/header.go | 50 ++ api/billing/documents/renderer/layout.go | 221 +++++++++ .../documents/renderer/renderer_test.go | 90 ++++ api/billing/documents/renderer/tags.go | 87 ++++ .../documents/storage/model/document.go | 92 ++++ .../documents/storage/mongo/repository.go | 68 +++ .../storage/mongo/store/documents.go | 145 ++++++ api/billing/documents/storage/storage.go | 32 ++ .../documents/templates/acceptance.tpl | 70 +++ api/billing/fees/go.mod | 4 +- api/billing/fees/go.sum | 8 +- api/discovery/go.mod | 4 +- api/discovery/go.sum | 8 +- api/fx/ingestor/go.mod | 4 +- api/fx/ingestor/go.sum | 8 +- 34 files changed, 2728 insertions(+), 18 deletions(-) create mode 100644 api/billing/documents/.air.toml create mode 100644 api/billing/documents/.gitignore create mode 100644 api/billing/documents/config.dev.yml create mode 100644 api/billing/documents/config.yml create mode 100644 api/billing/documents/go.mod create mode 100644 api/billing/documents/go.sum create mode 100644 api/billing/documents/internal/appversion/version.go create mode 100644 api/billing/documents/internal/docstore/local.go create mode 100644 api/billing/documents/internal/docstore/s3.go create mode 100644 api/billing/documents/internal/docstore/store.go create mode 100644 api/billing/documents/internal/server/internal/serverimp.go create mode 100644 api/billing/documents/internal/server/server.go create mode 100644 api/billing/documents/internal/service/documents/config.go create mode 100644 api/billing/documents/internal/service/documents/metrics.go create mode 100644 api/billing/documents/internal/service/documents/service.go create mode 100644 api/billing/documents/internal/service/documents/service_test.go create mode 100644 api/billing/documents/internal/service/documents/template.go create mode 100644 api/billing/documents/internal/service/documents/template_test.go create mode 100644 api/billing/documents/main.go create mode 100644 api/billing/documents/renderer/header.go create mode 100644 api/billing/documents/renderer/layout.go create mode 100644 api/billing/documents/renderer/renderer_test.go create mode 100644 api/billing/documents/renderer/tags.go create mode 100644 api/billing/documents/storage/model/document.go create mode 100644 api/billing/documents/storage/mongo/repository.go create mode 100644 api/billing/documents/storage/mongo/store/documents.go create mode 100644 api/billing/documents/storage/storage.go create mode 100644 api/billing/documents/templates/acceptance.tpl diff --git a/api/billing/documents/.air.toml b/api/billing/documents/.air.toml new file mode 100644 index 00000000..16f8c34b --- /dev/null +++ b/api/billing/documents/.air.toml @@ -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 diff --git a/api/billing/documents/.gitignore b/api/billing/documents/.gitignore new file mode 100644 index 00000000..4f356934 --- /dev/null +++ b/api/billing/documents/.gitignore @@ -0,0 +1,4 @@ +internal/generated +.gocache +/app +tmp \ No newline at end of file diff --git a/api/billing/documents/config.dev.yml b/api/billing/documents/config.dev.yml new file mode 100644 index 00000000..d5b2db2b --- /dev/null +++ b/api/billing/documents/config.dev.yml @@ -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 diff --git a/api/billing/documents/config.yml b/api/billing/documents/config.yml new file mode 100644 index 00000000..9aa78ca1 --- /dev/null +++ b/api/billing/documents/config.yml @@ -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 diff --git a/api/billing/documents/go.mod b/api/billing/documents/go.mod new file mode 100644 index 00000000..95b7cf9a --- /dev/null +++ b/api/billing/documents/go.mod @@ -0,0 +1,72 @@ +module github.com/tech/sendico/billing/documents + +go 1.25.6 + +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 v1.17.7 + 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/v3 v3.7.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/go-chi/chi/v5 v5.2.4 // indirect + github.com/golang/snappy v1.0.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/klauspost/compress v1.18.3 // 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.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.47.0 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/text v0.33.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect + google.golang.org/protobuf v1.36.11 // indirect +) diff --git a/api/billing/documents/go.sum b/api/billing/documents/go.sum new file mode 100644 index 00000000..ef014504 --- /dev/null +++ b/api/billing/documents/go.sum @@ -0,0 +1,275 @@ +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/v3 v3.7.0 h1:w9c3bea1BGK4eZTAmk17JkY52yv/xSZDSHKji8q+z6E= +github.com/casbin/mongodb-adapter/v3 v3.7.0/go.mod h1:F1mu4ojoJVE/8VhIMxMedhjfwRDdIXgANYs6Sd0MgVA= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/davecgh/go-spew v1.1.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.4 h1:WtFKPHwlywe8Srng8j2BhOD9312j9cGUxG1SP4V2cR4= +github.com/go-chi/chi/v5 v5.2.4/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/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/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= +github.com/jung-kurt/gofpdf v1.16.2 h1:jgbatWHfRlPYiK85qgevsZTHviWXKwB1TTiKdz5PtRc= +github.com/jung-kurt/gofpdf v1.16.2/go.mod h1:1hl7y57EsiPAkLbOwzpzqgx1A30nQCk/YmFV8S2vmK0= +github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw= +github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54 h1:mFWunSatvkQQDhpdyuFAYwyAan3hzCuma+Pz8sqvOfg= +github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= +github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/sys/user v0.3.0 h1:9ni5DlcW5an3SvRSx4MouotOygvzaXbaSrc/wGDFWPo= +github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE= +github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/nats-io/nats.go v1.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 v1.17.7 h1:a9w+U3Vt67eYzcfq3k/OAv284/uUUkL0uP75VE5rCOU= +go.mongodb.org/mongo-driver v1.17.7/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 h1:UP6IpuHFkUgOQL9FFQFrZ+5LiwhhYRbi7VZSIx6Nj5s= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= +go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= +go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/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.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/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-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/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= diff --git a/api/billing/documents/internal/appversion/version.go b/api/billing/documents/internal/appversion/version.go new file mode 100644 index 00000000..7aaa27b4 --- /dev/null +++ b/api/billing/documents/internal/appversion/version.go @@ -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) +} diff --git a/api/billing/documents/internal/docstore/local.go b/api/billing/documents/internal/docstore/local.go new file mode 100644 index 00000000..1544ff94 --- /dev/null +++ b/api/billing/documents/internal/docstore/local.go @@ -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) diff --git a/api/billing/documents/internal/docstore/s3.go b/api/billing/documents/internal/docstore/s3.go new file mode 100644 index 00000000..72db9a3d --- /dev/null +++ b/api/billing/documents/internal/docstore/s3.go @@ -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) diff --git a/api/billing/documents/internal/docstore/store.go b/api/billing/documents/internal/docstore/store.go new file mode 100644 index 00000000..458d65f6 --- /dev/null +++ b/api/billing/documents/internal/docstore/store.go @@ -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") + } +} diff --git a/api/billing/documents/internal/server/internal/serverimp.go b/api/billing/documents/internal/server/internal/serverimp.go new file mode 100644 index 00000000..46dd4315 --- /dev/null +++ b/api/billing/documents/internal/server/internal/serverimp.go @@ -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 +} diff --git a/api/billing/documents/internal/server/server.go b/api/billing/documents/internal/server/server.go new file mode 100644 index 00000000..b51f9f98 --- /dev/null +++ b/api/billing/documents/internal/server/server.go @@ -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) +} diff --git a/api/billing/documents/internal/service/documents/config.go b/api/billing/documents/internal/service/documents/config.go new file mode 100644 index 00000000..5f82d300 --- /dev/null +++ b/api/billing/documents/internal/service/documents/config.go @@ -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 +} diff --git a/api/billing/documents/internal/service/documents/metrics.go b/api/billing/documents/internal/service/documents/metrics.go new file mode 100644 index 00000000..c355fb94 --- /dev/null +++ b/api/billing/documents/internal/service/documents/metrics.go @@ -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 +} diff --git a/api/billing/documents/internal/service/documents/service.go b/api/billing/documents/internal/service/documents/service.go new file mode 100644 index 00000000..b3c89815 --- /dev/null +++ b/api/billing/documents/internal/service/documents/service.go @@ -0,0 +1,433 @@ +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 { + meta.AvailableTypes = toProtoTypes(record.Available) + meta.ReadyTypes = toProtoTypes(record.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 !containsDocType(record.Available, targetType) { + return nil, status.Error(codes.NotFound, "document type not available") + } + + 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 + } + + if docType != documentsv1.DocumentType_DOCUMENT_TYPE_ACT { + return nil, status.Error(codes.Unimplemented, "document type not implemented") + } + + 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 + record.Ready = appendUnique(record.Ready, targetType) + 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 + } + fileHash := sha256.Sum256(finalBytes) + return finalBytes, hex.EncodeToString(fileHash[:]), nil +} + +func containsDocType(list []model.DocumentType, target model.DocumentType) bool { + for _, entry := range list { + if entry == target { + return true + } + } + return false +} + +func appendUnique(list []model.DocumentType, value model.DocumentType) []model.DocumentType { + if containsDocType(list, value) { + return list + } + return append(list, value) +} + +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) +} diff --git a/api/billing/documents/internal/service/documents/service_test.go b/api/billing/documents/internal/service/documents/service_test.go new file mode 100644 index 00000000..56d3ec47 --- /dev/null +++ b/api/billing/documents/internal/service/documents/service_test.go @@ -0,0 +1,176 @@ +package documents + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "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", + OrgLegalName: "Acme Corp", + OrgAddress: "42 Galaxy Way", + } + + record := &model.DocumentRecord{ + PaymentRef: "PAY-123", + Snapshot: snapshot, + Available: []model.DocumentType{model.DocumentTypeAct}, + } + + 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") + } + + hash1 := sha256.Sum256(resp1.Content) + stored := record.Hashes[model.DocumentTypeAct] + if stored == "" { + t.Fatalf("expected stored hash") + } + if stored != hex.EncodeToString(hash1[:]) { + 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") + } +} diff --git a/api/billing/documents/internal/service/documents/template.go b/api/billing/documents/internal/service/documents/template.go new file mode 100644 index 00000000..63c7cd46 --- /dev/null +++ b/api/billing/documents/internal/service/documents/template.go @@ -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") +} diff --git a/api/billing/documents/internal/service/documents/template_test.go b/api/billing/documents/internal/service/documents/template_test.go new file mode 100644 index 00000000..3ae160f7 --- /dev/null +++ b/api/billing/documents/internal/service/documents/template_test.go @@ -0,0 +1,91 @@ +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", + OrgLegalName: "Acme Corp", + OrgAddress: "42 Galaxy Way", + } + + 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") + } + foundOrg := false + for _, row := range kv.Rows { + if len(row) >= 2 && row[0] == "Customer" && row[1] == snapshot.OrgLegalName { + foundOrg = true + break + } + } + if !foundOrg { + t.Fatalf("expected org 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 +} diff --git a/api/billing/documents/main.go b/api/billing/documents/main.go new file mode 100644 index 00000000..ada3672f --- /dev/null +++ b/api/billing/documents/main.go @@ -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) +} diff --git a/api/billing/documents/renderer/header.go b/api/billing/documents/renderer/header.go new file mode 100644 index 00000000..b7920066 --- /dev/null +++ b/api/billing/documents/renderer/header.go @@ -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 +} diff --git a/api/billing/documents/renderer/layout.go b/api/billing/documents/renderer/layout.go new file mode 100644 index 00000000..94e1ad7f --- /dev/null +++ b/api/billing/documents/renderer/layout.go @@ -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 +} diff --git a/api/billing/documents/renderer/renderer_test.go b/api/billing/documents/renderer/renderer_test.go new file mode 100644 index 00000000..942de80b --- /dev/null +++ b/api/billing/documents/renderer/renderer_test.go @@ -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 +} diff --git a/api/billing/documents/renderer/tags.go b/api/billing/documents/renderer/tags.go new file mode 100644 index 00000000..4d354adf --- /dev/null +++ b/api/billing/documents/renderer/tags.go @@ -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 +} diff --git a/api/billing/documents/storage/model/document.go b/api/billing/documents/storage/model/document.go new file mode 100644 index 00000000..eb57f010 --- /dev/null +++ b/api/billing/documents/storage/model/document.go @@ -0,0 +1,92 @@ +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"` + OrgLegalName string `bson:"orgLegalName" json:"orgLegalName"` + OrgAddress string `bson:"orgAddress" json:"orgAddress"` +} + +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) + s.OrgLegalName = strings.TrimSpace(s.OrgLegalName) + s.OrgAddress = strings.TrimSpace(s.OrgAddress) +} + +// 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"` + Available []DocumentType `bson:"availableTypes,omitempty" json:"availableTypes,omitempty"` + Ready []DocumentType `bson:"readyTypes,omitempty" json:"readyTypes,omitempty"` + 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 +} diff --git a/api/billing/documents/storage/mongo/repository.go b/api/billing/documents/storage/mongo/repository.go new file mode 100644 index 00000000..3de37f50 --- /dev/null +++ b/api/billing/documents/storage/mongo/repository.go @@ -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/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) diff --git a/api/billing/documents/storage/mongo/store/documents.go b/api/billing/documents/storage/mongo/store/documents.go new file mode 100644 index 00000000..5717d701 --- /dev/null +++ b/api/billing/documents/storage/mongo/store/documents.go @@ -0,0 +1,145 @@ +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/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, + }, + { + Keys: []ri.Key{{Field: "availableTypes", Sort: ri.Asc}}, + }, + { + Keys: []ri.Key{{Field: "readyTypes", Sort: ri.Asc}}, + }, + } + + 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) diff --git a/api/billing/documents/storage/storage.go b/api/billing/documents/storage/storage.go new file mode 100644 index 00000000..742fd0e1 --- /dev/null +++ b/api/billing/documents/storage/storage.go @@ -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) +} diff --git a/api/billing/documents/templates/acceptance.tpl b/api/billing/documents/templates/acceptance.tpl new file mode 100644 index 00000000..3a3413cd --- /dev/null +++ b/api/billing/documents/templates/acceptance.tpl @@ -0,0 +1,70 @@ +#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 +Customer | {{ .OrgLegalName }} +Address | {{ .OrgAddress }} + +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 ___________________________ diff --git a/api/billing/fees/go.mod b/api/billing/fees/go.mod index c09712e6..6cd5deac 100644 --- a/api/billing/fees/go.mod +++ b/api/billing/fees/go.mod @@ -32,7 +32,7 @@ require ( 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.48.0 // indirect - github.com/nats-io/nkeys v0.4.14 // 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 @@ -49,6 +49,6 @@ require ( golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.40.0 // indirect golang.org/x/text v0.33.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260122232226-8e98ce8d340d // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect google.golang.org/protobuf v1.36.11 ) diff --git a/api/billing/fees/go.sum b/api/billing/fees/go.sum index dacbccd6..4ae9f99b 100644 --- a/api/billing/fees/go.sum +++ b/api/billing/fees/go.sum @@ -97,8 +97,8 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq 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.14 h1:ofx8UiyHP5S4Q52/THHucCJsMWu6zhf4DLh0U2593HE= -github.com/nats-io/nkeys v0.4.14/go.mod h1:seG5UKwYdZXb7M1y1vvu53mNh3xq2B6um/XUgYAgvkM= +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= @@ -212,8 +212,8 @@ 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-20260122232226-8e98ce8d340d h1:xXzuihhT3gL/ntduUZwHECzAn57E8dA6l8SOtYWdD8Q= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260122232226-8e98ce8d340d/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/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= diff --git a/api/discovery/go.mod b/api/discovery/go.mod index 636dc60a..3b15e4e3 100644 --- a/api/discovery/go.mod +++ b/api/discovery/go.mod @@ -28,7 +28,7 @@ require ( 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.48.0 // indirect - github.com/nats-io/nkeys v0.4.14 // 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 @@ -45,7 +45,7 @@ require ( golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.40.0 // indirect golang.org/x/text v0.33.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260122232226-8e98ce8d340d // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect google.golang.org/grpc v1.78.0 // indirect google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/api/discovery/go.sum b/api/discovery/go.sum index dacbccd6..4ae9f99b 100644 --- a/api/discovery/go.sum +++ b/api/discovery/go.sum @@ -97,8 +97,8 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq 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.14 h1:ofx8UiyHP5S4Q52/THHucCJsMWu6zhf4DLh0U2593HE= -github.com/nats-io/nkeys v0.4.14/go.mod h1:seG5UKwYdZXb7M1y1vvu53mNh3xq2B6um/XUgYAgvkM= +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= @@ -212,8 +212,8 @@ 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-20260122232226-8e98ce8d340d h1:xXzuihhT3gL/ntduUZwHECzAn57E8dA6l8SOtYWdD8Q= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260122232226-8e98ce8d340d/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/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= diff --git a/api/fx/ingestor/go.mod b/api/fx/ingestor/go.mod index 3eb03dba..5d750a5e 100644 --- a/api/fx/ingestor/go.mod +++ b/api/fx/ingestor/go.mod @@ -33,7 +33,7 @@ require ( 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.48.0 // indirect - github.com/nats-io/nkeys v0.4.14 // 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 @@ -49,7 +49,7 @@ require ( golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.40.0 // indirect golang.org/x/text v0.33.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260122232226-8e98ce8d340d // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect google.golang.org/grpc v1.78.0 // indirect google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/api/fx/ingestor/go.sum b/api/fx/ingestor/go.sum index dacbccd6..4ae9f99b 100644 --- a/api/fx/ingestor/go.sum +++ b/api/fx/ingestor/go.sum @@ -97,8 +97,8 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq 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.14 h1:ofx8UiyHP5S4Q52/THHucCJsMWu6zhf4DLh0U2593HE= -github.com/nats-io/nkeys v0.4.14/go.mod h1:seG5UKwYdZXb7M1y1vvu53mNh3xq2B6um/XUgYAgvkM= +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= @@ -212,8 +212,8 @@ 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-20260122232226-8e98ce8d340d h1:xXzuihhT3gL/ntduUZwHECzAn57E8dA6l8SOtYWdD8Q= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260122232226-8e98ce8d340d/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/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=